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..d622ccf2111a3 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,40 @@ public function prepare_items() { $comment_status = 'all'; } + // The 'note' type is never listed here, so it is dropped from the request. $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 ) { + 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 +183,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..2dac177afa90b 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.1.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,19 @@ 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 ); + $excluded_types = wp_get_default_excluded_comment_types(); + + $type_not_in = ''; + if ( $excluded_types ) { + $type_not_in = $wpdb->prepare( + sprintf( ' AND comment_type NOT IN ( %s )', implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ) ), + $excluded_types + ); + } + + // $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..d99503e86ea78 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -544,6 +544,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.1.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -779,13 +780,40 @@ 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'; + $excluded_types = wp_get_default_excluded_comment_types( $this ); + + // 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/comment.php b/src/wp-includes/comment.php index b3738c24ec9df..d7b15126910bd 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2872,10 +2872,79 @@ 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. + * + * Applies the {@see 'default_excluded_comment_types'} filter and normalizes the + * result: 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.1.0 + * + * @param WP_Comment_Query|null $query Optional. The current query instance when called + * from WP_Comment_Query, or null in counting + * contexts. Default null. + * @return string[] Comment types excluded by default. + */ +function wp_get_default_excluded_comment_types( $query = null ) { + /** + * 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. The same set is applied when recalculating a + * post's stored comment count in wp_update_comment_count_now() and when + * counting pending comments, so an excluded type does not inflate + * get_comments_number(). + * + * 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 comment `last_changed` key, which does not + * account for this filter's output, so per-call toggling can serve stale + * results from the cache. + * + * 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.1.0 + * + * @param string[] $excluded_types Comment types excluded from query results by default. + * Default array contains the 'note' type. + * @param WP_Comment_Query|null $query The WP_Comment_Query instance, or null in counting + * contexts (recalculating a post's stored comment + * count, counting pending comments). + */ + $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), $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' ); + + $excluded_types = array_unique( array_filter( array_map( 'strval', $excluded_types ), 'strlen' ) ); + + // 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' ) ) ); +} + /** * Updates the comment count for the post. * * @since 2.5.0 + * @since 7.1.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -2914,7 +2983,26 @@ 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 ) ); + $excluded_types = wp_get_default_excluded_comment_types(); + + if ( $excluded_types ) { + $new = (int) $wpdb->get_var( + $wpdb->prepare( + sprintf( + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %%d AND comment_approved = '1' AND comment_type NOT IN (%s)", + implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ) + ), + array_merge( array( $post_id ), $excluded_types ) + ) + ); + } else { + $new = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1'", + $post_id + ) + ); + } } 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..3b9b6d35b482a 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -275,4 +275,108 @@ 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' ) ) + ); + } + + /** + * 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/query.php b/tests/phpunit/tests/comment/query.php index dc870a78ae494..f591be9480219 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5534,4 +5534,364 @@ 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.1.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.1.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.' + ); + } } 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; }