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 1dff5d26755ce..d948ef05c35b9 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -105,12 +105,33 @@ public function prepare_items() { $comment_status = 'all'; } + /* + * Notes and reactions cannot be requested as a type here, unlike other + * default-excluded types. A non-string request is ignored. + */ $comment_type = ''; - if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && is_string( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { $comment_type = $_REQUEST['comment_type']; } + /* + * The default exclusions are passed explicitly so they still apply to an 'all' + * request. The requested type is kept, so a plugin can list its own excluded + * type via the 'admin_comment_types_dropdown' filter. + */ + switch ( $comment_type ) { + 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'] ) : ''; @@ -157,7 +178,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => array( 'note', 'reaction' ), + '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 1cfc93144e978..df066f1df5c32 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -224,6 +224,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 Exclude the 'reaction' comment type from the count. + * @since 7.3.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -243,7 +245,10 @@ 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 NOT IN ( 'note', 'reaction' ) GROUP BY comment_post_ID", ARRAY_A ); + $type_not_in = wp_get_excluded_comment_types_clause(); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $post_id_in holds integers and $type_not_in is prepared. + $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 ad706ba532114..090f758fa1be6 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -66,6 +66,14 @@ class WP_Comment_Query { */ protected $filtered_where_clause; + /** + * Comment types excluded from the results by default, or null until resolved. + * + * @since 7.3.0 + * @var string[]|null + */ + protected $default_excluded_comment_types = null; + /** * Date query container * @@ -460,6 +468,14 @@ 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 excluded set changes the results without bumping 'last_changed', so it is + * part of the key. get_comment_ids() reuses it. + */ + $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' ); @@ -550,6 +566,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 Excludes the 'reaction' comment type, unless 'all' or the 'reaction' types are requested. + * @since 7.3.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -781,11 +798,41 @@ protected function get_comment_ids() { 'NOT IN' => (array) $this->query_vars['type__not_in'], ); - // Exclude the 'note' and 'reaction' comment types, unless 'all' types or that type explicitly are requested. + // 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' is requested, exclude each default-excluded type the query does not + * request. Aliases are expanded so a type requested via 'pings' stays included. + */ if ( ! in_array( 'all', $raw_types['IN'], true ) ) { - foreach ( array( 'note', 'reaction' ) as $excluded_type ) { + $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, $raw_types['IN'], true ) && + ! in_array( $excluded_type, $requested_types, true ) && ! in_array( $excluded_type, $raw_types['NOT IN'], true ) ) { $raw_types['NOT IN'][] = $excluded_type; diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 2b593c0addd6b..90a4bc589aeb7 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2822,13 +2822,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 NOT IN ( 'note', 'reaction' ) $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 NOT IN ( 'note', 'reaction' )"; + $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1'$ctype_not_in"; $cgroupby = ''; } @@ -3541,11 +3543,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 NOT IN ( 'note', 'reaction' )", &$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.php b/src/wp-includes/comment.php index b6847266eb162..c4578c54fb9ed 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -402,6 +402,8 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme * @since 4.7.0 Replaced caching the modified date in a local static variable * with the Object Cache API. * @since 7.2.0 The 'note' and 'reaction' comment types are excluded from the query. + * @since 7.3.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -419,19 +421,23 @@ function get_lastcommentmodified( $timezone = 'server' ) { return $comment_modified_date; } + $type_not_in = wp_get_excluded_comment_types_clause(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $type_not_in is prepared. switch ( $timezone ) { case 'gmt': - $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" ); + $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1'$type_not_in ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'blog': - $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" ); + $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1'$type_not_in ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'server': $add_seconds_server = gmdate( 'Z' ); - $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) ); + $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1'", $add_seconds_server ) . "$type_not_in ORDER BY comment_date_gmt DESC LIMIT 1" ); break; } + // phpcs:enable if ( $comment_modified_date ) { wp_cache_set( $key, $comment_modified_date, 'timeinfo' ); @@ -1102,6 +1108,7 @@ 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.3.0 Comment types excluded by default are no longer counted when 'type' is 'all'. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1111,7 +1118,8 @@ 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'. 'all' leaves out the + * comment types excluded by default. Default 'all'. * @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 @@ -1189,6 +1197,11 @@ function get_page_of_comment( $comment_id, $args = array() ) { ), ); + // 'all' disables the default exclusions, but comments_template() never renders those types. + if ( in_array( 'all', (array) $args['type'], true ) ) { + $comment_args['type__not_in'] = wp_get_default_excluded_comment_types(); + } + if ( is_user_logged_in() ) { $comment_args['include_unapproved'] = array( get_current_user_id() ); } else { @@ -1203,24 +1216,27 @@ function get_page_of_comment( $comment_id, $args = array() ) { * Filters the arguments used to query comments in get_page_of_comment(). * * @since 5.5.0 + * @since 7.3.0 Added the `$type__not_in` argument. * * @see WP_Comment_Query::__construct() * * @param array $comment_args { * Array of WP_Comment_Query arguments. * - * @type string $type Limit paginated comments to those matching a given type. - * Accepts 'comment', 'trackback', 'pingback', 'pings' - * (trackbacks and pingbacks), or 'all'. Default 'all'. - * @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 - * of comment objects (false). - * @type string $status Comment status. - * @type int $parent Parent ID of comment to retrieve children of. - * @type array $date_query Date query clauses to limit comments by. See WP_Date_Query. - * @type array $include_unapproved Array of IDs or email addresses whose unapproved comments - * will be included in paginated comments. + * @type string $type Limit paginated comments to those matching a given type. + * Accepts 'comment', 'trackback', 'pingback', 'pings' + * (trackbacks and pingbacks), or 'all'. Default 'all'. + * @type string[] $type__not_in Comment types excluded by default. Only set when + * `$type` is 'all'. + * @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 + * of comment objects (false). + * @type string $status Comment status. + * @type int $parent Parent ID of comment to retrieve children of. + * @type array $date_query Date query clauses to limit comments by. See WP_Date_Query. + * @type array $include_unapproved Array of IDs or email addresses whose unapproved comments + * will be included in paginated comments. * } */ $comment_args = apply_filters( 'get_page_of_comment_query_args', $comment_args ); @@ -3213,10 +3229,113 @@ 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. + * + * Starts from 'note', 'reaction' and every comment type registered as internal, then + * applies the {@see 'default_excluded_comment_types'} filter. The result holds unique, + * non-empty strings and never the WP_Comment_Query type aliases ('all', 'comment', + * 'comments', 'pings'). + * + * @since 7.3.0 + * + * @param WP_Comment_Query|null $query Optional. The comment query resolving the set, if any. + * Default null. + * @return string[] Comment types excluded by default. + */ +function wp_get_default_excluded_comment_types( $query = null ) { + $default_excluded_types = array( 'note', 'reaction' ); + + // The comment type registry is optional; 'note' and 'reaction' stay excluded without it. + if ( function_exists( 'get_comment_types' ) ) { + $internal_types = array_filter( (array) get_comment_types( array( 'internal' => true ), 'names' ), 'is_string' ); + + $default_excluded_types = array_values( array_unique( array_merge( $default_excluded_types, $internal_types ) ) ); + } + + /** + * Filters the comment types that are excluded from query results by default. + * + * Excluded types are left out of WP_Comment_Query results unless the query requests + * 'all' types or names the type in 'type' or 'type__in'. The same set is applied to + * the stored post comment count, the pending comment count, the comment feeds and + * get_lastcommentmodified(). + * + * Values are literal `comment_type` values. The WP_Comment_Query type aliases + * ('all', 'comment', 'comments', 'pings') and non-scalar values are ignored. + * + * Stored comment counts are only refreshed when a comment changes, so a plugin that + * changes the set for types that already have comments should call + * wp_update_comment_count_now() for the affected posts on activation and deactivation. + * + * This is a default-visibility setting, not access control: excluded types can still + * be queried explicitly. + * + * @since 7.3.0 + * + * @param string[] $excluded_types Comment types excluded by default. Defaults to + * 'note', 'reaction' and every comment type + * registered as internal. + * @param WP_Comment_Query|null $query The comment query resolving the set, or null + * outside of a comment query. + */ + $excluded_types = apply_filters( 'default_excluded_comment_types', $default_excluded_types, $query ); + + $excluded_types = array_filter( (array) $excluded_types, 'is_scalar' ); + + // Keeps a type named '0', which array_filter() without a callback would drop. + $excluded_types = array_filter( + array_map( 'strval', $excluded_types ), + static function ( $excluded_type ) { + return '' !== $excluded_type; + } + ); + + $excluded_types = array_unique( $excluded_types ); + + // An alias in the set would exclude types a query requests explicitly. + return array_values( array_diff( $excluded_types, array( 'all', 'comment', 'comments', 'pings' ) ) ); +} + +/** + * Builds the SQL condition that excludes the default-excluded comment types. + * + * For comment queries built without WP_Comment_Query. + * + * @since 7.3.0 + * + * @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, for example + * 'wp_comments.comment_type'. 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( string $column = 'comment_type' ): string { + global $wpdb; + + $excluded_types = wp_get_default_excluded_comment_types(); + + if ( ! $excluded_types ) { + return ''; + } + + // Quotes each part of a qualified name as its own identifier. + $column_parts = explode( '.', $column ); + $column_sql = implode( '.', array_fill( 0, count( $column_parts ), '%i' ) ); + $placeholders = implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Both hold placeholders only. + return (string) $wpdb->prepare( " AND $column_sql NOT IN ( $placeholders )", array_merge( $column_parts, $excluded_types ) ); +} + /** * Updates the comment count for the post. * * @since 2.5.0 + * @since 7.3.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -3255,7 +3374,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 NOT IN ( 'note', 'reaction' )", $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..fe57d4ac236bf --- /dev/null +++ b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php @@ -0,0 +1,132 @@ +comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '0', + ) + ); + } + + /** + * @ticket 65537 + */ + public function test_counts_only_pending_comments(): void { + $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(): void { + $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(): void { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id ); + $this->make_pending( $post_id, 'review' ); + + $this->assertSame( 2, get_pending_comments_num( $post_id ), 'A custom type should be counted by default.' ); + + add_filter( + 'default_excluded_comment_types', + static function ( $types ) { + $types[] = 'review'; + return $types; + } + ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ), 'A filtered type should not be counted.' ); + } + + /** + * The exclusion is filter-driven, not a hard-coded 'note' literal. + * + * @ticket 65537 + */ + public function test_emptying_filter_counts_note_type(): void { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id, 'note' ); + + $this->assertSame( 0, get_pending_comments_num( $post_id ), 'A note should not be counted by default.' ); + + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ), 'A note should be counted once nothing is excluded.' ); + } + + /** + * 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(): void { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id, 'note' ); + + add_filter( 'default_excluded_comment_types', '__return_false' ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ) ); + } + + /** + * @ticket 65537 + */ + public function test_array_input_returns_counts_keyed_by_post(): void { + $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->assertSameSetsWithIndex( + 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 b1bbce346328a..73892ea06f200 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -279,4 +279,196 @@ public static 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 ): void { + $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->assertSameSets( + 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(): void { + $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->assertSameSets( + 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(): void { + $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.' + ); + } + + /** + * A non-string 'comment_type' request is ignored rather than raising a warning. + * + * The requested type is subtracted from the default-excluded set with + * array_diff(), which casts its arguments to strings. An array request such as + * comment_type[]=private would otherwise raise an "Array to string conversion" + * warning and could skip the exclusion it names. + * + * @ticket 65537 + */ + public function test_comments_list_table_ignores_a_non_string_comment_type_request(): void { + $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'] = array( 'private' ); + $this->table->prepare_items(); + + $this->assertSameSets( + array( $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ), + 'An array request should be treated as no type filter and keep the excluded type hidden.' + ); + } + + /** + * 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/getLastCommentModified.php b/tests/phpunit/tests/comment/getLastCommentModified.php index 4f61f626ffb7a..512d1b058d95c 100644 --- a/tests/phpunit/tests/comment/getLastCommentModified.php +++ b/tests/phpunit/tests/comment/getLastCommentModified.php @@ -212,4 +212,72 @@ public function test_only_internal_comment_types_returns_false() { $this->assertFalse( get_lastcommentmodified() ); } + + /** + * Tests that a comment type added via the 'default_excluded_comment_types' filter + * does not move the last comment modified date. + * + * @ticket 65537 + * + * @dataProvider data_internal_comment_types_are_excluded + * + * @param string $timezone Timezone argument to pass to get_lastcommentmodified(). + * @param string $expected Expected date. + */ + public function test_filtered_excluded_comment_type_is_excluded( string $timezone, string $expected ): void { + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'review'; + return $types; + } + ); + + self::factory()->comment->create( + array( + 'comment_date' => '2000-01-01 11:00:00', + 'comment_date_gmt' => '2000-01-01 10:00:00', + ) + ); + self::factory()->comment->create( + array( + 'comment_type' => 'review', + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + + $modified = get_lastcommentmodified( $timezone ); + + $this->assertIsString( $modified ); + $this->assertSame( strtotime( $expected ), strtotime( $modified ) ); + } + + /** + * Tests that emptying the excluded comment types lets notes move the date. + * + * @ticket 65537 + */ + public function test_empty_excluded_comment_types_includes_notes(): void { + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + self::factory()->comment->create( + array( + 'comment_date' => '2000-01-01 11:00:00', + 'comment_date_gmt' => '2000-01-01 10:00:00', + ) + ); + self::factory()->comment->create( + array( + 'comment_type' => 'note', + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + + $modified = get_lastcommentmodified( 'gmt' ); + + $this->assertIsString( $modified ); + $this->assertSame( strtotime( '2020-01-01 10:00:00' ), strtotime( $modified ) ); + } } diff --git a/tests/phpunit/tests/comment/getPageOfComment.php b/tests/phpunit/tests/comment/getPageOfComment.php index 4849d0670d54b..8d72c32b9b914 100644 --- a/tests/phpunit/tests/comment/getPageOfComment.php +++ b/tests/phpunit/tests/comment/getPageOfComment.php @@ -543,4 +543,175 @@ 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(): void { + $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( + 1, + get_page_of_comment( + $last_comment, + array( + 'per_page' => 2, + 'type' => 'all', + ) + ), + 'An explicit type of all should match the default.' + ); + $this->assertSame( + 1, + get_page_of_comment( + $last_comment, + array( + 'per_page' => 2, + 'type' => '', + ) + ), + 'An empty type should match the default.' + ); + } + + /** + * A default-excluded type is still counted when it is requested by name. + * + * @ticket 65537 + */ + public function test_explicitly_requested_excluded_type_is_counted(): void { + $post_id = self::factory()->post->create(); + + foreach ( array( 'note', '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 ), + ) + ); + } + + $this->assertSame( + 2, + get_page_of_comment( + end( $comment_ids ), + array( + 'per_page' => 2, + 'type' => 'note', + ) + ) + ); + } + + /** + * 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(): void { + $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 arguments to get_page_of_comment(). Walker_Comment + * passes the wp_list_comments() arguments, which set 'type' to 'all', so both the + * default and an explicit 'all' have to land on the rendered page. + * + * @ticket 65537 + * + * @covers ::get_comment_link + */ + public function test_get_comment_link_does_not_count_excluded_types(): void { + 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 ), + ) + ); + } + + $last_comment = end( $comment_ids ); + + $this->assertStringNotContainsString( + 'cpage=2', + get_comment_link( $last_comment ), + 'The default permalink should point at the page the comment is rendered on.' + ); + $this->assertStringNotContainsString( + 'cpage=2', + get_comment_link( $last_comment, array( 'type' => 'all' ) ), + 'A permalink with an explicit type of all should point at the same page.' + ); + } } diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index b19ff930b9d0b..e14853986d4eb 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5491,8 +5491,8 @@ protected function create_internal_comment_type_test_comments(): array { * @covers WP_Comment_Query::get_comment_ids * @dataProvider data_internal_comment_type_exclusion * - * @param array $query_args Query arguments for WP_Comment_Query. - * @param string[] $expected_types Expected comment types. + * @param array $query_args Query arguments for WP_Comment_Query. + * @param string[] $expected_types Expected comment types. */ public function test_internal_comment_type_exclusion( array $query_args, array $expected_types ) { $this->create_internal_comment_type_test_comments(); @@ -5515,7 +5515,7 @@ static function ( int $comment_id ): string { * * @since 6.9.0 * - * @return array, expected_types: string[] }> + * @return array, expected_types: string[] }> */ public static function data_internal_comment_type_exclusion(): array { return array( @@ -5647,4 +5647,518 @@ public function test_get_comment_count_excludes_internal_comment_types() { $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.3.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(): void { + $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(): void { + $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 ): void { + $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.3.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' and 'reaction' types 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(): void { + $filter = new MockAction(); + add_filter( 'default_excluded_comment_types', array( $filter, 'filter' ), 10, 2 ); + + $query = new WP_Comment_Query(); + $query->query( array( 'fields' => 'ids' ) ); + + $filter_args = $filter->get_args()[0]; + + $this->assertSame( array( 'note', 'reaction' ), $filter_args[0], 'The filter should receive the default note and reaction types.' ); + $this->assertSame( $query, $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(): void { + $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(): void { + $comments = $this->create_internal_comment_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(): void { + $comments = $this->create_internal_comment_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->assertSameSets( + array( $comments['pingback'] ), + array_map( 'intval', $found ), + 'An explicit request for pingbacks should still find them.' + ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertContains( $comments['pingback'], $found, 'The alias should exclude nothing from a default query.' ); + } + + /** + * 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(): void { + $comments = $this->create_internal_comment_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, 'Requesting the pings alias should return pingbacks.' ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertNotContains( $comments['pingback'], $found, 'A default query should still exclude pingbacks.' ); + } + + /** + * 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(): void { + 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(): void { + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = new stdClass(); + $types[] = array( 'nested' ); + $types[] = null; + $types[] = 'private'; + return $types; + } + ); + + $this->assertSameSets( + array( 'note', 'reaction', 'private' ), + wp_get_default_excluded_comment_types(), + 'Only the scalar comment types should survive normalization.' + ); + } + + /** + * A table-qualified column is quoted one identifier per part, so the clause stays + * valid when the query joins other tables. + * + * @ticket 65537 + * @covers ::wp_get_excluded_comment_types_clause + */ + public function test_excluded_comment_types_clause_quotes_a_qualified_column(): void { + global $wpdb; + + $this->assertSame( + " AND `comment_type` NOT IN ( 'note', 'reaction' )", + wp_get_excluded_comment_types_clause(), + 'The default column should be quoted as an identifier.' + ); + $this->assertSame( + " AND `{$wpdb->comments}`.`comment_type` NOT IN ( 'note', 'reaction' )", + wp_get_excluded_comment_types_clause( "{$wpdb->comments}.comment_type" ), + 'Each part of a qualified column should be quoted separately.' + ); + } + + /** + * Nothing is added to the query when no comment types are excluded. + * + * @ticket 65537 + * @covers ::wp_get_excluded_comment_types_clause + */ + public function test_excluded_comment_types_clause_is_empty_without_excluded_types(): void { + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertSame( '', wp_get_excluded_comment_types_clause() ); + } + + /** + * 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(): void { + 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(): void { + $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(): void { + $filter = new MockAction(); + add_filter( 'default_excluded_comment_types', array( $filter, 'filter' ) ); + + $query = new WP_Comment_Query(); + $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSame( 1, $filter->get_call_count(), '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(): void { + $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 34ec540400ab4..ed76805d68bd8 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -97,6 +97,96 @@ 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(): void { + $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, + ) + ); + + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The unfiltered count should be updated.' ); + $this->assertSame( '2', get_comments_number( $post_id ), 'Both approved comments should be counted without the filter.' ); + + add_filter( + 'default_excluded_comment_types', + static function ( $types ) { + $types[] = 'review'; + return $types; + } + ); + + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The filtered count should be updated.' ); + $this->assertSame( '1', get_comments_number( $post_id ), 'The filtered type should drop out of the count.' ); + } + + /** + * 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(): void { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + ) + ); + + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The default count should be updated.' ); + $this->assertSame( '0', get_comments_number( $post_id ), 'A note should not be counted by default.' ); + + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The unfiltered count should be updated.' ); + $this->assertSame( '1', get_comments_number( $post_id ), 'A note should be counted once nothing is excluded.' ); + } + + /** + * 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(): void { + $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 ), 'The count should be updated.' ); + $this->assertSame( '1', get_comments_number( $post_id ), 'A note should be counted once nothing is excluded.' ); + } + public function _return_100() { return 100; } diff --git a/tests/phpunit/tests/query/commentFeed.php b/tests/phpunit/tests/query/commentFeed.php index 09aab062731f3..fee7d6cec4cc5 100644 --- a/tests/phpunit/tests/query/commentFeed.php +++ b/tests/phpunit/tests/query/commentFeed.php @@ -191,6 +191,169 @@ public function test_single_comment_feed_should_exclude_internal_comment_types( $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(), 'The query should be a comments feed.' ); + $this->assertFalse( $q->is_singular(), 'The query should not be 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(), 'The query should be a comments feed.' ); + $this->assertTrue( $q->is_archive(), 'The query should be an 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, 'The fixture post should exist.' ); + + $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(), 'The query should be a comments feed.' ); + $this->assertTrue( $q->is_singular(), 'The query should be 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.' + ); + } + /** * Data provider. * diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 898b344842f9a..cc2cf8efc563a 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4469,6 +4469,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(): void { + $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. *