From d008aaa614d1eeeab653cc940edea9a49b9a53bf Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 17 Jun 2026 12:10:57 +0200 Subject: [PATCH 1/2] Knowledge: Introduce the wp_knowledge custom post type. Register wp_knowledge, a private-by-default storage primitive for structured site knowledge, together with the wp_knowledge_type taxonomy. Both are built-in and headless (no admin UI); rows are managed through the REST API. Add WP_REST_Knowledge_Controller at /wp/v2/knowledge: reads require an authenticated user, collections are scoped to rows the current user can read, callers without the publish capability are limited to the private status, and new rows default to private. Revisions use the default controller; autosave endpoints are disabled. Synthesize a graded capability set through the user_has_cap filter: administrators manage all knowledge, while contributors and above create and manage their own private rows. Knowledge types are filterable via wp_knowledge_types(), with note as the save-time fallback. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/capabilities.php | 81 + src/wp-includes/default-filters.php | 5 + src/wp-includes/knowledge.php | 142 ++ src/wp-includes/post.php | 50 + .../class-wp-rest-knowledge-controller.php | 131 ++ src/wp-includes/taxonomy.php | 28 + src/wp-settings.php | 2 + .../phpunit/tests/knowledge/capabilities.php | 197 +++ tests/phpunit/tests/knowledge/postType.php | 141 ++ tests/phpunit/tests/knowledge/types.php | 142 ++ .../tests/rest-api/rest-schema-setup.php | 6 + .../rest-api/wpRestKnowledgeController.php | 278 ++++ tests/qunit/fixtures/wp-api-generated.js | 1327 ++++++++++++++++- 13 files changed, 2465 insertions(+), 65 deletions(-) create mode 100644 src/wp-includes/knowledge.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php create mode 100644 tests/phpunit/tests/knowledge/capabilities.php create mode 100644 tests/phpunit/tests/knowledge/postType.php create mode 100644 tests/phpunit/tests/knowledge/types.php create mode 100644 tests/phpunit/tests/rest-api/wpRestKnowledgeController.php diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 028e61ec414a8..d4f4991110b15 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1365,6 +1365,87 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { return $allcaps; } +/** + * Filters the user capabilities to grant the `wp_knowledge` post type capabilities as necessary. + * + * The `wp_knowledge` post type uses a `knowledge`-prefixed capability set that is + * granted dynamically rather than stored on roles. Administrators (users with + * `manage_options`) receive every knowledge capability. Contributors, authors, + * and editors (users with `edit_posts`) may list and create knowledge rows and + * fully manage their own private rows; publishing knowledge and acting on other + * users' rows is reserved for administrators. Subscribers receive nothing and + * are stopped at the post-type door by the `read_knowledge` mapping. + * + * @since 7.1.0 + * + * @param bool[] $allcaps An array of all the user's capabilities. + * @param string[] $caps Required primitive capabilities for the requested capability. + * @param array $args { + * Arguments that accompany the requested capability check. + * + * @type string $0 Requested capability. + * @type int $1 Concerned user ID. + * @type mixed ...$2 Optional second and further parameters, typically object ID. + * } + * @param WP_User $user The user object. + * @return bool[] Filtered array of the user's capabilities. + */ +function wp_maybe_grant_knowledge_caps( array $allcaps, array $caps, array $args, WP_User $user ): array { + if ( ! empty( $allcaps['manage_options'] ) ) { + $allcaps['read_knowledge'] = true; + $allcaps['edit_knowledge'] = true; + $allcaps['edit_others_knowledge'] = true; + $allcaps['edit_published_knowledge'] = true; + $allcaps['edit_private_knowledge'] = true; + $allcaps['publish_knowledge'] = true; + $allcaps['delete_knowledge'] = true; + $allcaps['delete_others_knowledge'] = true; + $allcaps['delete_published_knowledge'] = true; + $allcaps['delete_private_knowledge'] = true; + $allcaps['read_private_knowledge'] = true; + + return $allcaps; + } + + if ( empty( $allcaps['edit_posts'] ) ) { + return $allcaps; + } + + /* + * Ambient floor for contributors and above: `read_knowledge` clears the + * post-type read check; `edit_knowledge` clears the create and ownership + * checks that do not pass a post ID. Per-post primitives are granted only + * in the per-post branch below. + */ + $allcaps['read_knowledge'] = true; + $allcaps['edit_knowledge'] = true; + + if ( ! isset( $args[0], $args[2] ) ) { + return $allcaps; + } + + if ( ! in_array( $args[0], array( 'edit_post', 'delete_post', 'read_post' ), true ) ) { + return $allcaps; + } + + $post = get_post( $args[2] ); + if ( + ! $post instanceof WP_Post || + 'wp_knowledge' !== $post->post_type || + (int) $post->post_author !== (int) $user->ID || + 'private' !== $post->post_status + ) { + return $allcaps; + } + + $allcaps['edit_private_knowledge'] = true; + $allcaps['delete_knowledge'] = true; + $allcaps['delete_private_knowledge'] = true; + $allcaps['read_private_knowledge'] = true; + + return $allcaps; +} + return; // Dummy gettext calls to get strings in the catalog. diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 5581828a10b61..81b9b274eedfc 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -773,6 +773,7 @@ add_filter( 'user_has_cap', 'wp_maybe_grant_install_languages_cap', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_resume_extensions_caps', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_site_health_caps', 1, 4 ); +add_filter( 'user_has_cap', 'wp_maybe_grant_knowledge_caps', 1, 4 ); // Block templates post type and rendering. add_filter( 'render_block_context', '_block_template_render_without_post_block_context' ); @@ -786,6 +787,10 @@ // wp_navigation post type. add_filter( 'rest_wp_navigation_item_schema', array( 'WP_Navigation_Fallback', 'update_wp_navigation_post_schema' ) ); +// wp_knowledge post type. +add_action( 'save_post_wp_knowledge', '_wp_knowledge_ensure_default_type_term' ); +add_filter( 'wp_insert_term_data', '_wp_knowledge_maybe_map_term_label', 10, 2 ); + // Fluid typography. add_filter( 'render_block', 'wp_render_typography_support', 10, 2 ); diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php new file mode 100644 index 0000000000000..9956bfddce88b --- /dev/null +++ b/src/wp-includes/knowledge.php @@ -0,0 +1,142 @@ + + */ +function wp_knowledge_types(): array { + /** + * Filters the knowledge types available on this site. + * + * @since 7.1.0 + * + * @param array $types { + * Slug-keyed map of knowledge types. + * + * @type array ...$0 { + * Data for a single knowledge type. + * + * @type string $title The human-readable label for the type. + * } + * } + * @phpstan-param array $types + */ + return apply_filters( + 'wp_knowledge_types', + array( + 'guideline' => array( + 'title' => _x( 'Guideline', 'knowledge type' ), + ), + 'memory' => array( + 'title' => _x( 'Memory', 'knowledge type' ), + ), + 'note' => array( + 'title' => _x( 'Note', 'knowledge type' ), + ), + ) + ); +} + +/** + * Assigns the `note` fallback term when a knowledge post is saved without a type. + * + * Hooked to the `save_post_wp_knowledge` action so that every knowledge row has + * at least one `wp_knowledge_type` term. Uses get_the_terms() so the check is + * served by the object term cache. + * + * @since 7.1.0 + * @access private + * + * @param int $post_id Saved post ID. + */ +function _wp_knowledge_ensure_default_type_term( int $post_id ): void { + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + $terms = get_the_terms( $post_id, 'wp_knowledge_type' ); + if ( is_wp_error( $terms ) || ! empty( $terms ) ) { + return; + } + + /* + * Resolve to a term ID up front, creating the term on first use: + * wp_set_object_terms() interprets strings as names for hierarchical + * taxonomies, not slugs. + */ + $term = term_exists( 'note', 'wp_knowledge_type' ); + if ( ! $term ) { + $term = wp_insert_term( 'note', 'wp_knowledge_type' ); + if ( is_wp_error( $term ) ) { + return; + } + } + + wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); +} + +/** + * Swaps a raw knowledge-type slug for its registered label on term creation. + * + * Hooked to the `wp_insert_term_data` filter. When wp_set_object_terms() is + * called with a slug that does not yet exist, wp_insert_term() fires and this + * filter runs after WordPress has computed both `name` and `slug`. A `name` + * equal to `slug` indicates the term was created from a raw slug (for example by + * wp_set_object_terms()) rather than from a user-provided label, so the label is + * replaced with the title from wp_knowledge_types(). Because term names are + * persisted in the database, the translated title is stored in the locale active + * when the term is created. + * + * @since 7.1.0 + * @access private + * + * @param array $data Term data to be inserted (keyed by column name). + * @param string $taxonomy Taxonomy slug. + * @return array Possibly modified term data. + * + * @phpstan-param array $data + * @phpstan-return array + */ +function _wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { + if ( 'wp_knowledge_type' !== $taxonomy ) { + return $data; + } + + if ( $data['name'] !== $data['slug'] ) { + return $data; + } + + $types = wp_knowledge_types(); + if ( isset( $types[ $data['slug'] ] ) ) { + $data['name'] = $types[ $data['slug'] ]['title']; + } + + return $data; +} diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 005ccadd62e34..519ca45b6f20c 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -16,6 +16,7 @@ * See {@see 'init'}. * * @since 2.9.0 + * @since 7.1.0 Added the `wp_knowledge` post type. */ function create_initial_post_types() { WP_Post_Type::reset_default_labels(); @@ -657,6 +658,55 @@ function create_initial_post_types() { ) ); + register_post_type( + 'wp_knowledge', + array( + 'labels' => array( + 'name' => _x( 'Knowledge', 'post type general name' ), + 'singular_name' => _x( 'Knowledge Item', 'post type singular name' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + /* + * Knowledge rows have no native post-type screens; they are managed + * through the REST API and consuming features, not the wp-admin UI. + */ + 'show_ui' => false, + 'map_meta_cap' => true, + /* + * "Knowledge" is a mass noun, so the singular and plural capability + * bases must differ: with both set to `knowledge`, the generated + * per-post meta caps (`edit_knowledge_item`) would collide with the + * primitive caps (`edit_knowledge`). The `*_knowledge_item` forms are + * never granted directly; `map_meta_cap()` resolves them onto the + * primitives, which `wp_maybe_grant_knowledge_caps()` synthesizes. + */ + 'capability_type' => array( 'knowledge_item', 'knowledge' ), + /* + * `read` is remapped so that subscribers (who hold the base `read` + * capability) are stopped at the post-type door. Every other + * primitive defaults to a `knowledge`-prefixed capability granted by + * `wp_maybe_grant_knowledge_caps()`. + */ + 'capabilities' => array( + 'read' => 'read_knowledge', + ), + 'query_var' => false, + 'rewrite' => false, + 'show_in_rest' => true, + 'rest_base' => 'knowledge', + 'rest_controller_class' => 'WP_REST_Knowledge_Controller', + 'supports' => array( 'title', 'editor', 'excerpt', 'author', 'revisions' ), + ) + ); + /* + * Disable autosave endpoints for knowledge. 'editor' support implies + * 'autosave', but knowledge is headless storage with no editor session, so + * the autosave REST routes have no consumer. Revision history is retained. + */ + remove_post_type_support( 'wp_knowledge', 'autosave' ); + register_post_status( 'publish', array( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php new file mode 100644 index 0000000000000..14e3b4bd64def --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php @@ -0,0 +1,131 @@ +post_type ); + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to view knowledge.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return parent::get_items_permissions_check( $request ); + } + + /** + * Determines the allowed query_vars for a get_items() response and prepares + * them for WP_Query. + * + * Scopes the collection to rows readable by the current user so that the + * total count and pagination headers reflect per-user visibility. + * + * @since 7.1.0 + * + * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. + * @param WP_REST_Request $request Optional. Full details about the request. + * @return array Items query arguments. + */ + protected function prepare_items_query( $prepared_args = array(), $request = null ) { + $query_args = parent::prepare_items_query( $prepared_args, $request ); + $query_args['perm'] = 'readable'; + + return $query_args; + } + + /** + * Checks if a knowledge row can be read. + * + * A row is readable only when the current user passes the `read_post` + * capability check, which accounts for the row's author and status. + * + * @since 7.1.0 + * + * @param WP_Post $post Post object. + * @return bool Whether the post can be read. + */ + public function check_read_permission( $post ) { + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return false; + } + + return parent::check_read_permission( $post ); + } + + /** + * Determines validity and normalizes the given status parameter. + * + * Callers without the publish capability may only set the `private` status. + * + * @since 7.1.0 + * + * @param string $post_status The post status. + * @param WP_Post_Type $post_type The post type object. + * @return string|WP_Error Post status or WP_Error if not allowed. + */ + protected function handle_status_param( $post_status, $post_type ) { + if ( ! current_user_can( $post_type->cap->publish_posts ) ) { + if ( 'private' !== $post_status ) { + return new WP_Error( + 'rest_cannot_publish', + __( 'Sorry, you are only allowed to set knowledge to a private status.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return $post_status; + } + + return parent::handle_status_param( $post_status, $post_type ); + } + + /** + * Prepares a single knowledge row for create or update. + * + * New rows default to the `private` status when no status is supplied. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + if ( ! isset( $request['id'] ) && null === $request['status'] ) { + $request->set_param( 'status', 'private' ); + } + + return parent::prepare_item_for_database( $request ); + } +} diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 80f457de0e6f7..1077c5c65b808 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -19,6 +19,7 @@ * * @since 2.8.0 * @since 5.9.0 Added `'wp_template_part_area'` taxonomy. + * @since 7.1.0 Added `'wp_knowledge_type'` taxonomy. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. */ @@ -261,6 +262,33 @@ function create_initial_taxonomies() { 'show_tagcloud' => false, ) ); + + register_taxonomy( + 'wp_knowledge_type', + array( 'wp_knowledge' ), + array( + 'public' => false, + 'publicly_queryable' => false, + 'hierarchical' => true, + 'labels' => array( + 'name' => _x( 'Knowledge Types', 'taxonomy general name' ), + 'singular_name' => _x( 'Knowledge Type', 'taxonomy singular name' ), + ), + 'capabilities' => array( + 'manage_terms' => 'manage_options', + 'edit_terms' => 'edit_knowledge', + 'delete_terms' => 'manage_options', + 'assign_terms' => 'edit_knowledge', + ), + 'query_var' => false, + 'rewrite' => false, + 'show_ui' => false, + '_builtin' => true, + 'show_in_nav_menus' => false, + 'show_in_rest' => true, + 'show_admin_column' => true, + ) + ); } /** diff --git a/src/wp-settings.php b/src/wp-settings.php index ef5c7784ee561..f10b71095aa28 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -254,6 +254,7 @@ require ABSPATH . WPINC . '/class-wp-term.php'; require ABSPATH . WPINC . '/class-wp-term-query.php'; require ABSPATH . WPINC . '/class-wp-tax-query.php'; +require ABSPATH . WPINC . '/knowledge.php'; require ABSPATH . WPINC . '/update.php'; require ABSPATH . WPINC . '/canonical.php'; require ABSPATH . WPINC . '/shortcodes.php'; @@ -356,6 +357,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-knowledge-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icons-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; diff --git a/tests/phpunit/tests/knowledge/capabilities.php b/tests/phpunit/tests/knowledge/capabilities.php new file mode 100644 index 0000000000000..40f58827f4b96 --- /dev/null +++ b/tests/phpunit/tests/knowledge/capabilities.php @@ -0,0 +1,197 @@ +user->create( array( 'role' => $role ) ); + } + + self::$own_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + self::$own_published = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'publish', + 'post_author' => self::$users['contributor'], + ) + ); + + self::$others_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['author'], + ) + ); + } + + /** + * @ticket 65476 + */ + public function test_administrator_has_every_primitive() { + wp_set_current_user( self::$users['administrator'] ); + + $this->assertTrue( current_user_can( 'read_knowledge' ) ); + $this->assertTrue( current_user_can( 'edit_knowledge' ) ); + $this->assertTrue( current_user_can( 'edit_others_knowledge' ) ); + $this->assertTrue( current_user_can( 'publish_knowledge' ) ); + $this->assertTrue( current_user_can( 'delete_knowledge' ) ); + $this->assertTrue( current_user_can( 'delete_others_knowledge' ) ); + $this->assertTrue( current_user_can( 'read_private_knowledge' ) ); + } + + /** + * @ticket 65476 + */ + public function test_administrator_can_act_on_others_rows() { + wp_set_current_user( self::$users['administrator'] ); + + $this->assertTrue( current_user_can( 'edit_post', self::$others_private ) ); + $this->assertTrue( current_user_can( 'read_post', self::$others_private ) ); + $this->assertTrue( current_user_can( 'delete_post', self::$others_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_subscriber_has_no_access() { + wp_set_current_user( self::$users['subscriber'] ); + + $this->assertFalse( current_user_can( 'read_knowledge' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge' ) ); + } + + /** + * @ticket 65476 + */ + public function test_anonymous_has_no_access() { + wp_set_current_user( 0 ); + + $this->assertFalse( current_user_can( 'read_knowledge' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge' ) ); + } + + /** + * @ticket 65476 + * + * @dataProvider data_contributor_level_roles + * + * @param string $role Role slug. + */ + public function test_contributor_level_ambient_floor( $role ) { + wp_set_current_user( self::$users[ $role ] ); + + // May list and create knowledge. + $this->assertTrue( current_user_can( 'read_knowledge' ), "$role should read_knowledge" ); + $this->assertTrue( current_user_can( 'edit_knowledge' ), "$role should edit_knowledge" ); + + // May not publish or act on other users' rows. + $this->assertFalse( current_user_can( 'publish_knowledge' ), "$role should not publish_knowledge" ); + $this->assertFalse( current_user_can( 'edit_others_knowledge' ), "$role should not edit_others_knowledge" ); + $this->assertFalse( current_user_can( 'delete_others_knowledge' ), "$role should not delete_others_knowledge" ); + } + + public function data_contributor_level_roles(): array { + return array( + 'contributor' => array( 'contributor' ), + 'author' => array( 'author' ), + 'editor' => array( 'editor' ), + ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_can_manage_own_private_row() { + wp_set_current_user( self::$users['contributor'] ); + + $this->assertTrue( current_user_can( 'edit_post', self::$own_private ) ); + $this->assertTrue( current_user_can( 'read_post', self::$own_private ) ); + $this->assertTrue( current_user_can( 'delete_post', self::$own_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_edit_own_published_row() { + wp_set_current_user( self::$users['contributor'] ); + + // Publishing is reserved for administrators, so an already-published + // row falls outside the per-post grant. + $this->assertFalse( current_user_can( 'edit_post', self::$own_published ) ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_act_on_others_rows() { + wp_set_current_user( self::$users['contributor'] ); + + $this->assertFalse( current_user_can( 'edit_post', self::$others_private ) ); + $this->assertFalse( current_user_can( 'read_post', self::$others_private ) ); + $this->assertFalse( current_user_can( 'delete_post', self::$others_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_grant_does_not_apply_to_other_post_types() { + wp_set_current_user( self::$users['contributor'] ); + + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + // The knowledge per-post grant must not leak into other post types. + $this->assertFalse( current_user_can( 'edit_post', $page_id ) ); + } +} diff --git a/tests/phpunit/tests/knowledge/postType.php b/tests/phpunit/tests/knowledge/postType.php new file mode 100644 index 0000000000000..8bf82c76284b5 --- /dev/null +++ b/tests/phpunit/tests/knowledge/postType.php @@ -0,0 +1,141 @@ +assertTrue( post_type_exists( 'wp_knowledge' ) ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_is_builtin_and_private() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertInstanceOf( 'WP_Post_Type', $post_type ); + $this->assertTrue( $post_type->_builtin, '_builtin should be true' ); + $this->assertFalse( $post_type->public, 'public should be false' ); + $this->assertFalse( $post_type->show_ui, 'show_ui should be false' ); + $this->assertFalse( $post_type->hierarchical, 'hierarchical should be false' ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_rest_configuration() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertTrue( $post_type->show_in_rest, 'show_in_rest should be true' ); + $this->assertSame( 'knowledge', $post_type->rest_base ); + $this->assertSame( 'WP_REST_Knowledge_Controller', $post_type->rest_controller_class ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_supports() { + $this->assertTrue( post_type_supports( 'wp_knowledge', 'title' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'editor' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'excerpt' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'author' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'revisions' ) ); + } + + /** + * Revisions are supported and served by the default revisions controller. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_supports_revisions_with_default_controller() { + $this->assertTrue( post_type_supports( 'wp_knowledge', 'revisions' ) ); + + $controller = get_post_type_object( 'wp_knowledge' )->get_revisions_rest_controller(); + $this->assertInstanceOf( 'WP_REST_Revisions_Controller', $controller ); + } + + /** + * Autosave support is removed, so no autosave endpoints are registered. + * + * Knowledge is headless storage with no editor session; `editor` support + * implies `autosave`, which is explicitly removed at registration. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_does_not_support_autosaves() { + $this->assertFalse( post_type_supports( 'wp_knowledge', 'autosave' ) ); + $this->assertNull( get_post_type_object( 'wp_knowledge' )->get_autosave_rest_controller() ); + } + + /** + * The `read` capability is remapped so that the base `read` cap (held by + * subscribers) does not grant access to the post type. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_read_capability_is_remapped() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertSame( 'read_knowledge', $post_type->cap->read ); + } + + /** + * "Knowledge" is a mass noun, so the per-post meta capabilities (derived + * from the singular base) must not collide with the primitive capabilities + * (derived from the plural base). A collision would make checks such as + * `current_user_can( 'edit_knowledge' )` ambiguous. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_meta_caps_do_not_collide_with_primitives() { + $cap = get_post_type_object( 'wp_knowledge' )->cap; + + // Meta capabilities are derived from the singular `knowledge_item` base. + $this->assertSame( 'edit_knowledge_item', $cap->edit_post ); + $this->assertSame( 'read_knowledge_item', $cap->read_post ); + $this->assertSame( 'delete_knowledge_item', $cap->delete_post ); + + // Primitive capabilities are derived from the plural `knowledge` base. + $this->assertSame( 'edit_knowledge', $cap->edit_posts ); + $this->assertSame( 'edit_others_knowledge', $cap->edit_others_posts ); + $this->assertSame( 'publish_knowledge', $cap->publish_posts ); + $this->assertSame( 'read_private_knowledge', $cap->read_private_posts ); + + // The meta and primitive forms must be distinct. + $this->assertNotSame( $cap->edit_post, $cap->edit_posts ); + $this->assertNotSame( $cap->read_post, $cap->read_private_posts ); + $this->assertNotSame( $cap->delete_post, $cap->delete_posts ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_taxonomies + */ + public function test_knowledge_type_taxonomy_is_attached() { + $this->assertTrue( taxonomy_exists( 'wp_knowledge_type' ) ); + $this->assertContains( 'wp_knowledge_type', get_object_taxonomies( 'wp_knowledge' ) ); + + $taxonomy = get_taxonomy( 'wp_knowledge_type' ); + $this->assertTrue( $taxonomy->hierarchical, 'taxonomy should be hierarchical' ); + $this->assertFalse( $taxonomy->public, 'taxonomy should not be public' ); + $this->assertTrue( $taxonomy->show_in_rest, 'taxonomy should be shown in REST' ); + } +} diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php new file mode 100644 index 0000000000000..ff9921d8071e1 --- /dev/null +++ b/tests/phpunit/tests/knowledge/types.php @@ -0,0 +1,142 @@ +assertArrayHasKey( 'guideline', $types ); + $this->assertArrayHasKey( 'memory', $types ); + $this->assertArrayHasKey( 'note', $types ); + + $this->assertSame( 'Guideline', $types['guideline']['title'] ); + $this->assertSame( 'Memory', $types['memory']['title'] ); + $this->assertSame( 'Note', $types['note']['title'] ); + } + + /** + * @ticket 65476 + * @covers ::wp_knowledge_types + */ + public function test_types_are_filterable() { + $callback = static function ( $types ) { + $types['skill'] = array( 'title' => 'Skill' ); + return $types; + }; + + add_filter( 'wp_knowledge_types', $callback ); + $types = wp_knowledge_types(); + remove_filter( 'wp_knowledge_types', $callback ); + + $this->assertArrayHasKey( 'skill', $types ); + $this->assertSame( 'Skill', $types['skill']['title'] ); + } + + /** + * A knowledge row saved without a type term should fall back to `note`. + * + * @ticket 65476 + * @covers ::_wp_knowledge_ensure_default_type_term + */ + public function test_default_type_term_is_assigned_on_save() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + ) + ); + + $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + + $this->assertSame( array( 'note' ), $terms ); + } + + /** + * A row that already carries a type term should keep it, not gain `note`. + * + * @ticket 65476 + * @covers ::_wp_knowledge_ensure_default_type_term + */ + public function test_existing_type_term_is_preserved_on_save() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + ) + ); + + // Assign a non-default term, replacing the `note` fallback from creation. + $term = wp_insert_term( 'memory', 'wp_knowledge_type' ); + wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); + + // A subsequent save must not re-add the `note` fallback. + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Updated knowledge', + ) + ); + + $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + + $this->assertSame( array( 'memory' ), $terms ); + } + + /** + * A term lazily created from a registered slug gets the registered label. + * + * @ticket 65476 + * @covers ::_wp_knowledge_maybe_map_term_label + */ + public function test_registered_slug_term_gets_mapped_label() { + $term = wp_insert_term( 'guideline', 'wp_knowledge_type' ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + + $this->assertSame( 'guideline', $created->slug ); + $this->assertSame( 'Guideline', $created->name ); + } + + /** + * A user-provided label (where name differs from the slug) is left intact. + * + * @ticket 65476 + * @covers ::_wp_knowledge_maybe_map_term_label + */ + public function test_custom_label_is_not_overwritten() { + $term = wp_insert_term( 'My Custom Type', 'wp_knowledge_type', array( 'slug' => 'guideline' ) ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + + $this->assertSame( 'guideline', $created->slug ); + $this->assertSame( 'My Custom Type', $created->name ); + } + + /** + * The label mapping must not touch terms in other taxonomies. + * + * @ticket 65476 + * @covers ::_wp_knowledge_maybe_map_term_label + */ + public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { + $term = wp_insert_term( 'guideline', 'category' ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'category' ); + + $this->assertSame( 'guideline', $created->name ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..3c2faa87ab159 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -108,6 +108,10 @@ public function test_expected_routes_in_schema() { '/wp/v2/pages/(?P[\\d]+)/autosaves', '/wp/v2/pages/(?P[\\d]+)/autosaves/(?P[\\d]+)', '/wp/v2/pattern-directory/patterns', + '/wp/v2/knowledge', + '/wp/v2/knowledge/(?P[\\d]+)', + '/wp/v2/knowledge/(?P[\\d]+)/revisions', + '/wp/v2/knowledge/(?P[\\d]+)/revisions/(?P[\\d]+)', '/wp/v2/media', '/wp/v2/media/(?P[\\d]+)', '/wp/v2/media/(?P[\\d]+)/post-process', @@ -192,6 +196,8 @@ public function test_expected_routes_in_schema() { '/wp-site-health/v1/tests/authorization-header', '/wp-site-health/v1/tests/page-cache', '/wp-site-health/v1/directory-sizes', + '/wp/v2/wp_knowledge_type', + '/wp/v2/wp_knowledge_type/(?P[\d]+)', '/wp/v2/wp_pattern_category', '/wp/v2/wp_pattern_category/(?P[\d]+)', '/wp/v2/font-collections', diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php new file mode 100644 index 0000000000000..a4515f5ed4540 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -0,0 +1,278 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$contributor_id = $factory->user->create( array( 'role' => 'contributor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$admin_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$admin_id, + 'post_title' => 'Admin private knowledge', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$contributor_id ); + self::delete_user( self::$subscriber_id ); + wp_delete_post( self::$admin_private, true ); + } + + /** + * Creates a private knowledge row for the given author. + * + * @param int $author_id Author user ID. + * @return int Post ID. + */ + private function create_knowledge_post( int $author_id ): int { + return self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => $author_id, + 'post_title' => 'Knowledge row', + ) + ); + } + + /** + * @ticket 65476 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp/v2/knowledge', $routes ); + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)', $routes ); + } + + /** + * @ticket 65476 + */ + public function test_context_param() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * @ticket 65476 + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + // The collection defaults to the `publish` status; knowledge rows are + // private by default, so list a published row here. + self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + 'post_title' => 'Published knowledge', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( $response->get_data() ); + } + + /** + * @ticket 65476 + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + + wp_set_current_user( self::$subscriber_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * @ticket 65476 + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( self::$admin_private, $response->get_data()['id'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_read_others_private_row() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertContains( $response->get_status(), array( 401, 403 ) ); + } + + /** + * @ticket 65476 + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( array( 'title' => 'Created by admin' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $data = $response->get_data(); + // With no status supplied, new rows default to private rather than draft. + $this->assertSame( 'private', $data['status'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_create_defaults_to_private() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( array( 'title' => 'Created by contributor' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'private', $response->get_data()['status'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_publish() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( + array( + 'title' => 'Attempted publish', + 'status' => 'publish', + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_publish', $response, 403 ); + } + + /** + * @ticket 65476 + */ + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->create_knowledge_post( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $post_id ); + $request->set_body_params( array( 'title' => 'Updated title' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated title', $response->get_data()['title']['raw'] ); + } + + /** + * @ticket 65476 + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->create_knowledge_post( self::$admin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . $post_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $post_id ) ); + } + + /** + * @ticket 65476 + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertArrayHasKey( 'status', $data ); + $this->assertArrayHasKey( 'author', $data ); + } + + /** + * @ticket 65476 + */ + public function test_get_item_schema() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $properties = $response->get_data()['schema']['properties']; + + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'title', $properties ); + $this->assertArrayHasKey( 'content', $properties ); + $this->assertArrayHasKey( 'excerpt', $properties ); + $this->assertArrayHasKey( 'status', $properties ); + $this->assertArrayHasKey( 'author', $properties ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..cf1edba25e3c5 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -8426,6 +8426,874 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/knowledge": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "after": { + "description": "Limit response to posts published after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_after": { + "description": "Limit response to posts modified after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "author": { + "description": "Limit result set to posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "author_exclude": { + "description": "Ensure result set excludes posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "before": { + "description": "Limit response to posts published before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_before": { + "description": "Limit response to posts modified before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "search_semantics": { + "description": "How to interpret the search input.", + "type": "string", + "enum": [ + "exact" + ], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by post attribute.", + "type": "string", + "default": "date", + "enum": [ + "author", + "date", + "id", + "include", + "modified", + "parent", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, + "slug": { + "description": "Limit result set to posts with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "status": { + "default": "publish", + "description": "Limit result set to posts assigned one or more statuses.", + "type": "array", + "items": { + "enum": [ + "publish", + "future", + "draft", + "pending", + "private", + "trash", + "auto-draft", + "inherit", + "request-pending", + "request-confirmed", + "request-failed", + "request-completed", + "any" + ], + "type": "string" + }, + "required": false + }, + "tax_relation": { + "description": "Limit result set based on relationship between multiple taxonomies.", + "type": "string", + "enum": [ + "AND", + "OR" + ], + "required": false + }, + "wp_knowledge_type": { + "description": "Limit result set to items with specific terms assigned in the wp_knowledge_type taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + }, + "operator": { + "description": "Whether items must be assigned all or any of the specified terms.", + "type": "string", + "enum": [ + "AND", + "OR" + ], + "default": "OR" + } + }, + "additionalProperties": false + } + ], + "required": false + }, + "wp_knowledge_type_exclude": { + "description": "Limit result set to items except those with specific terms assigned in the wp_knowledge_type taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + ], + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "allow_batch": { + "v1": true + }, + "args": { + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "author": { + "description": "The ID for the author of the post.", + "type": "integer", + "required": false + }, + "excerpt": { + "description": "The excerpt for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Excerpt for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML excerpt for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + }, + "protected": { + "description": "Whether the excerpt is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_knowledge_type": { + "description": "The terms assigned to the post in the wp_knowledge_type taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/knowledge" + } + ] + } + }, + "/wp/v2/knowledge/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "excerpt_length": { + "description": "Override the default excerpt length.", + "type": "integer", + "required": false + }, + "password": { + "description": "The password for the post if it is password protected.", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "author": { + "description": "The ID for the author of the post.", + "type": "integer", + "required": false + }, + "excerpt": { + "description": "The excerpt for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Excerpt for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML excerpt for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + }, + "protected": { + "description": "Whether the excerpt is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_knowledge_type": { + "description": "The terms assigned to the post in the wp_knowledge_type taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", + "required": false + } + } + } + ] + }, + "/wp/v2/knowledge/(?P[\\d]+)/revisions": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by object attribute.", + "type": "string", + "default": "date", + "enum": [ + "date", + "id", + "include", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + } + } + } + ] + }, + "/wp/v2/knowledge/(?P[\\d]+)/revisions/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as revisions do not support trashing.", + "required": false + } + } + } + ] + }, "/wp/v2/types": { "namespace": "wp/v2", "methods": [ @@ -8577,23 +9445,219 @@ mockedApiResponse.Schema = { } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + "self": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + } + }, + "/wp/v2/taxonomies/(?P[\\w-]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "taxonomy": { + "description": "An alphanumeric identifier for the taxonomy.", + "type": "string", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, + "/wp/v2/categories": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by term attribute.", + "type": "string", + "default": "name", + "enum": [ + "id", + "include", + "name", + "slug", + "include_slugs", + "term_group", + "description", + "count" + ], + "required": false + }, + "hide_empty": { + "description": "Whether to hide terms not assigned to any posts.", + "type": "boolean", + "default": false, + "required": false + }, + "parent": { + "description": "Limit result set to terms assigned to a specific parent.", + "type": "integer", + "required": false + }, + "post": { + "description": "Limit result set to terms assigned to a specific post.", + "type": "integer", + "default": null, + "required": false + }, + "slug": { + "description": "Limit result set to terms with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "allow_batch": { + "v1": true + }, + "args": { + "description": { + "description": "HTML description of the term.", + "type": "string", + "required": false + }, + "name": { + "description": "HTML title for the term.", + "type": "string", + "required": true + }, + "slug": { + "description": "An alphanumeric identifier for the term unique to its type.", + "type": "string", + "required": false + }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + } + ], + "_links": { + "self": "http://example.org/index.php?rest_route=/wp/v2/categories" } }, - "/wp/v2/taxonomies/(?P[\\w-]+)": { + "/wp/v2/categories/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ - "GET" + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" ], "endpoints": [ { "methods": [ "GET" ], + "allow_batch": { + "v1": true + }, "args": { - "taxonomy": { - "description": "An alphanumeric identifier for the taxonomy.", - "type": "string", + "id": { + "description": "Unique identifier for the term.", + "type": "integer", "required": false }, "context": { @@ -8608,10 +9672,74 @@ mockedApiResponse.Schema = { "required": false } } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the term.", + "type": "integer", + "required": false + }, + "description": { + "description": "HTML description of the term.", + "type": "string", + "required": false + }, + "name": { + "description": "HTML title for the term.", + "type": "string", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the term unique to its type.", + "type": "string", + "required": false + }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the term.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as terms do not support trashing.", + "required": false + } + } } ] }, - "/wp/v2/categories": { + "/wp/v2/tags": { "namespace": "wp/v2", "methods": [ "GET", @@ -8675,6 +9803,11 @@ mockedApiResponse.Schema = { "default": [], "required": false }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, "order": { "description": "Order sort attribute ascending or descending.", "type": "string", @@ -8707,11 +9840,6 @@ mockedApiResponse.Schema = { "default": false, "required": false }, - "parent": { - "description": "Limit result set to terms assigned to a specific parent.", - "type": "integer", - "required": false - }, "post": { "description": "Limit result set to terms assigned to a specific post.", "type": "integer", @@ -8751,11 +9879,6 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "parent": { - "description": "The parent term ID.", - "type": "integer", - "required": false - }, "meta": { "description": "Meta fields.", "type": "object", @@ -8766,10 +9889,10 @@ mockedApiResponse.Schema = { } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/categories" + "self": "http://example.org/index.php?rest_route=/wp/v2/tags" } }, - "/wp/v2/categories/(?P[\\d]+)": { + "/wp/v2/tags/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -8835,11 +9958,6 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "parent": { - "description": "The parent term ID.", - "type": "integer", - "required": false - }, "meta": { "description": "Meta fields.", "type": "object", @@ -8871,7 +9989,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/tags": { + "/wp/v2/menus": { "namespace": "wp/v2", "methods": [ "GET", @@ -9016,15 +10134,32 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false + }, + "locations": { + "description": "The locations assigned to the menu.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "auto_add": { + "description": "Whether to automatically add top level pages to this menu.", + "type": "boolean", + "required": false } } } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/tags" + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/menus" + } + ] } }, - "/wp/v2/tags/(?P[\\d]+)": { + "/wp/v2/menus/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9095,6 +10230,19 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false + }, + "locations": { + "description": "The locations assigned to the menu.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "auto_add": { + "description": "Whether to automatically add top level pages to this menu.", + "type": "boolean", + "required": false } } }, @@ -9121,7 +10269,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/menus": { + "/wp/v2/wp_pattern_category": { "namespace": "wp/v2", "methods": [ "GET", @@ -9266,19 +10414,6 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false - }, - "locations": { - "description": "The locations assigned to the menu.", - "type": "array", - "items": { - "type": "string" - }, - "required": false - }, - "auto_add": { - "description": "Whether to automatically add top level pages to this menu.", - "type": "boolean", - "required": false } } } @@ -9286,12 +10421,12 @@ mockedApiResponse.Schema = { "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/menus" + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_pattern_category" } ] } }, - "/wp/v2/menus/(?P[\\d]+)": { + "/wp/v2/wp_pattern_category/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9362,19 +10497,6 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false - }, - "locations": { - "description": "The locations assigned to the menu.", - "type": "array", - "items": { - "type": "string" - }, - "required": false - }, - "auto_add": { - "description": "Whether to automatically add top level pages to this menu.", - "type": "boolean", - "required": false } } }, @@ -9401,7 +10523,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/wp_pattern_category": { + "/wp/v2/wp_knowledge_type": { "namespace": "wp/v2", "methods": [ "GET", @@ -9465,11 +10587,6 @@ mockedApiResponse.Schema = { "default": [], "required": false }, - "offset": { - "description": "Offset the result set by a specific number of items.", - "type": "integer", - "required": false - }, "order": { "description": "Order sort attribute ascending or descending.", "type": "string", @@ -9502,6 +10619,11 @@ mockedApiResponse.Schema = { "default": false, "required": false }, + "parent": { + "description": "Limit result set to terms assigned to a specific parent.", + "type": "integer", + "required": false + }, "post": { "description": "Limit result set to terms assigned to a specific post.", "type": "integer", @@ -9541,6 +10663,11 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, "meta": { "description": "Meta fields.", "type": "object", @@ -9553,12 +10680,12 @@ mockedApiResponse.Schema = { "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/wp_pattern_category" + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_knowledge_type" } ] } }, - "/wp/v2/wp_pattern_category/(?P[\\d]+)": { + "/wp/v2/wp_knowledge_type/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9624,6 +10751,11 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, "meta": { "description": "Meta fields.", "type": "object", @@ -9801,7 +10933,8 @@ mockedApiResponse.Schema = { "wp_global_styles": "wp_global_styles", "wp_navigation": "wp_navigation", "wp_font_family": "wp_font_family", - "wp_font_face": "wp_font_face" + "wp_font_face": "wp_font_face", + "wp_knowledge": "wp_knowledge" } }, "required": false @@ -14065,6 +15198,40 @@ mockedApiResponse.TypesCollection = { } ] } + }, + "wp_knowledge": { + "description": "", + "hierarchical": false, + "has_archive": false, + "name": "Knowledge", + "slug": "wp_knowledge", + "icon": null, + "taxonomies": [ + "wp_knowledge_type" + ], + "rest_base": "knowledge", + "rest_namespace": "wp/v2", + "template": [], + "template_lock": false, + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/types" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/knowledge" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } }; @@ -14300,6 +15467,36 @@ mockedApiResponse.TaxonomiesCollection = { } ] } + }, + "wp_knowledge_type": { + "name": "Knowledge Types", + "slug": "wp_knowledge_type", + "description": "", + "types": [ + "wp_knowledge" + ], + "hierarchical": true, + "rest_base": "wp_knowledge_type", + "rest_namespace": "wp/v2", + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_knowledge_type" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } }; From 32cfc1d2aabac6ef6fff98aa8e9f5a4a62cae8dc Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 18 Jun 2026 09:36:38 +0200 Subject: [PATCH 2/2] Knowledge: Address PR feedback on naming and capability filter. Drop the underscore prefix from the private wp_knowledge_ensure_default_type_term() and wp_knowledge_maybe_map_term_label() functions, following the coding standards change that annotates private functions with @access private instead of a name prefix. Update the corresponding hooks and @covers tags. Remove the scalar/array type hints from wp_maybe_grant_knowledge_caps() so it matches the graceful coercion used by the sibling user_has_cap callbacks (wp_maybe_grant_site_health_caps() et al.). Assert in the controller test that the revisions routes are registered while the autosaves routes are not, documenting the disabled autosave support. Follow-up to [d008aaa614]. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/capabilities.php | 2 +- src/wp-includes/default-filters.php | 4 ++-- src/wp-includes/knowledge.php | 4 ++-- tests/phpunit/tests/knowledge/types.php | 10 +++++----- .../tests/rest-api/wpRestKnowledgeController.php | 8 ++++++++ 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index d4f4991110b15..f8c71cb427b2b 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1390,7 +1390,7 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * @param WP_User $user The user object. * @return bool[] Filtered array of the user's capabilities. */ -function wp_maybe_grant_knowledge_caps( array $allcaps, array $caps, array $args, WP_User $user ): array { +function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { if ( ! empty( $allcaps['manage_options'] ) ) { $allcaps['read_knowledge'] = true; $allcaps['edit_knowledge'] = true; diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 81b9b274eedfc..d0b784f8cef3a 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -788,8 +788,8 @@ add_filter( 'rest_wp_navigation_item_schema', array( 'WP_Navigation_Fallback', 'update_wp_navigation_post_schema' ) ); // wp_knowledge post type. -add_action( 'save_post_wp_knowledge', '_wp_knowledge_ensure_default_type_term' ); -add_filter( 'wp_insert_term_data', '_wp_knowledge_maybe_map_term_label', 10, 2 ); +add_action( 'save_post_wp_knowledge', 'wp_knowledge_ensure_default_type_term' ); +add_filter( 'wp_insert_term_data', 'wp_knowledge_maybe_map_term_label', 10, 2 ); // Fluid typography. add_filter( 'render_block', 'wp_render_typography_support', 10, 2 ); diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index 9956bfddce88b..e646ec55a4901 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -76,7 +76,7 @@ function wp_knowledge_types(): array { * * @param int $post_id Saved post ID. */ -function _wp_knowledge_ensure_default_type_term( int $post_id ): void { +function wp_knowledge_ensure_default_type_term( int $post_id ): void { if ( wp_is_post_revision( $post_id ) ) { return; } @@ -124,7 +124,7 @@ function _wp_knowledge_ensure_default_type_term( int $post_id ): void { * @phpstan-param array $data * @phpstan-return array */ -function _wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { +function wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { if ( 'wp_knowledge_type' !== $taxonomy ) { return $data; } diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php index ff9921d8071e1..f2b26b2272dd6 100644 --- a/tests/phpunit/tests/knowledge/types.php +++ b/tests/phpunit/tests/knowledge/types.php @@ -47,7 +47,7 @@ public function test_types_are_filterable() { * A knowledge row saved without a type term should fall back to `note`. * * @ticket 65476 - * @covers ::_wp_knowledge_ensure_default_type_term + * @covers ::wp_knowledge_ensure_default_type_term */ public function test_default_type_term_is_assigned_on_save() { $post_id = self::factory()->post->create( @@ -66,7 +66,7 @@ public function test_default_type_term_is_assigned_on_save() { * A row that already carries a type term should keep it, not gain `note`. * * @ticket 65476 - * @covers ::_wp_knowledge_ensure_default_type_term + * @covers ::wp_knowledge_ensure_default_type_term */ public function test_existing_type_term_is_preserved_on_save() { $post_id = self::factory()->post->create( @@ -97,7 +97,7 @@ public function test_existing_type_term_is_preserved_on_save() { * A term lazily created from a registered slug gets the registered label. * * @ticket 65476 - * @covers ::_wp_knowledge_maybe_map_term_label + * @covers ::wp_knowledge_maybe_map_term_label */ public function test_registered_slug_term_gets_mapped_label() { $term = wp_insert_term( 'guideline', 'wp_knowledge_type' ); @@ -113,7 +113,7 @@ public function test_registered_slug_term_gets_mapped_label() { * A user-provided label (where name differs from the slug) is left intact. * * @ticket 65476 - * @covers ::_wp_knowledge_maybe_map_term_label + * @covers ::wp_knowledge_maybe_map_term_label */ public function test_custom_label_is_not_overwritten() { $term = wp_insert_term( 'My Custom Type', 'wp_knowledge_type', array( 'slug' => 'guideline' ) ); @@ -129,7 +129,7 @@ public function test_custom_label_is_not_overwritten() { * The label mapping must not touch terms in other taxonomies. * * @ticket 65476 - * @covers ::_wp_knowledge_maybe_map_term_label + * @covers ::wp_knowledge_maybe_map_term_label */ public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { $term = wp_insert_term( 'guideline', 'category' ); diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php index a4515f5ed4540..66099f3ea72ad 100644 --- a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -81,6 +81,14 @@ public function test_register_routes() { $this->assertArrayHasKey( '/wp/v2/knowledge', $routes ); $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)', $routes ); + + // Revisions are supported. + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)/revisions', $routes ); + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)/revisions/(?P[\d]+)', $routes ); + + // Autosave support is removed, so the autosaves routes are not registered. + $this->assertArrayNotHasKey( '/wp/v2/knowledge/(?P[\d]+)/autosaves', $routes ); + $this->assertArrayNotHasKey( '/wp/v2/knowledge/(?P[\d]+)/autosaves/(?P[\d]+)', $routes ); } /**