diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php index 2b927a7f81a6a..8dc3fd0eb0f98 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -103,12 +103,48 @@ public function prepare_items() { $comment_status = 'all'; } + /* + * Notes carry their own visibility rules, so they are never listed here and a + * request for them is dropped outright. This is deliberately narrower than the + * treatment of the other default-excluded types below, which can be listed when + * explicitly requested. Emptying the excluded set does surface notes in the + * untyped views, but not as a type the table can be filtered to. + */ $comment_type = ''; if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { $comment_type = $_REQUEST['comment_type']; } + /* + * WP_Comment_Query drops the default exclusions when 'all' types are + * requested, so they are also passed as 'type__not_in' to keep excluded + * types out of the list table in that case. + * + * The requested type is removed from that list, so a plugin that adds + * its own default-excluded type to the type dropdown via + * 'admin_comment_types_dropdown' can still list it. Type aliases are + * expanded first, matching how WP_Comment_Query resolves them. + */ + switch ( $comment_type ) { + // Kept for symmetry with WP_Comment_Query; the accessor strips these tokens + // from the excluded set, so this branch can never subtract anything. + case 'comment': + case 'comments': + $requested_types = array( '', 'comment' ); + break; + + case 'pings': + $requested_types = array( 'pingback', 'trackback' ); + break; + + default: + $requested_types = array( $comment_type ); + break; + } + + $excluded_types = array_values( array_diff( wp_get_default_excluded_comment_types(), $requested_types ) ); + $search = $_REQUEST['s'] ?? ''; $post_type = ( isset( $_REQUEST['post_type'] ) ) ? sanitize_key( $_REQUEST['post_type'] ) : ''; @@ -155,7 +191,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => array( 'note' ), + 'type__not_in' => $excluded_types, 'orderby' => $orderby, 'order' => $order, 'post_type' => $post_type, diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index f32bd91ad265d..a65b5011e6c8c 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -223,6 +223,8 @@ function get_comment_to_edit( $id ) { * * @since 2.3.0 * @since 6.9.0 Exclude the 'note' comment type from the count. + * @since 7.2.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -242,7 +244,11 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type != 'note' GROUP BY comment_post_ID", ARRAY_A ); + $type_not_in = _wp_get_excluded_comment_types_clause(); + + // $post_id_in is built from integers and $type_not_in is prepared above. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0'$type_not_in GROUP BY comment_post_ID", ARRAY_A ); if ( $single ) { if ( empty( $pending ) ) { diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index f80864d31c8bc..8e12dea53e7e7 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -66,6 +66,17 @@ class WP_Comment_Query { */ protected $filtered_where_clause; + /** + * Comment types excluded from the results by default. + * + * Resolved once per query in get_comments(), so that the set folded into the cache + * key is the same one get_comment_ids() builds the SQL from. Null until resolved. + * + * @since 7.2.0 + * @var string[]|null + */ + protected $default_excluded_comment_types = null; + /** * Date query container * @@ -455,6 +466,18 @@ public function get_comments() { $_args = wp_array_slice_assoc( $this->query_vars, array_keys( $this->query_var_defaults ) ); unset( $_args['fields'], $_args['update_comment_meta_cache'], $_args['update_comment_post_cache'] ); + /* + * The default-excluded types are not query vars, but they do change the results, + * so they belong in the key. Without them a persistent object cache would keep + * serving entries built before a plugin changed the excluded set, since changing + * it does not touch the comment 'last_changed' value the key is salted with. + * + * The resolved set is reused in get_comment_ids() so the filter runs once per query. + */ + $this->default_excluded_comment_types = wp_get_default_excluded_comment_types( $this ); + + $_args['default_excluded_comment_types'] = $this->default_excluded_comment_types; + $key = md5( serialize( $_args ) ); $last_changed = wp_cache_get_last_changed( 'comment' ); @@ -544,6 +567,7 @@ public function get_comments() { * * @since 4.4.0 * @since 6.9.0 Excludes the 'note' comment type, unless 'all' or the 'note' types are requested. + * @since 7.2.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -779,13 +803,45 @@ protected function get_comment_ids() { 'NOT IN' => (array) $this->query_vars['type__not_in'], ); - // Exclude the 'note' comment type, unless 'all' types or the 'note' type explicitly are requested. - if ( - ! in_array( 'all', $raw_types['IN'], true ) && - ! in_array( 'note', $raw_types['IN'], true ) && - ! in_array( 'note', $raw_types['NOT IN'], true ) - ) { - $raw_types['NOT IN'][] = 'note'; + // Resolved in get_comments() when the cache key is built; resolve here for direct calls. + if ( null === $this->default_excluded_comment_types ) { + $this->default_excluded_comment_types = wp_get_default_excluded_comment_types( $this ); + } + + $excluded_types = $this->default_excluded_comment_types; + + // Unless all types are requested, exclude each default-excluded type + // that the query does not explicitly request. The special type tokens + // in the request ('comment', 'comments', 'pings') are first expanded to + // the literal comment_type values they represent, so a type requested + // via an alias (for example 'pings' for 'pingback' and 'trackback') is + // still treated as explicitly requested and is not excluded. + if ( ! in_array( 'all', $raw_types['IN'], true ) ) { + $requested_types = array(); + foreach ( $raw_types['IN'] as $requested_type ) { + switch ( $requested_type ) { + case 'comment': + case 'comments': + $requested_types[] = ''; + $requested_types[] = 'comment'; + break; + + case 'pings': + $requested_types[] = 'pingback'; + $requested_types[] = 'trackback'; + break; + + default: + $requested_types[] = $requested_type; + break; + } + } + + foreach ( $excluded_types as $excluded_type ) { + if ( ! in_array( $excluded_type, $requested_types, true ) ) { + $raw_types['NOT IN'][] = $excluded_type; + } + } } $comment_types = array(); diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 228691d26d12b..1d9a8f5d13134 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2825,13 +2825,15 @@ public function get_posts() { // Comments feeds. if ( $this->is_comment_feed && ! $this->is_singular ) { + $ctype_not_in = _wp_get_excluded_comment_types_clause( "{$wpdb->comments}.comment_type" ); + if ( $this->is_archive || $this->is_search ) { $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID ) $join "; - $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note' $where"; + $cwhere = "WHERE comment_approved = '1'$ctype_not_in $where"; $cgroupby = "{$wpdb->comments}.comment_id"; } else { // Other non-singular, e.g. front. $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID )"; - $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'"; + $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1'$ctype_not_in"; $cgroupby = ''; } @@ -3485,11 +3487,13 @@ public function get_posts() { } if ( ! empty( $this->posts ) && $this->is_comment_feed && $this->is_singular ) { + $ctype_not_in = _wp_get_excluded_comment_types_clause( "{$wpdb->comments}.comment_type" ); + /** This filter is documented in wp-includes/class-wp-query.php */ $cjoin = apply_filters_ref_array( 'comment_feed_join', array( '', &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ - $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'", &$this ) ); + $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1'$ctype_not_in", &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ $cgroupby = apply_filters_ref_array( 'comment_feed_groupby', array( '', &$this ) ); diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 43bd68ff972a4..6eed5822421c9 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -755,6 +755,7 @@ function comment_ID() { // phpcs:ignore WordPress.NamingConventions.ValidFunctio * * @since 1.5.0 * @since 4.4.0 Added the ability for `$comment` to also accept a WP_Comment object. Added `$cpage` argument. + * @since 7.2.0 The default 'type' changed from 'all' to '', matching get_page_of_comment(). * * @see get_page_of_comment() * @@ -786,7 +787,7 @@ function get_comment_link( $comment = null, $args = array() ) { } $defaults = array( - 'type' => 'all', + 'type' => '', 'page' => '', 'per_page' => '', 'max_depth' => '', diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 7557e9258c87f..a12edd55549b5 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1086,6 +1086,8 @@ function get_comment_pages_count( $comments = null, $per_page = null, $threaded * Calculates what page number a comment will appear on for comment paging. * * @since 2.7.0 + * @since 7.2.0 The default 'type' changed from 'all' to '', so that the page math + * applies the same default exclusions as the rendered comment list. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1095,7 +1097,10 @@ function get_comment_pages_count( $comments = null, $per_page = null, $threaded * * @type string $type Limit paginated comments to those matching a given type. * Accepts 'comment', 'trackback', 'pingback', 'pings' - * (trackbacks and pingbacks), or 'all'. Default 'all'. + * (trackbacks and pingbacks), or 'all'. An empty string + * counts every type except those excluded by default, which + * is what comments_template() renders. Pass 'all' to count + * the excluded types as well. Default empty string. * @type int $per_page Per-page count to use when calculating pagination. * Defaults to the value of the 'comments_per_page' option. * @type int|string $max_depth If greater than 1, comment page will be determined @@ -1114,8 +1119,13 @@ function get_page_of_comment( $comment_id, $args = array() ) { return null; } + /* + * The default type is empty rather than 'all' so the comments counted here are the + * ones comments_template() actually renders. Counting the default-excluded types + * would put a comment's permalink on a page the visitor never sees. + */ $defaults = array( - 'type' => 'all', + 'type' => '', 'page' => '', 'per_page' => '', 'max_depth' => '', @@ -1195,7 +1205,9 @@ function get_page_of_comment( $comment_id, $args = array() ) { * * @type string $type Limit paginated comments to those matching a given type. * Accepts 'comment', 'trackback', 'pingback', 'pings' - * (trackbacks and pingbacks), or 'all'. Default 'all'. + * (trackbacks and pingbacks), or 'all'. An empty string + * counts every type except those excluded by default. + * Default empty string. * @type int $post_id ID of the post. * @type string $fields Comment fields to return. * @type bool $count Whether to return a comment count (true) or array @@ -3093,10 +3105,155 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { return null; } +/** + * Retrieves the comment types that are excluded from queries and counts by default. + * + * The default set is the 'note' type plus every comment type registered with + * `'internal' => true`. The {@see 'default_excluded_comment_types'} filter is then + * applied and the result normalized: non-scalar values are discarded, the rest are + * cast to strings, empties and duplicates are removed, and the special type tokens + * understood by WP_Comment_Query ('all', 'comment', 'comments', 'pings') are + * stripped - the filter deals in literal `comment_type` values only. + * + * @since 7.2.0 + * + * @param WP_Comment_Query|null $query Optional. The current query instance when called + * from WP_Comment_Query, or null when the set is + * resolved outside of a comment query. Default null. + * @return string[] Comment types excluded by default. + */ +function wp_get_default_excluded_comment_types( $query = null ) { + $default_excluded_types = array( 'note' ); + + /* + * Comment types registered as internal are excluded by default. The 'note' type is + * listed above as well so that the default holds before comment types are registered + * on 'init', and on installs running without the registry. + */ + if ( function_exists( 'get_comment_types' ) ) { + $default_excluded_types = array_values( + array_unique( + array_merge( + $default_excluded_types, + get_comment_types( array( 'internal' => true ), 'names' ) + ) + ) + ); + } + + /** + * Filters the comment types that are excluded from query results by default. + * + * Comment types in this list are omitted from `WP_Comment_Query` results + * unless the query explicitly requests the 'all' type, or explicitly + * includes the specific type via the 'type' or 'type__in' query variables. + * + * This allows plugins to keep comment types out of standard comment + * listings and counts by default, without having to filter every + * query individually. The 'note' comment type, used by the editor, is + * excluded by default, as is every comment type registered with + * `'internal' => true`. The same set is applied when recalculating a post's + * stored comment count in wp_update_comment_count_now(), when counting + * pending comments, and when building the comment feed queries, so an + * excluded type neither inflates get_comments_number() nor appears in + * /comments/feed/. + * + * Values must be literal `comment_type` values as stored in the database; + * the special type tokens understood by WP_Comment_Query ('all', 'comment', + * 'comments', 'pings') are ignored, as are values that are not scalar. + * + * Register callbacks for this filter unconditionally (for example on + * 'plugins_loaded' or 'init') rather than toggling them per call. Query + * results are cached against the filtered set, so toggling does not serve + * stale results, but each distinct set is cached separately. + * + * A plugin that starts excluding a type which already has comments in the + * database should recount the affected posts on activation, and again on + * deactivation: stored `wp_posts.comment_count` values and the 'counts' cache + * group are only refreshed when a comment is created, updated, or deleted, so + * until then get_comments_number() and the admin count bubbles keep reporting + * the pre-filter totals. Call wp_update_comment_count_now() for each post that + * has a comment of the type. + * + * This exclusion is a default-visibility convenience, not an access-control + * mechanism: callers can still retrieve excluded types explicitly (for + * example with 'type' => 'all'), so do not rely on this filter to keep + * comment data private. Enforce capability checks wherever the data is + * displayed or exposed (for example over REST). + * + * @since 7.2.0 + * + * @param string[] $excluded_types Comment types excluded from query results by default. + * Defaults to the 'note' type and every comment type + * registered as internal. + * @param WP_Comment_Query|null $query The WP_Comment_Query instance, or null when the set is + * resolved outside of a comment query (recalculating a + * post's stored comment count, counting pending comments, + * building a comment feed query). + */ + $excluded_types = apply_filters( 'default_excluded_comment_types', $default_excluded_types, $query ); + + // Drop values that cannot be cast to a string, so a stray object or array cannot error out. + $excluded_types = array_filter( (array) $excluded_types, 'is_scalar' ); + + /* + * Drop empty strings, but keep a type literally named '0', which array_filter() + * without a callback would treat as empty. + */ + $excluded_types = array_filter( + array_map( 'strval', $excluded_types ), + static function ( $excluded_type ) { + return '' !== $excluded_type; + } + ); + + $excluded_types = array_unique( $excluded_types ); + + // Strip the special type tokens so an alias cannot poison explicit-type queries. + return array_values( array_diff( $excluded_types, array( 'all', 'comment', 'comments', 'pings' ) ) ); +} + +/** + * Builds the SQL condition that excludes the default-excluded comment types. + * + * For the comment queries that are assembled directly rather than through + * WP_Comment_Query - the comment feeds and the comment counters - so that they all + * honor the same {@see 'default_excluded_comment_types'} filter output. + * + * @since 7.2.0 + * @access private + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $column Optional. The `comment_type` column to filter on, qualified with a + * table name where the query joins other tables. Must not contain + * user input. Default 'comment_type'. + * @return string Prepared ` AND NOT IN (...)` condition, or an empty string when + * no comment types are excluded. + */ +function _wp_get_excluded_comment_types_clause( $column = 'comment_type' ) { + global $wpdb; + + $excluded_types = wp_get_default_excluded_comment_types(); + + if ( ! $excluded_types ) { + return ''; + } + + $placeholders = implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ); + $clause = sprintf( ' AND %s NOT IN ( %s )', $column, $placeholders ); + + // The column name comes from core call sites; the type values are prepared here. + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + return $wpdb->prepare( $clause, $excluded_types ); +} + /** * Updates the comment count for the post. * * @since 2.5.0 + * @since 7.2.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -3135,7 +3292,14 @@ function wp_update_comment_count_now( $post_id ) { $new = apply_filters( 'pre_wp_update_comment_count_now', null, $old, $post_id ); if ( is_null( $new ) ) { - $new = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note'", $post_id ) ); + $type_not_in = _wp_get_excluded_comment_types_clause(); + + $new = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1'", + $post_id + ) . $type_not_in + ); } else { $new = (int) $new; } diff --git a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php new file mode 100644 index 0000000000000..ef0059967a670 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php @@ -0,0 +1,137 @@ +comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '0', + ) + ); + } + + /** + * @ticket 65537 + */ + public function test_counts_only_pending_comments() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '1', + ) + ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ) ); + } + + /** + * @ticket 65537 + */ + public function test_excludes_note_type_by_default() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id ); + $this->make_pending( $post_id, 'note' ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ) ); + } + + /** + * A type added to the excluded set must drop out of the pending count. + * + * @ticket 65537 + */ + public function test_excludes_a_filtered_type() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id ); + $this->make_pending( $post_id, 'review' ); + + // 'review' is counted by default. + $this->assertSame( 2, get_pending_comments_num( $post_id ) ); + + $filter = static function ( $types ) { + $types[] = 'review'; + return $types; + }; + add_filter( 'default_excluded_comment_types', $filter ); + $num = get_pending_comments_num( $post_id ); + remove_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( 1, $num ); + } + + /** + * The exclusion is filter-driven, not a hard-coded 'note' literal. + * + * @ticket 65537 + */ + public function test_emptying_filter_counts_note_type() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id, 'note' ); + + $this->assertSame( 0, get_pending_comments_num( $post_id ) ); + + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + $num = get_pending_comments_num( $post_id ); + remove_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertSame( 1, $num ); + } + + /** + * A filter callback returning false degrades gracefully to no exclusions. + * + * Scalar returns are cast to an array and treated as a single excluded type; + * only values that normalize to an empty set disable the exclusions. + * + * @ticket 65537 + */ + public function test_false_filter_return_counts_all_types() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id, 'note' ); + + add_filter( 'default_excluded_comment_types', '__return_false' ); + $num = get_pending_comments_num( $post_id ); + remove_filter( 'default_excluded_comment_types', '__return_false' ); + + $this->assertSame( 1, $num ); + } + + /** + * @ticket 65537 + */ + public function test_array_input_returns_counts_keyed_by_post() { + $post_a = self::factory()->post->create(); + $post_b = self::factory()->post->create(); + $this->make_pending( $post_a ); + $this->make_pending( $post_a, 'note' ); + $this->make_pending( $post_b ); + $this->make_pending( $post_b ); + + $counts = get_pending_comments_num( array( $post_a, $post_b ) ); + + $this->assertSame( + array( + $post_a => 1, + $post_b => 2, + ), + $counts + ); + } +} diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 185bc5bfa48b0..b77c59c974e53 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -275,4 +275,155 @@ public function data_comment_type(): array { 'all type requested' => array( 'all' ), ); } + + /** + * A type added to the default-excluded set is not listed unless it is requested. + * + * The list table forces the default exclusions through 'type__not_in', so an + * excluded type stays hidden even for a 'type=all' request. + * + * @ticket 65537 + * + * @dataProvider data_unrequested_comment_type + * + * @param string $comment_type The comment_type request value to test. + */ + public function test_comments_list_table_hides_filtered_excluded_comment_type( string $comment_type ) { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + $regular_comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => '', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_add_private_comment_type' ) ); + + $_REQUEST['comment_type'] = $comment_type; + $this->table->prepare_items(); + + $this->assertSame( + array( $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ) + ); + } + + /** + * Data provider for test_comments_list_table_hides_filtered_excluded_comment_type(). + * + * @return array + */ + public function data_unrequested_comment_type(): array { + return array( + 'no type requested' => array( '' ), + 'all type requested' => array( 'all' ), + ); + } + + /** + * A type added to the default-excluded set is listed when explicitly requested. + * + * A plugin can surface its own excluded type through the + * 'admin_comment_types_dropdown' filter, so selecting it has to return results. + * + * @ticket 65537 + */ + public function test_comments_list_table_shows_explicitly_requested_excluded_comment_type() { + $post_id = self::factory()->post->create(); + + $private_comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => '', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_add_private_comment_type' ) ); + + $_REQUEST['comment_type'] = 'private'; + $this->table->prepare_items(); + + $this->assertSame( + array( $private_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ) + ); + } + + /** + * Emptying the excluded set surfaces notes in the untyped views, but the table + * still cannot be filtered to notes: the request for that type is dropped + * separately from the exclusions, because notes have their own visibility rules. + * + * @ticket 65537 + */ + public function test_comments_list_table_note_request_is_dropped_with_an_empty_excluded_set() { + $post_id = self::factory()->post->create(); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $regular_comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => '', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $_REQUEST['comment_type'] = ''; + $this->table->prepare_items(); + + $this->assertSameSets( + array( $note_id, $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ), + 'An empty excluded set should surface notes in the untyped view.' + ); + + $_REQUEST['comment_type'] = 'note'; + $this->table->prepare_items(); + + $this->assertSameSets( + array( $note_id, $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ), + 'A request for the note type should be dropped rather than filter the table.' + ); + } + + /** + * Adds the 'private' comment type to the default-excluded set. + * + * @param string[] $excluded_types Comment types excluded by default. + * @return string[] Filtered comment types. + */ + public function filter_add_private_comment_type( $excluded_types ): array { + $excluded_types[] = 'private'; + + return $excluded_types; + } } diff --git a/tests/phpunit/tests/comment/getPageOfComment.php b/tests/phpunit/tests/comment/getPageOfComment.php index 44e6af5ac3f87..e7aaa82011ca8 100644 --- a/tests/phpunit/tests/comment/getPageOfComment.php +++ b/tests/phpunit/tests/comment/getPageOfComment.php @@ -543,4 +543,126 @@ public function test_page_number_when_unapproved_comments_are_included_for_curre wp_set_current_user( $current_user ); } + + /** + * The page math has to count the comments the rendered list actually shows. Counting + * the default-excluded types too would send a comment permalink to a page the visitor + * never sees. + * + * @ticket 65537 + */ + public function test_default_excluded_types_are_not_counted() { + $post_id = self::factory()->post->create(); + + // Two notes, then two regular comments, oldest first. + foreach ( array( 'note', 'note', '', '' ) as $index => $comment_type ) { + $comment_ids[] = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'comment_date' => sprintf( '2024-01-01 10:0%d:00', $index ), + 'comment_date_gmt' => sprintf( '2024-01-01 10:0%d:00', $index ), + ) + ); + } + + $last_comment = end( $comment_ids ); + + $this->assertSame( + 1, + get_page_of_comment( $last_comment, array( 'per_page' => 2 ) ), + 'Notes should not push a regular comment onto a later page.' + ); + $this->assertSame( + 2, + get_page_of_comment( + $last_comment, + array( + 'per_page' => 2, + 'type' => 'all', + ) + ), + 'An explicit type of all should still count every type.' + ); + } + + /** + * A type added through the filter is excluded from the page math the same way. + * + * @ticket 65537 + */ + public function test_filtered_excluded_types_are_not_counted() { + $post_id = self::factory()->post->create(); + + foreach ( array( 'private', 'private', '', '' ) as $index => $comment_type ) { + $comment_ids[] = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'comment_date' => sprintf( '2024-01-01 10:0%d:00', $index ), + 'comment_date_gmt' => sprintf( '2024-01-01 10:0%d:00', $index ), + ) + ); + } + + $last_comment = end( $comment_ids ); + + $this->assertSame( + 2, + get_page_of_comment( $last_comment, array( 'per_page' => 2 ) ), + 'An unfiltered custom type counts toward the page math.' + ); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $this->assertSame( + 1, + get_page_of_comment( $last_comment, array( 'per_page' => 2 ) ), + 'Once excluded, the custom type should drop out of the page math.' + ); + } + + /** + * get_comment_link() forwards its own arguments to get_page_of_comment(), so its + * default has to match or the permalink lands on the wrong page. + * + * @ticket 65537 + * + * @covers ::get_comment_link + */ + public function test_get_comment_link_does_not_count_excluded_types() { + update_option( 'page_comments', 1 ); + update_option( 'comments_per_page', 2 ); + update_option( 'default_comments_page', 'oldest' ); + + $post_id = self::factory()->post->create(); + + foreach ( array( 'note', 'note', '', '' ) as $index => $comment_type ) { + $comment_ids[] = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'comment_date' => sprintf( '2024-01-01 10:0%d:00', $index ), + 'comment_date_gmt' => sprintf( '2024-01-01 10:0%d:00', $index ), + ) + ); + } + + $link = get_comment_link( end( $comment_ids ) ); + + $this->assertStringNotContainsString( + 'cpage=2', + $link, + 'The permalink should point at the page the comment is rendered on.' + ); + } } diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index dc870a78ae494..6f178c968d06e 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5534,4 +5534,494 @@ public function test_get_comment_count_excludes_note_type() { $this->assertSame( 1, $counts['all'] ); $this->assertSame( 1, $counts['total_comments'] ); } + + /** + * Helper method to create the standard set of comments used by the + * `default_excluded_comment_types` filter tests. + * + * Creates one comment of each of the 'comment', 'note', and 'private' types. + * + * @since 7.2.0 + * + * @return array<'comment'|'note'|'private', int> Array of created comment IDs keyed by type. + */ + protected function create_excluded_type_test_comments(): array { + return array( + 'comment' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + ) + ), + 'note' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + 'comment_type' => 'note', + ) + ), + 'private' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + 'comment_type' => 'private', + ) + ), + ); + } + + /** + * Returns the comment types for a list of comment IDs. + * + * @param int[] $comment_ids Comment IDs. + * @return string[] Comment types. + */ + private function get_comment_types_for_ids( array $comment_ids ): array { + return array_map( + static function ( int $comment_id ): string { + return get_comment( $comment_id )->comment_type; + }, + $comment_ids + ); + } + + /** + * A custom comment type added through the filter is excluded by default, + * alongside the default-excluded 'note' type. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_excludes_custom_type() { + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSameSets( + array( 'comment' ), + $this->get_comment_types_for_ids( $found ), + 'The custom excluded type and the default note type should both be omitted.' + ); + } + + /** + * Removing 'note' from the filtered list makes notes appear in default queries, + * proving the default exclusion itself is filterable. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_can_remove_note() { + $this->create_excluded_type_test_comments(); + + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSameSets( + array( 'comment', 'note', 'private' ), + $this->get_comment_types_for_ids( $found ), + 'With an empty exclusion list, all comment types should be returned.' + ); + } + + /** + * A custom excluded type is still returned when explicitly requested. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + * @dataProvider data_default_excluded_comment_types_explicit_request + * + * @param array $query_args Query arguments for WP_Comment_Query. + * @param string[] $expected_types Expected comment types. + */ + public function test_default_excluded_comment_types_filter_respects_explicit_request( array $query_args, array $expected_types ) { + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $query = new WP_Comment_Query(); + $found = $query->query( array_merge( $query_args, array( 'fields' => 'ids' ) ) ); + + $this->assertSameSets( $expected_types, $this->get_comment_types_for_ids( $found ) ); + } + + /** + * Data provider for explicit-request tests against a filtered excluded type. + * + * @since 7.2.0 + * + * @return array, expected_types: string[] }> + */ + public function data_default_excluded_comment_types_explicit_request(): array { + return array( + 'type all includes excluded types' => array( + 'query_args' => array( 'type' => 'all' ), + 'expected_types' => array( 'comment', 'note', 'private' ), + ), + 'explicit custom type' => array( + 'query_args' => array( 'type' => 'private' ), + 'expected_types' => array( 'private' ), + ), + 'custom type via type__in' => array( + 'query_args' => array( 'type__in' => array( 'private' ) ), + 'expected_types' => array( 'private' ), + ), + 'custom type with comment via type__in' => array( + 'query_args' => array( 'type__in' => array( 'private', 'comment' ) ), + 'expected_types' => array( 'private', 'comment' ), + ), + ); + } + + /** + * The filter receives the default 'note' type and the WP_Comment_Query instance. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_receives_default_and_instance() { + $filter_args = array(); + + add_filter( + 'default_excluded_comment_types', + static function ( $types, $query ) use ( &$filter_args ) { + $filter_args = array( $types, $query ); + return $types; + }, + 10, + 2 + ); + + $query = new WP_Comment_Query(); + $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSame( array( 'note' ), $filter_args[0], 'The filter should receive the default note type.' ); + $this->assertInstanceOf( WP_Comment_Query::class, $filter_args[1], 'The filter should receive the query instance.' ); + } + + /** + * A custom excluded type is only added once to the query, even when a query + * already excludes it via type__not_in. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_not_duplicated_in_query() { + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $captured_where = ''; + add_filter( + 'comments_clauses', + static function ( array $clauses ) use ( &$captured_where ): array { + $captured_where = $clauses['where']; + return $clauses; + } + ); + + $query = new WP_Comment_Query(); + $query->query( + array( + 'type__not_in' => array( 'private' ), + 'fields' => 'ids', + ) + ); + + $private_count = substr_count( $captured_where, "'private'" ); + $this->assertSame( 1, $private_count, 'The private type should only appear once in the WHERE clause.' ); + } + + /** + * A filter callback returning false degrades gracefully to no exclusions. + * + * Scalar returns are cast to an array and treated as a single excluded type; + * only values that normalize to an empty set disable the exclusions. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_false_return_is_tolerated() { + $comments = $this->create_note_type_test_comments(); + + add_filter( 'default_excluded_comment_types', '__return_false' ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + // With no exclusions, the note comment is included. + $this->assertContains( $comments['note'], $found ); + } + + /** + * The special type tokens understood by WP_Comment_Query are stripped from the + * filter output, so an alias cannot poison an explicit-type query. + * + * @ticket 65537 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_default_excluded_comment_types_filter_strips_special_tokens() { + $comments = $this->create_note_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + // 'pings' is a WP_Comment_Query alias, not a literal comment type. + $types[] = 'pings'; + return $types; + } + ); + + // An explicit request for pingbacks must still find them. + $query = new WP_Comment_Query(); + $found = $query->query( + array( + 'type' => 'pingback', + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( $comments['pingback'] ), array_map( 'intval', $found ) ); + + // The alias excludes nothing from a default query either. + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertContains( $comments['pingback'], $found ); + } + + /** + * A type requested via a query alias counts as an explicit request, so an + * excluded literal type is still returned when its alias is requested. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_respects_alias_request() { + $comments = $this->create_note_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'pingback'; + return $types; + } + ); + + // 'pings' is a query alias for the 'pingback' and 'trackback' types, so + // an explicit request for it must still return pingbacks. + $query = new WP_Comment_Query(); + $found = $query->query( + array( + 'type' => 'pings', + 'fields' => 'ids', + ) + ); + + $this->assertContains( $comments['pingback'], $found ); + + // A default query, which does not request the type, still excludes it. + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertNotContains( $comments['pingback'], $found ); + } + + /** + * A comment type named '0' is preserved by the normalization rather than + * being dropped as an empty value. + * + * @ticket 65537 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_default_excluded_comment_types_filter_preserves_zero_string_type() { + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = '0'; + return $types; + } + ); + + $this->assertContains( '0', wp_get_default_excluded_comment_types() ); + } + + /** + * Non-scalar values in the filter output are dropped rather than cast, so a + * stray object or array does not error out. + * + * @ticket 65537 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_default_excluded_comment_types_filter_drops_non_scalar_values() { + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = new stdClass(); + $types[] = array( 'nested' ); + $types[] = null; + $types[] = 'private'; + return $types; + } + ); + + $this->assertSame( + array( 'note', 'private' ), + wp_get_default_excluded_comment_types(), + 'Only the scalar comment types should survive normalization.' + ); + } + + /** + * A comment type registered as internal is excluded by default, so a plugin does + * not have to both register the type and add it through the filter. + * + * Skipped until the register_comment_type() API lands, at which point + * wp_get_default_excluded_comment_types() stops falling back to 'note' alone. + * + * @ticket 65537 + * @ticket 35214 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_internal_comment_types_are_excluded_by_default() { + if ( ! function_exists( 'register_comment_type' ) ) { + $this->markTestSkipped( 'Requires the comment type registry.' ); + } + + register_comment_type( + 'wp_tests_internal', + array( + 'label' => 'Internal', + 'public' => false, + 'internal' => true, + ) + ); + + $excluded_types = wp_get_default_excluded_comment_types(); + + $this->assertContains( + 'wp_tests_internal', + $excluded_types, + 'A comment type registered as internal should be excluded by default.' + ); + $this->assertContains( + 'note', + $excluded_types, + 'The note type should stay excluded alongside the registered internal types.' + ); + } + + /** + * The excluded set changes the results but is not a query var, so it has to be part + * of the cache key. Otherwise a persistent object cache serves entries built before + * a plugin added or removed a type. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comments + */ + public function test_default_excluded_comment_types_are_part_of_the_cache_key() { + $this->create_excluded_type_test_comments(); + + $callback = static function ( array $types ): array { + $types[] = 'private'; + return $types; + }; + + // Warm the cache with the type included. + $warm = new WP_Comment_Query(); + $this->assertContains( + 'private', + $this->get_comment_types_for_ids( $warm->query( array( 'fields' => 'ids' ) ) ), + 'The custom type should be returned before it is excluded.' + ); + + add_filter( 'default_excluded_comment_types', $callback ); + + $filtered = new WP_Comment_Query(); + $this->assertNotContains( + 'private', + $this->get_comment_types_for_ids( $filtered->query( array( 'fields' => 'ids' ) ) ), + 'A warm cache should not serve a type that is now excluded.' + ); + + remove_filter( 'default_excluded_comment_types', $callback ); + + $restored = new WP_Comment_Query(); + $this->assertContains( + 'private', + $this->get_comment_types_for_ids( $restored->query( array( 'fields' => 'ids' ) ) ), + 'A type should be returned again once it is no longer excluded.' + ); + } + + /** + * Resolving the excluded set once per query keeps the SQL and the cache key in + * agreement, and keeps the filter from running twice. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comments + */ + public function test_default_excluded_comment_types_filter_runs_once_per_query() { + $runs = 0; + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ) use ( &$runs ): array { + ++$runs; + return $types; + } + ); + + $query = new WP_Comment_Query(); + $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSame( 1, $runs, 'The filter should run once for a single query.' ); + } + + /** + * The admin count bubbles read wp_count_comments(), which routes through + * WP_Comment_Query and so inherits the exclusions. + * + * @ticket 65537 + * @covers ::wp_count_comments + */ + public function test_wp_count_comments_excludes_filtered_types() { + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $counts = wp_count_comments( self::$post_id ); + + $this->assertSame( 1, (int) $counts->approved, 'Only the regular comment should be counted.' ); + $this->assertSame( 1, (int) $counts->total_comments, 'The excluded types should not inflate the total.' ); + } } diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 9dbb1f244ccf8..2b0d8e5eab4d0 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -83,6 +83,101 @@ public function test_only_approved_regular_comments_are_counted() { $this->assertSame( '1', get_comments_number( $post_id ) ); } + /** + * A comment type excluded via the shared filter must not inflate the stored count. + * + * @ticket 65537 + */ + public function test_filtered_excluded_type_does_not_inflate_count() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'review', + 'comment_approved' => 1, + ) + ); + + // Without exclusion, both approved comments are counted. + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + $this->assertSame( '2', get_comments_number( $post_id ) ); + + // Excluding 'review' through the same filter that hides it from queries drops it from the count. + $filter = static function ( $types ) { + $types[] = 'review'; + return $types; + }; + add_filter( 'default_excluded_comment_types', $filter ); + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + remove_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + + /** + * The count is driven by the filtered set, not a hard-coded 'note' literal. + * + * Clearing the excluded set causes 'note' comments to be counted, proving the + * exclusion comes from the filter rather than an in-query literal. + * + * @ticket 65537 + */ + public function test_emptying_filter_counts_otherwise_excluded_types() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + ) + ); + + // By default the 'note' type is excluded. + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + + // A plugin that clears the excluded set causes notes to be counted. + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + remove_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + + /** + * A filter callback returning false degrades gracefully to no exclusions. + * + * Scalar returns are cast to an array and treated as a single excluded type; + * only values that normalize to an empty set disable the exclusions. + * + * @ticket 65537 + */ + public function test_false_filter_return_counts_all_types() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + ) + ); + + add_filter( 'default_excluded_comment_types', '__return_false' ); + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + remove_filter( 'default_excluded_comment_types', '__return_false' ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + public function _return_100() { return 100; } diff --git a/tests/phpunit/tests/query/commentFeed.php b/tests/phpunit/tests/query/commentFeed.php index d26bd3829c06a..7aa2a14af52e1 100644 --- a/tests/phpunit/tests/query/commentFeed.php +++ b/tests/phpunit/tests/query/commentFeed.php @@ -176,6 +176,169 @@ public function test_single_comment_feed_should_exclude_notes(): void { $this->assertSame( 5, $q->comment_count, 'Singular comments feed should include all regular comments.' ); } + /** + * Adds a custom comment type to the default-excluded set. + * + * @param string[] $excluded_types Comment types excluded by default. + * @return string[] Filtered comment types. + */ + public function filter_exclude_private_comment_type( $excluded_types ) { + $excluded_types[] = 'private'; + + return $excluded_types; + } + + /** + * @ticket 65537 + */ + public function test_main_comment_feed_should_exclude_filtered_types(): void { + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_ids[0], + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + $args = array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + ); + + $unfiltered = new WP_Query(); + $unfiltered->query( $args ); + + $this->assertContains( + $private_id, + array_map( 'intval', wp_list_pluck( $unfiltered->comments, 'comment_ID' ) ), + 'An unfiltered custom comment type should appear in the comments feed.' + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $q = new WP_Query(); + $q->query( $args ); + + $this->assertTrue( $q->is_comment_feed() ); + $this->assertFalse( $q->is_singular() ); + + $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); + $this->assertNotContains( $private_id, $comment_ids, 'Comments feed should not include excluded types.' ); + $this->assertSame( 15, $q->comment_count, 'Comments feed should include all regular comments.' ); + } + + /** + * @ticket 65537 + */ + public function test_archive_comment_feed_should_exclude_filtered_types(): void { + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_ids[0], + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $q = new WP_Query(); + $q->query( + array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + 'year' => (int) get_the_date( 'Y', self::$post_ids[0] ), + ) + ); + + $this->assertTrue( $q->is_comment_feed() ); + $this->assertTrue( $q->is_archive() ); + + $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); + $this->assertNotContains( $private_id, $comment_ids, 'Archive comments feed should not include excluded types.' ); + $this->assertSame( 15, $q->comment_count, 'Archive comments feed should include all regular comments.' ); + } + + /** + * @ticket 65537 + */ + public function test_single_comment_feed_should_exclude_filtered_types(): void { + $post = get_post( self::$post_ids[0] ); + $this->assertInstanceOf( WP_Post::class, $post ); + + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post->ID, + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $q = new WP_Query(); + $q->query( + array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + 'post_type' => $post->post_type, + 'name' => $post->post_name, + ) + ); + + $this->assertTrue( $q->is_comment_feed() ); + $this->assertTrue( $q->is_singular() ); + + $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); + $this->assertNotContains( $private_id, $comment_ids, 'Singular comments feed should not include excluded types.' ); + $this->assertSame( 5, $q->comment_count, 'Singular comments feed should include all regular comments.' ); + } + + /** + * The feed queries are cached against the SQL they build, so an excluded type + * must not survive in a cached result after the filter changes. + * + * @ticket 65537 + */ + public function test_comment_feed_cache_reflects_a_changed_excluded_set(): void { + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_ids[0], + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + $args = array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + ); + + $warm = new WP_Query(); + $warm->query( $args ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $filtered = new WP_Query(); + $filtered->query( $args ); + + $this->assertNotContains( + $private_id, + array_map( 'intval', wp_list_pluck( $filtered->comments, 'comment_ID' ) ), + 'A warm feed cache should not serve a type that is now excluded.' + ); + + remove_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $restored = new WP_Query(); + $restored->query( $args ); + + $this->assertContains( + $private_id, + array_map( 'intval', wp_list_pluck( $restored->comments, 'comment_ID' ) ), + 'A type should reappear in the feed once it is no longer excluded.' + ); + } + /** * @ticket 36904 */ diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 7162b278839d5..f9a4713aab8f2 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4234,6 +4234,83 @@ public function test_get_items_type_arg_unauthenticated( $comment_type, $count ) } } + /** + * The `default_excluded_comment_types` filter sets a visibility default, not an + * access-control boundary. An excluded type stays out of the default listing, but + * it is still readable by ID and still returned to a caller authorized to ask for + * it by name. + * + * @ticket 65537 + */ + public function test_excluded_comment_type_visibility_is_not_access_control() { + $comment_id = self::factory()->comment->create( + array( + 'comment_approved' => 1, + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'private', + ) + ); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + wp_logout(); + + // The excluded type is kept out of the default collection. + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'per_page', self::$per_page ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The default collection should be readable.' ); + $this->assertNotContains( + $comment_id, + wp_list_pluck( $response->get_data(), 'id' ), + 'An excluded type should not appear in the default collection.' + ); + + // Unauthenticated callers cannot enumerate it by asking for the type. + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'type', 'private' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( + 'rest_forbidden_param', + $response, + 401, + 'Requesting a type without edit_posts should be forbidden.' + ); + + // An approved comment of the type is still readable by ID. + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%d', $comment_id ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( + 200, + $response->get_status(), + 'Excluding a type does not restrict reading an approved comment of that type.' + ); + + // An authorized caller asking for the type explicitly still receives it. + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'type', 'private' ); + $request->set_param( 'per_page', self::$per_page ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'An authorized request for the type should succeed.' ); + $this->assertContains( + $comment_id, + wp_list_pluck( $response->get_data(), 'id' ), + 'An explicitly requested excluded type should still be returned.' + ); + } + /** * Data provider for comment type tests. *