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 7294f89d1bd3b..1dff5d26755ce 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -107,7 +107,7 @@ public function prepare_items() { $comment_type = ''; - if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { + if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { $comment_type = $_REQUEST['comment_type']; } @@ -157,7 +157,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => array( 'note' ), + 'type__not_in' => array( 'note', 'reaction' ), '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..1cfc93144e978 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -223,6 +223,7 @@ 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. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -242,7 +243,7 @@ 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 ); + $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 ); 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 8b5f1f276c391..76367e43ee08a 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -549,6 +549,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. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -784,13 +785,16 @@ 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'; + // Exclude the 'note' and 'reaction' comment types, unless 'all' types or that type explicitly are requested. + if ( ! in_array( 'all', $raw_types['IN'], true ) ) { + foreach ( array( 'note', 'reaction' ) as $excluded_type ) { + if ( + ! in_array( $excluded_type, $raw_types['IN'], true ) && + ! in_array( $excluded_type, $raw_types['NOT IN'], true ) + ) { + $raw_types['NOT IN'][] = $excluded_type; + } + } } $comment_types = array(); diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index a01724ce966cc..4f98934468c29 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2803,11 +2803,11 @@ public function get_posts() { if ( $this->is_comment_feed && ! $this->is_singular ) { if ( $this->is_archive || $this->is_search ) { $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID ) $join "; - $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note' $where"; + $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' ) $where"; $cgroupby = "{$wpdb->comments}.comment_id"; } else { // Other non-singular, e.g. front. $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID )"; - $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'"; + $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' )"; $cgroupby = ''; } @@ -3524,7 +3524,7 @@ public function get_posts() { $cjoin = apply_filters_ref_array( 'comment_feed_join', array( '', &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ - $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'", &$this ) ); + $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' )", &$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 9111358efc40b..f290c3b27da6a 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -351,6 +351,40 @@ function get_comment_statuses() { return $status; } +/** + * Retrieves the IDs of a note's reaction comments. + * + * Reactions hang off a note as child comments, so they have to be trashed + * and deleted along with it. + * + * @since 7.2.0 + * + * @param int|WP_Comment $comment_id Note comment ID or WP_Comment object. + * @param string $status Optional. Comment status to match, as accepted by + * WP_Comment_Query. Note that 'all' covers only approved + * and pending comments, so trashed reactions need an + * explicit status. Default 'any'. + * @return int[] Reaction comment IDs, oldest first. Empty if the comment is not a note. + */ +function wp_get_note_reaction_ids( $comment_id, $status = 'any' ): array { + $comment = get_comment( $comment_id ); + + if ( ! $comment || 'note' !== $comment->comment_type ) { + return array(); + } + + return get_comments( + array( + 'parent' => $comment->comment_ID, + 'type' => 'reaction', + 'status' => $status, + 'fields' => 'ids', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ) + ); +} + /** * Gets the default comment status for a post type. * @@ -401,6 +435,7 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme * @since 1.5.0 * @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. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -420,15 +455,15 @@ function get_lastcommentmodified( $timezone = 'server' ) { switch ( $timezone ) { case 'gmt': - $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1' 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' AND comment_type NOT IN ( 'note', 'reaction' ) 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' ORDER BY comment_date_gmt DESC LIMIT 1" ); + $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" ); 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' 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' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) ); break; } @@ -1571,6 +1606,8 @@ function wp_count_comments( $post_id = 0 ) { * post ID available. * * @since 2.0.0 + * @since 7.2.0 A note's reactions are deleted along with it, rather than + * being reparented. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1601,6 +1638,16 @@ function wp_delete_comment( $comment_id, $force_delete = false ) { */ do_action( 'delete_comment', $comment->comment_ID, $comment ); + /* + * Delete a note's reactions rather than letting them be reparented below. + * A reaction only means anything attached to its note, and an orphaned one + * would keep the reactor's identity on a note that no longer exists. This + * covers every status: a reaction the user removed is trashed, not deleted. + */ + foreach ( wp_get_note_reaction_ids( $comment ) as $reaction_id ) { + wp_delete_comment( $reaction_id, true ); + } + // Move children up a level. $children = $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM $wpdb->comments WHERE comment_parent = %d", $comment->comment_ID ) ); if ( ! empty( $children ) ) { @@ -1651,6 +1698,7 @@ function wp_delete_comment( $comment_id, $force_delete = false ) { * * @since 2.9.0 * @since 6.9.0 Any child notes are deleted when deleting a note. + * @since 7.2.0 A note's reactions are trashed along with it. * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. * @return bool True on success, false on failure. @@ -1717,6 +1765,19 @@ function wp_trash_comment( $comment_id ) { */ do_action( 'trashed_comment', $comment->comment_ID, $comment ); + /* + * Trash a note's reactions with it, at any depth. The child-note + * cascade below trashes each reply in turn, which brings the replies' + * own reactions along through this same branch. + * + * Restoring the note does not bring its reactions back: + * wp_untrash_comment() restores no children of any type, so restoring + * children is left to a cascade that covers every child type together. + */ + foreach ( wp_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { + wp_trash_comment( $reaction_id ); + } + // For top level 'note' type comments, also trash children. if ( 'note' === $comment->comment_type && 0 === (int) $comment->comment_parent ) { $children = $comment->get_children( @@ -3159,7 +3220,7 @@ 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 ) ); + $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 ) ); } else { $new = (int) $new; } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index a35203c65e996..f6bd62e0e3ca5 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4378,10 +4378,12 @@ function is_avatar_comment_type( $comment_type ) { * @since 3.0.0 * * @since 6.9.0 The 'note' comment type was added. + * @since 7.2.0 The 'reaction' comment type was added. * - * @param array $types An array of content types. Default contains 'comment' and 'note'. + * @param array $types An array of content types. Default contains 'comment', 'note', + * and 'reaction'. */ - $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note' ) ); + $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note', 'reaction' ) ); return in_array( $comment_type, (array) $allowed_comment_types, true ); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index 1fa960d58488c..bc3dbb25e396c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -24,6 +24,34 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { */ protected $meta; + /** + * Pre-fetched reaction summaries keyed by note comment ID. + * + * Populated by get_items() to avoid N+1 queries when listing notes + * with their reaction summaries. Reset after each get_items() call. + * + * @since 7.2.0 + * @var array|null + */ + protected $reaction_summaries = null; + + /** + * Retrieves the hex keys of the emoji a note reaction accepts. + * + * Each key is the emoji's lowercase code points, padded to four digits, + * matching the client's `emojiToHexKey()`. A reaction stores its key in + * `comment_content`. + * + * @since 7.2.0 + * + * @return string[] Hex keys for heart, celebration, smile, eyes and rocket. + * + * @phpstan-return non-empty-list + */ + private static function get_note_reaction_keys(): array { + return array( '2764', '1f389', '1f604', '1f440', '1f680' ); + } + /** * Constructor. * @@ -123,7 +151,7 @@ public function register_routes() { * @return true|WP_Error True if the request has read access, error object otherwise. */ public function get_items_permissions_check( $request ) { - $is_note = 'note' === $request['type']; + $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true ); $is_edit_context = 'edit' === $request['context']; $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); $forbidden_params = array(); @@ -330,6 +358,23 @@ public function get_items( $request ) { if ( ! $is_head_request ) { $comments = array(); + /* + * When listing notes that include the reaction_summary field, + * pre-fetch all summaries in a single aggregated query to + * avoid an N+1 query in prepare_item_for_response(). + */ + $fields = $this->get_fields_for_response( $request ); + if ( + ! empty( $request['type'] ) && + 'note' === $request['type'] && + rest_is_field_included( 'reaction_summary', $fields ) + ) { + $note_ids = array_map( 'intval', wp_list_pluck( $query_result, 'comment_ID' ) ); + if ( ! empty( $note_ids ) ) { + $this->prefetch_reaction_summaries( $note_ids ); + } + } + foreach ( $query_result as $comment ) { if ( ! $this->check_read_permission( $comment, $request ) ) { continue; @@ -338,6 +383,8 @@ public function get_items( $request ) { $data = $this->prepare_item_for_response( $comment, $request ); $comments[] = $this->prepare_response_for_collection( $data ); } + + $this->reaction_summaries = null; } $total_comments = (int) $query->found_comments; @@ -437,8 +484,8 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` type. - $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + // Re-map edit context capabilities when requesting `note` or `reaction` type. + $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -497,7 +544,7 @@ public function get_item( $request ) { * @return true|WP_Error True if the request has access to create items, error object otherwise. */ public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && 'note' === $request['type']; + $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); if ( ! is_user_logged_in() && $is_note ) { return new WP_Error( @@ -549,6 +596,23 @@ public function create_item_permissions_check( $request ) { ); } + /* + * A reaction is always first-person. create_item() enforces one emoji per + * user per note against the current user, and update_item() refuses to + * reattribute one, so a reaction stored against somebody else would be a + * row the uniqueness check and the reaction summary can never see. + */ + if ( + ! empty( $request['type'] ) && 'reaction' === $request['type'] && + isset( $request['author'] ) && get_current_user_id() !== (int) $request['author'] + ) { + return new WP_Error( + 'rest_comment_invalid_author', + __( 'Sorry, you are not allowed to add a reaction on behalf of another user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) { if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) { return new WP_Error( @@ -645,6 +709,7 @@ public function create_item_permissions_check( $request ) { * Creates a comment. * * @since 4.7.0 + * @since 7.2.0 Added support for the `reaction` comment type. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. @@ -659,7 +724,7 @@ public function create_item( $request ) { } // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.' ), @@ -667,6 +732,154 @@ public function create_item( $request ) { ); } + /* + * The canonical reaction key, populated once validated below so the + * stored content matches what was validated (not the raw input). + */ + $reaction_key = null; + + // Validate reaction-specific constraints. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { + // Reaction parent must be specified. + if ( empty( $request['parent'] ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must have a parent note.' ), + array( 'status' => 400 ) + ); + } + + // Reaction parent must exist and be a note. + $parent_comment = get_comment( $request['parent'] ); + if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note.' ), + array( 'status' => 400 ) + ); + } + + /* + * The parent note must belong to the post the reaction targets. + * create_item_permissions_check() requires `post` and checks that the + * user can edit it, so this runs before the parent's status checks: + * a note on another post gets the same error whatever its status, + * rather than revealing whether it is trashed, spammed or resolved. + */ + if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note on the same post.' ), + array( 'status' => 400 ) + ); + } + + // A reaction under a trashed or spammed note would escape the trash cascade. + if ( in_array( $parent_comment->comment_approved, array( 'trash', 'spam' ), true ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a trashed or spam note.' ), + array( 'status' => 400 ) + ); + } + + /* + * Resolving a thread approves its root note, and the editor disables + * reactions from then on. Hold requests from stale editor sessions + * to that too, for the root note and for every reply in its thread. + */ + $thread_root = $parent_comment; + $visited = array( (int) $thread_root->comment_ID => true ); + while ( $thread_root->comment_parent ) { + $ancestor = get_comment( $thread_root->comment_parent ); + + // Stop at a missing ancestor or a corrupt, cyclic chain. + if ( ! $ancestor || isset( $visited[ (int) $ancestor->comment_ID ] ) ) { + break; + } + + $visited[ (int) $ancestor->comment_ID ] = true; + $thread_root = $ancestor; + } + + if ( '1' === $thread_root->comment_approved ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a resolved note.' ), + array( 'status' => 400 ) + ); + } + + /* + * Validate the reaction content: the hex key of one of the curated + * reaction emoji, as listed by self::get_note_reaction_keys() (e.g. + * `2764` for the heart). Raw emoji bytes are rejected because the + * comments table is not guaranteed to be utf8mb4 across all WordPress + * installs; clients are expected to normalize before submitting. + * + * Read the content the same two ways prepare_item_for_database() + * does, so `content` and `content.raw` are both accepted. + */ + $raw_content = ''; + if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { + $raw_content = $request['content']; + } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) { + $raw_content = $request['content']['raw']; + } + + $emoji_key = trim( wp_strip_all_tags( $raw_content ) ); + + if ( ! in_array( $emoji_key, self::get_note_reaction_keys(), true ) ) { + return new WP_Error( + 'rest_comment_invalid_reaction', + __( 'Invalid reaction emoji.' ), + array( 'status' => 400 ) + ); + } + + /* + * A reaction is always approved. The uniqueness check, the race + * cleanup below and the reaction summary only see approved rows, so + * a reaction created in any other status could never be counted, + * deduplicated or, since reactions cannot be updated, fixed. + */ + if ( isset( $request['status'] ) && ! in_array( $request['status'], array( 'approve', 'approved', '1' ), true ) ) { + return new WP_Error( + 'rest_comment_invalid_status', + __( 'A reaction cannot be created with that status.' ), + array( 'status' => 400 ) + ); + } + + /* + * Enforce uniqueness: one emoji per user per note. + * + * Scope to active (approved) reactions only — trashed reactions + * are invisible to the user and must not block re-adding the + * same emoji. + */ + $existing = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'approve', + ) + ); + + foreach ( $existing as $existing_reaction ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_key ) { + return new WP_Error( + 'rest_comment_duplicate_reaction', + __( 'You have already reacted with this emoji.' ), + array( 'status' => 409 ) + ); + } + } + + $reaction_key = $emoji_key; + } + $prepared_comment = $this->prepare_item_for_database( $request ); if ( is_wp_error( $prepared_comment ) ) { return $prepared_comment; @@ -674,6 +887,15 @@ public function create_item( $request ) { $prepared_comment['comment_type'] = $request['type']; + /* + * Persist the validated, canonical reaction key rather than the raw + * request content, so stored values stay consistent for grouping and + * counting (e.g. "2764" is stored as "2764"). + */ + if ( null !== $reaction_key ) { + $prepared_comment['comment_content'] = $reaction_key; + } + if ( ! isset( $prepared_comment['comment_content'] ) ) { $prepared_comment['comment_content'] = ''; } @@ -711,6 +933,20 @@ public function create_item( $request ) { $prepared_comment['comment_author_url'] = $user->user_url; } + /* + * Pin a reaction to the current user, whatever author details the request + * carried. Author fields alone leave `user_id` at 0, which the uniqueness + * check and the reaction summary both key on. + */ + if ( null !== $reaction_key ) { + $user = wp_get_current_user(); + + $prepared_comment['user_id'] = $user->ID; + $prepared_comment['comment_author'] = $user->display_name; + $prepared_comment['comment_author_email'] = $user->user_email; + $prepared_comment['comment_author_url'] = $user->user_url; + } + // Honor the discussion setting that requires a name and email address of the comment author. if ( get_option( 'require_name_email' ) ) { if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) { @@ -745,9 +981,9 @@ public function create_item( $request ) { ); } - // Don't check for duplicates or flooding for notes. + // Don't check for duplicates or flooding for notes or reactions. $prepared_comment['comment_approved'] = - 'note' === $prepared_comment['comment_type'] ? + in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -802,7 +1038,54 @@ public function create_item( $request ) { ); } - if ( isset( $request['status'] ) ) { + /* + * The pre-insert uniqueness check is not atomic, so two concurrent + * requests for the same user/note/emoji can both insert an approved + * row. Converge on a single row deterministically: keep the earliest + * matching reaction (lowest comment ID) and delete any later + * duplicates. Every concurrent request applies the same rule, so they + * all settle on the same surviving row. If this request's own row lost + * the race, repoint the response to the survivor. + */ + if ( null !== $reaction_key ) { + $matching = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'approve', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ) + ); + $duplicates = array(); + foreach ( $matching as $candidate ) { + if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_key ) { + $duplicates[] = (int) $candidate->comment_ID; + } + } + + /* + * Repoint whenever any matching row survives, not only when this + * request still sees its own duplicate: a competing request may + * already have deleted this request's row, leaving a single + * survivor that is not `$comment_id`. + */ + if ( ! empty( $duplicates ) ) { + $survivor_id = array_shift( $duplicates ); + foreach ( $duplicates as $duplicate_id ) { + wp_delete_comment( $duplicate_id, true ); + } + $comment_id = $survivor_id; + } + } + + /* + * Reactions are inserted approved and their status was validated above. + * Skipping them here also keeps a request from changing the status of a + * row that the race cleanup above may have handed it from another request. + */ + if ( isset( $request['status'] ) && null === $reaction_key ) { $this->handle_status_param( $request['status'], $comment_id ); } @@ -865,6 +1148,7 @@ public function create_item( $request ) { * * @since 4.7.0 * @since 7.1.1 Target post permissions are checked when a comment's parent post is changed. + * @since 7.2.0 Reactions cannot be updated. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, error object otherwise. @@ -875,6 +1159,22 @@ public function update_item_permissions_check( $request ) { return $comment; } + /* + * Reactions are immutable. create_item() validates the author, parent + * note, target post and canonical emoji hex key as a set, and none of that + * is re-checked here. Allowing an update would let anyone who can edit + * the note's post reattribute a reaction to another user, move it to a + * note on a post they cannot edit, or store a duplicate or invalid + * key. Removing a reaction is a delete. + */ + if ( 'reaction' === $comment->comment_type ) { + return new WP_Error( + 'rest_comment_update_not_allowed', + __( 'Reactions cannot be edited. Remove the reaction and add a new one instead.' ), + array( 'status' => 403 ) + ); + } + if ( ! $this->check_edit_permission( $comment ) ) { return new WP_Error( 'rest_cannot_edit', @@ -1034,6 +1334,7 @@ public function update_item( $request ) { * Checks if a given request has access to delete a comment. * * @since 4.7.0 + * @since 7.2.0 A reaction can only be deleted by the user who added it. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to delete the item, error object otherwise. @@ -1044,6 +1345,19 @@ public function delete_item_permissions_check( $request ) { return $comment; } + /* + * Anyone who can edit a note's post can edit the note, and the check + * below follows that, but a reaction belongs to the user who added it: + * only they can take it back. + */ + if ( 'reaction' === $comment->comment_type && get_current_user_id() !== (int) $comment->user_id ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'Sorry, you can only remove your own reactions.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + if ( ! $this->check_edit_permission( $comment ) ) { return new WP_Error( 'rest_cannot_delete', @@ -1235,6 +1549,20 @@ public function prepare_item_for_response( $item, $request ) { $data['meta'] = $this->meta->get_value( $comment->comment_ID, $request ); } + if ( in_array( 'reaction_summary', $fields, true ) && 'note' === $comment->comment_type ) { + $note_id = (int) $comment->comment_ID; + + if ( null !== $this->reaction_summaries && isset( $this->reaction_summaries[ $note_id ] ) ) { + $data['reaction_summary'] = $this->reaction_summaries[ $note_id ]; + } else { + // Single-item path (get_item or single create/update): query individually. + $this->prefetch_reaction_summaries( array( $note_id ) ); + $data['reaction_summary'] = $this->reaction_summaries[ $note_id ] ?? array(); + // Reset so subsequent unrelated calls do not see this entry. + $this->reaction_summaries = null; + } + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -1305,12 +1633,17 @@ protected function prepare_links( $comment ) { ); } - // Only grab one comment to verify the comment has children. + /* + * Only grab one comment to verify the comment has children. Reactions are + * left out: they are summarized in `reaction_summary`, and counting them + * would advertise a `children` link on a note that has no replies. + */ $comment_children = $comment->get_children( array( - 'count' => true, - 'orderby' => 'none', - 'type' => 'all', + 'count' => true, + 'orderby' => 'none', + 'type' => 'all', + 'type__not_in' => array( 'reaction' ), ) ); @@ -1642,6 +1975,25 @@ public function get_item_schema() { 'readonly' => true, 'default' => 'comment', ), + 'reaction_summary' => array( + 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji hex key.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'count' => array( + 'description' => __( 'Total number of reactions with this emoji.' ), + 'type' => 'integer', + ), + 'current_user_reaction' => array( + 'description' => __( "The current user's reaction comment ID for this emoji, or 0 if they have not reacted." ), + 'type' => 'integer', + ), + ), + ), + ), ), ); @@ -1932,6 +2284,93 @@ protected function check_read_post_permission( $post, $request ) { return $result; } + /** + * Pre-fetches reaction summaries for a set of note IDs. + * + * Runs two aggregated queries (one for the per-emoji counts, one for the + * current user's own reactions) and stores the result in + * $this->reaction_summaries, keyed by note comment ID. This lets a + * batched note listing return reaction_summary for many notes without + * issuing a per-note query. + * + * @since 7.2.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param int[] $note_ids Array of note comment IDs. + */ + protected function prefetch_reaction_summaries( $note_ids ) { + global $wpdb; + + $this->reaction_summaries = array(); + + if ( empty( $note_ids ) ) { + return; + } + + $note_ids = array_map( 'intval', $note_ids ); + $current_user_id = get_current_user_id(); + $id_placeholders = implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ); + + // Query 1: aggregated counts per emoji per note. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $counts = $wpdb->get_results( + $wpdb->prepare( + "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count + FROM {$wpdb->comments} + WHERE comment_parent IN ( $id_placeholders ) + AND comment_type = %s + AND comment_approved = %s + GROUP BY comment_parent, comment_content", + ...array_merge( $note_ids, array( 'reaction', '1' ) ) + ) + ); + + // Query 2: the current user's own reaction IDs (only when logged in). + $my_reactions = array(); + if ( $current_user_id ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $user_rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT comment_ID, comment_parent, comment_content + FROM {$wpdb->comments} + WHERE comment_parent IN ( $id_placeholders ) + AND comment_type = %s + AND comment_approved = %s + AND user_id = %d", + ...array_merge( $note_ids, array( 'reaction', '1', $current_user_id ) ) + ) + ); + + if ( $user_rows ) { + foreach ( $user_rows as $row ) { + $key = (int) $row->comment_parent . ':' . wp_strip_all_tags( $row->comment_content ); + $my_reactions[ $key ] = (int) $row->comment_ID; + } + } + } + + // Initialize empty summaries for every requested note ID. + foreach ( $note_ids as $note_id ) { + $this->reaction_summaries[ $note_id ] = array(); + } + + if ( ! $counts ) { + return; + } + + foreach ( $counts as $row ) { + $note_id = (int) $row->comment_parent; + $emoji_key = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $emoji_key; + + $this->reaction_summaries[ $note_id ][ $emoji_key ] = array( + 'count' => (int) $row->reaction_count, + 'current_user_reaction' => $my_reactions[ $key ] ?? 0, + ); + } + } + /** * Checks if the comment can be read. * @@ -1942,7 +2381,7 @@ protected function check_read_post_permission( $post, $request ) { * @return bool Whether the comment can be read. */ protected function check_read_permission( $comment, $request ) { - if ( 'note' !== $comment->comment_type && ! empty( $comment->comment_post_ID ) ) { + if ( ! in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) && ! empty( $comment->comment_post_ID ) ) { $post = get_post( $comment->comment_post_ID ); if ( $post ) { if ( $this->check_read_post_permission( $post, $request ) && 1 === (int) $comment->comment_approved ) { @@ -2057,6 +2496,11 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { return true; } + // Reactions always have content (the emoji hex key), so allow them. + if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) { + return true; + } + /* * Do not allow a comment to be created with missing or empty * comment_content. See wp_handle_comment_submission(). diff --git a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php index c7a6db12ef252..5c92c1b65e176 100644 --- a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php @@ -83,4 +83,68 @@ public function test_should_respect_timezone_gmt() { public function test_invalid_timezone_should_fall_back_on_blog() { $this->assertSame( (string) self::$post_id, comment_exists( 1, '2014-05-06 12:00:00', 'not_a_valid_value' ) ); } + /** + * Internal comment types are not awaiting moderation, so they must not be + * counted as pending. + * + * @ticket 64638 + * + * @covers ::get_pending_comments_num + */ + public function test_get_pending_comments_num_excludes_internal_comment_types() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '0', + ) + ); + + foreach ( array( 'note', 'reaction' ) as $internal_type ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '0', + 'comment_type' => $internal_type, + ) + ); + } + + $this->assertSame( 1, (int) get_pending_comments_num( $post_id ) ); + } + + /** + * The array form of the count excludes internal comment types too. + * + * @ticket 64638 + * + * @covers ::get_pending_comments_num + */ + public function test_get_pending_comments_num_for_multiple_posts_excludes_internal_comment_types() { + $with_comment = self::factory()->post->create(); + $notes_only = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $with_comment, + 'comment_approved' => '0', + ) + ); + + foreach ( array( 'note', 'reaction' ) as $internal_type ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $notes_only, + 'comment_approved' => '0', + 'comment_type' => $internal_type, + ) + ); + } + + $counts = get_pending_comments_num( array( $with_comment, $notes_only ) ); + + $this->assertSame( 1, (int) $counts[ $with_comment ] ); + $this->assertSame( 0, (int) $counts[ $notes_only ] ); + } } diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 47b9bec57f140..0c0b349f5f422 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -215,27 +215,30 @@ public function test_get_views_should_return_views_by_default() { } /** - * Verify that the comments table never shows the note comment_type. + * Verify that the comments table never shows internal comment types. * * @ticket 64198 * @ticket 64474 + * @ticket 64638 * * @dataProvider data_comment_type * * @param string $comment_type The comment_type parameter value to test. */ - public function test_comments_list_table_does_not_show_note_comment_type( string $comment_type ) { + public function test_comments_list_table_does_not_show_internal_comment_types( string $comment_type ) { $post_id = self::factory()->post->create(); - self::factory()->comment->create( - array( - 'comment_post_ID' => $post_id, - 'comment_content' => 'This is a note.', - 'comment_type' => 'note', - 'comment_approved' => '1', - 'comment_date' => '2024-01-01 10:00:00', - 'comment_date_gmt' => '2024-01-01 10:00:00', - ) - ); + foreach ( array( 'note', 'reaction' ) as $internal_type ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'This is a ' . $internal_type . '.', + 'comment_type' => $internal_type, + 'comment_approved' => '1', + 'comment_date' => '2024-01-01 10:00:00', + 'comment_date_gmt' => '2024-01-01 10:00:00', + ) + ); + } $regular_comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $post_id, @@ -265,14 +268,15 @@ public function test_comments_list_table_does_not_show_note_comment_type( string } /** - * Data provider for test_comments_list_table_does_not_show_note_comment_type(). + * Data provider for test_comments_list_table_does_not_show_internal_comment_types(). * * @return array */ public function data_comment_type(): array { return array( - 'note type explicitly requested' => array( 'note' ), - 'all type requested' => array( 'all' ), + 'note type explicitly requested' => array( 'note' ), + 'reaction type explicitly requested' => array( 'reaction' ), + 'all type requested' => array( 'all' ), ); } } diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index a7ebe57808c17..c733b32515376 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2009,6 +2009,363 @@ public function test_wp_trash_comment_only_top_level_notes_trigger_child_deletio $this->assertSame( '1', get_comment( $sibling_note )->comment_approved ); } + /** + * Creates an approved reaction on a note. + * + * @param int $note_id Parent note comment ID. + * @param string $key Reaction hex key. + * @return int Reaction comment ID. + */ + private function create_reaction_on_note( $note_id, $key = '2764' ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + 'comment_content' => $key, + ) + ); + } + + /** + * Tests that permanently deleting a note deletes its reactions. + * + * wp_delete_comment() reparents a deleted comment's children one level up. + * A reaction only means anything attached to its note, so without a cascade + * it would survive as an approved top-level row still carrying the + * reactor's identity. + * + * @ticket 64638 + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_note_reactions() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reaction_1 = $this->create_reaction_on_note( $note_id ); + $reaction_2 = $this->create_reaction_on_note( $note_id, '1f680' ); + + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $reaction_1 ), 'The first reaction outlived its note.' ); + $this->assertNull( get_comment( $reaction_2 ), 'The second reaction outlived its note.' ); + $this->assertSame( + array(), + get_comments( + array( + 'post_id' => self::$post_id, + 'type' => 'reaction', + 'status' => 'all', + 'fields' => 'ids', + ) + ), + 'Reaction rows remained after the note was permanently deleted.' + ); + } + + /** + * Tests that deleting a note reply takes only that reply's reactions. + * + * @ticket 64638 + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_note_reply_reactions() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reply_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + ) + ); + + $note_reaction = $this->create_reaction_on_note( $note_id ); + $reply_reaction = $this->create_reaction_on_note( $reply_id ); + + wp_delete_comment( $reply_id, true ); + + $this->assertNull( get_comment( $reply_reaction ), "The reply's reaction outlived the reply." ); + $this->assertNotNull( get_comment( $note_reaction ), "The root note's reaction should be untouched." ); + } + + /** + * Tests that a regular comment's children are still reparented. + * + * The reaction cascade must not change how any other comment type behaves. + * + * @ticket 64638 + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_still_reparents_non_note_children() { + $parent_comment = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'comment', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $child_comment = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'comment', + 'comment_parent' => $parent_comment, + 'comment_approved' => '1', + ) + ); + + wp_delete_comment( $parent_comment, true ); + + $child = get_comment( $child_comment ); + $this->assertNotNull( $child, 'The child comment should survive its parent.' ); + $this->assertSame( '0', $child->comment_parent, 'The child comment should have moved up a level.' ); + } + + /** + * Tests that trashing a note carries its reactions along. + * + * Core cascades a trashed note to its `note` children only, so without this + * a reaction stays approved under a trashed note. + * + * @ticket 64638 + * @covers ::wp_trash_comment + */ + public function test_wp_trash_comment_trashes_note_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reaction_id = $this->create_reaction_on_note( $note_id ); + + wp_trash_comment( $note_id ); + $this->assertSame( 'trash', get_comment( $reaction_id )->comment_approved, 'The reaction stayed approved under a trashed note.' ); + } + + /** + * Tests that trashing a note reply carries that reply's reactions along. + * + * @ticket 64638 + * @covers ::wp_trash_comment + */ + public function test_wp_trash_comment_trashes_note_reply_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reply_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + ) + ); + + $reply_reaction = $this->create_reaction_on_note( $reply_id ); + + // Trashing the root cascades to the reply, which brings its reactions. + wp_trash_comment( $note_id ); + + $this->assertSame( 'trash', get_comment( $reply_id )->comment_approved, 'The reply was not trashed.' ); + $this->assertSame( 'trash', get_comment( $reply_reaction )->comment_approved, "The reply's reaction was not trashed." ); + } + + /** + * A reaction the user already removed is trashed, not deleted, so deleting + * its note must take it along too rather than leave it orphaned. + * + * @ticket 64638 + * + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_trashed_note_reactions() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $approved = $this->create_reaction_on_note( $note_id ); + $trashed = $this->create_reaction_on_note( $note_id, '1f680' ); + wp_trash_comment( $trashed ); + + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $approved ), 'The approved reaction was not deleted.' ); + $this->assertNull( get_comment( $trashed ), 'The trashed reaction was left behind.' ); + } + + /** + * Trashing a note trashes its reactions, so permanently deleting the + * trashed note must take those reactions along, as well as any the user + * removed earlier. + * + * @ticket 64638 + * + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_reactions_of_trashed_note() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $removed_id = $this->create_reaction_on_note( $note_id ); + wp_trash_comment( $removed_id ); + $live_id = $this->create_reaction_on_note( $note_id ); + + wp_trash_comment( $note_id ); + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $live_id ), 'A reaction trashed with its note outlived it.' ); + $this->assertNull( get_comment( $removed_id ), 'A reaction the user removed outlived its note.' ); + } + + /** + * @ticket 64638 + * + * @covers ::wp_get_note_reaction_ids + */ + public function test_wp_get_note_reaction_ids_returns_reactions_oldest_first() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $heart = $this->create_reaction_on_note( $note_id ); + $rocket = $this->create_reaction_on_note( $note_id, '1f680' ); + + // A reply is a child of the note, but it is not a reaction. + self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + ) + ); + + $this->assertSame( array( $heart, $rocket ), array_map( 'intval', wp_get_note_reaction_ids( $note_id ) ) ); + } + + /** + * @ticket 64638 + * + * @covers ::wp_get_note_reaction_ids + */ + public function test_wp_get_note_reaction_ids_filters_by_status() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $approved = $this->create_reaction_on_note( $note_id ); + $trashed = $this->create_reaction_on_note( $note_id, '1f680' ); + wp_trash_comment( $trashed ); + + $this->assertSame( array( $approved ), array_map( 'intval', wp_get_note_reaction_ids( $note_id, 'approve' ) ), 'Only the approved reaction was expected.' ); + $this->assertSame( array( $trashed ), array_map( 'intval', wp_get_note_reaction_ids( $note_id, 'trash' ) ), 'Only the trashed reaction was expected.' ); + $this->assertCount( 2, wp_get_note_reaction_ids( $note_id ), 'Both reactions were expected by default.' ); + $this->assertSame( array( $approved, $trashed ), array_map( 'intval', wp_get_note_reaction_ids( $note_id ) ) ); + } + + /** + * Only notes carry reactions. + * + * @ticket 64638 + * + * @covers ::wp_get_note_reaction_ids + * + * @dataProvider data_wp_get_note_reaction_ids_non_note_comments + * + * @param string $comment_type The comment type to attach the reaction to. + */ + public function test_wp_get_note_reaction_ids_returns_empty_for_non_notes( $comment_type ) { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + ) + ); + + $this->create_reaction_on_note( $comment_id ); + + $this->assertSame( array(), wp_get_note_reaction_ids( $comment_id ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_wp_get_note_reaction_ids_non_note_comments() { + return array( + 'a discussion comment' => array( 'comment' ), + 'a pingback' => array( 'pingback' ), + 'another reaction' => array( 'reaction' ), + ); + } + + /** + * @ticket 64638 + * + * @covers ::wp_get_note_reaction_ids + */ + public function test_wp_get_note_reaction_ids_returns_empty_for_unknown_comment() { + $this->assertSame( array(), wp_get_note_reaction_ids( PHP_INT_MAX ) ); + } + /** * @ticket 61244 * diff --git a/tests/phpunit/tests/comment/getLastCommentModified.php b/tests/phpunit/tests/comment/getLastCommentModified.php index 03229ba350a9b..b04354f2bd766 100644 --- a/tests/phpunit/tests/comment/getLastCommentModified.php +++ b/tests/phpunit/tests/comment/getLastCommentModified.php @@ -137,4 +137,79 @@ public function test_cache_is_cleared_when_comment_is_trashed() { $this->assertSame( strtotime( '1998-01-01 10:00:00' ), strtotime( get_lastcommentmodified() ) ); $this->assertSame( strtotime( '1998-01-01 10:00:00' ), strtotime( wp_cache_get( 'lastcommentmodified:server', 'timeinfo' ) ) ); } + /** + * Internal comment types are not user-facing discussion, so they must not + * move the last comment modified date. + * + * @ticket 64638 + * + * @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_internal_comment_types_are_excluded( $timezone, $expected ) { + self::factory()->comment->create( + array( + 'comment_status' => 1, + 'comment_date' => '2000-01-01 11:00:00', + 'comment_date_gmt' => '2000-01-01 10:00:00', + ) + ); + + foreach ( array( 'note', 'reaction' ) as $comment_type ) { + self::factory()->comment->create( + array( + 'comment_status' => 1, + 'comment_type' => $comment_type, + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + } + + $this->assertSame( strtotime( $expected ), strtotime( get_lastcommentmodified( $timezone ) ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_internal_comment_types_are_excluded() { + return array( + 'server timezone' => array( + 'timezone' => 'server', + 'expected' => '2000-01-01 10:00:00', + ), + 'blog timezone' => array( + 'timezone' => 'blog', + 'expected' => '2000-01-01 11:00:00', + ), + 'gmt timezone' => array( + 'timezone' => 'gmt', + 'expected' => '2000-01-01 10:00:00', + ), + ); + } + + /** + * With nothing but internal comment types stored there is no last modified date. + * + * @ticket 64638 + */ + public function test_only_internal_comment_types_returns_false() { + foreach ( array( 'note', 'reaction' ) as $comment_type ) { + self::factory()->comment->create( + array( + 'comment_status' => 1, + 'comment_type' => $comment_type, + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + } + + $this->assertFalse( get_lastcommentmodified() ); + } } diff --git a/tests/phpunit/tests/comment/isAvatarCommentType.php b/tests/phpunit/tests/comment/isAvatarCommentType.php index aa91a6d6fe806..40d542ed693eb 100644 --- a/tests/phpunit/tests/comment/isAvatarCommentType.php +++ b/tests/phpunit/tests/comment/isAvatarCommentType.php @@ -40,6 +40,9 @@ public function data_is_avatar_comment_type() { array( '', false ), array( 'non-existing-comment-type', false ), array( 'comment', true ), + // Internal comment types are authored by a user, so they get an avatar. + array( 'note', true ), + array( 'reaction', true ), ); } diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 0fc0ff33d10be..935630f987415 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5445,13 +5445,15 @@ public function test_query_does_not_have_leading_whitespace() { } /** - * Helper method to create standard test comments for note type exclusion tests. + * Helper method to create standard test comments for internal comment type + * exclusion tests. * * @since 6.9.0 + * @since 7.2.0 A 'reaction' comment is created alongside the 'note'. * - * @return array<'comment'|'pingback'|'note', int> Array of comments created. + * @return array<'comment'|'pingback'|'note'|'reaction', int> Array of comments created. */ - protected function create_note_type_test_comments(): array { + protected function create_internal_comment_type_test_comments(): array { return array( 'comment' => self::factory()->comment->create( array( @@ -5473,19 +5475,27 @@ protected function create_note_type_test_comments(): array { 'comment_type' => 'note', ) ), + 'reaction' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + 'comment_type' => 'reaction', + ) + ), ); } /** * @ticket 64145 + * @ticket 64638 * @covers WP_Comment_Query::get_comment_ids - * @dataProvider data_note_type_exclusion + * @dataProvider data_internal_comment_type_exclusion * * @param array $query_args Query arguments for WP_Comment_Query. * @param string[] $expected_types Expected comment types. */ - public function test_note_type_exclusion( array $query_args, array $expected_types ) { - $this->create_note_type_test_comments(); + public function test_internal_comment_type_exclusion( array $query_args, array $expected_types ) { + $this->create_internal_comment_type_test_comments(); $query = new WP_Comment_Query(); $found = $query->query( array_merge( $query_args, array( 'fields' => 'ids' ) ) ); @@ -5507,7 +5517,7 @@ static function ( int $comment_id ): string { * * @return array, expected_types: string[] }> */ - public function data_note_type_exclusion(): array { + public function data_internal_comment_type_exclusion(): array { return array( 'default query excludes note' => array( 'query_args' => array(), @@ -5519,7 +5529,7 @@ public function data_note_type_exclusion(): array { ), 'type all includes note' => array( 'query_args' => array( 'type' => 'all' ), - 'expected_types' => array( 'comment', 'pingback', 'note' ), + 'expected_types' => array( 'comment', 'pingback', 'note', 'reaction' ), ), 'explicit note type' => array( 'query_args' => array( 'type' => 'note' ), @@ -5541,17 +5551,34 @@ public function data_note_type_exclusion(): array { 'query_args' => array( 'type__not_in' => array( 'note' ) ), 'expected_types' => array( 'comment', 'pingback' ), ), + 'explicit reaction type' => array( + 'query_args' => array( 'type' => 'reaction' ), + 'expected_types' => array( 'reaction' ), + ), + 'type__in with reaction' => array( + 'query_args' => array( 'type__in' => array( 'reaction' ) ), + 'expected_types' => array( 'reaction' ), + ), + 'type__in with note and reaction' => array( + 'query_args' => array( 'type__in' => array( 'note', 'reaction' ) ), + 'expected_types' => array( 'note', 'reaction' ), + ), + 'type__not_in with reaction' => array( + 'query_args' => array( 'type__not_in' => array( 'reaction' ) ), + 'expected_types' => array( 'comment', 'pingback' ), + ), ); } /** * @ticket 64145 + * @ticket 64638 * @covers WP_Comment_Query::get_comment_ids */ - public function test_note_type_not_duplicated_in_type__not_in() { + public function test_internal_comment_types_not_duplicated_in_type__not_in() { global $wpdb; - $comments = $this->create_note_type_test_comments(); + $comments = $this->create_internal_comment_type_test_comments(); $query = new WP_Comment_Query(); $found = $query->query( @@ -5563,15 +5590,23 @@ public function test_note_type_not_duplicated_in_type__not_in() { $this->assertSameSets( array( $comments['comment'], $comments['pingback'] ), $found ); $this->assertNotContains( $comments['note'], $found ); - $note_count = substr_count( $wpdb->last_query, "'note'" ); - $this->assertSame( 1, $note_count, 'The note type should only appear once in the query' ); + $this->assertNotContains( $comments['reaction'], $found ); + + foreach ( array( 'note', 'reaction' ) as $internal_type ) { + $this->assertSame( + 1, + substr_count( $wpdb->last_query, "'" . $internal_type . "'" ), + "The {$internal_type} type should only appear once in the query" + ); + } } /** * @ticket 64145 + * @ticket 64638 * @covers ::get_comment_count */ - public function test_get_comment_count_excludes_note_type() { + public function test_get_comment_count_excludes_internal_comment_types() { $post_id = self::factory()->post->create(); self::factory()->comment->create( @@ -5594,6 +5629,13 @@ public function test_get_comment_count_excludes_note_type() { 'comment_type' => 'note', ) ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '1', + 'comment_type' => 'reaction', + ) + ); $counts = get_comment_count( $post_id ); diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 9dbb1f244ccf8..34ec540400ab4 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -78,6 +78,20 @@ public function test_only_approved_regular_comments_are_counted() { 'comment_approved' => 1, ) ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_approved' => 0, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_approved' => 1, + ) + ); $this->assertTrue( wp_update_comment_count_now( $post_id ) ); $this->assertSame( '1', get_comments_number( $post_id ) ); diff --git a/tests/phpunit/tests/query/commentFeed.php b/tests/phpunit/tests/query/commentFeed.php index d26bd3829c06a..cc8e809bdf86e 100644 --- a/tests/phpunit/tests/query/commentFeed.php +++ b/tests/phpunit/tests/query/commentFeed.php @@ -88,12 +88,17 @@ public function test_archive_comment_feed_invalid_cache() { /** * @ticket 65613 + * @ticket 64638 + * + * @dataProvider data_internal_comment_types + * + * @param string $comment_type Internal comment type. */ - public function test_main_comment_feed_should_exclude_notes(): void { - $note_id = self::factory()->comment->create( + public function test_main_comment_feed_should_exclude_internal_comment_types( string $comment_type ): void { + $internal_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_ids[0], - 'comment_type' => 'note', + 'comment_type' => $comment_type, 'comment_approved' => '1', ) ); @@ -110,18 +115,23 @@ public function test_main_comment_feed_should_exclude_notes(): void { $this->assertFalse( $q->is_singular() ); $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); - $this->assertNotContains( $note_id, $comment_ids, 'Comments feed should not include notes.' ); + $this->assertNotContains( $internal_id, $comment_ids, "Comments feed should not include '{$comment_type}' comments." ); $this->assertSame( 15, $q->comment_count, 'Comments feed should include all regular comments.' ); } /** * @ticket 65613 + * @ticket 64638 + * + * @dataProvider data_internal_comment_types + * + * @param string $comment_type Internal comment type. */ - public function test_archive_comment_feed_should_exclude_notes(): void { - $note_id = self::factory()->comment->create( + public function test_archive_comment_feed_should_exclude_internal_comment_types( string $comment_type ): void { + $internal_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_ids[0], - 'comment_type' => 'note', + 'comment_type' => $comment_type, 'comment_approved' => '1', ) ); @@ -139,21 +149,26 @@ public function test_archive_comment_feed_should_exclude_notes(): void { $this->assertTrue( $q->is_archive() ); $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); - $this->assertNotContains( $note_id, $comment_ids, 'Archive comments feed should not include notes.' ); + $this->assertNotContains( $internal_id, $comment_ids, "Archive comments feed should not include '{$comment_type}' comments." ); $this->assertSame( 15, $q->comment_count, 'Archive comments feed should include all regular comments.' ); } /** * @ticket 65613 + * @ticket 64638 + * + * @dataProvider data_internal_comment_types + * + * @param string $comment_type Internal comment type. */ - public function test_single_comment_feed_should_exclude_notes(): void { + public function test_single_comment_feed_should_exclude_internal_comment_types( string $comment_type ): void { $post = get_post( self::$post_ids[0] ); $this->assertInstanceOf( WP_Post::class, $post ); - $note_id = self::factory()->comment->create( + $internal_id = self::factory()->comment->create( array( 'comment_post_ID' => $post->ID, - 'comment_type' => 'note', + 'comment_type' => $comment_type, 'comment_approved' => '1', ) ); @@ -172,10 +187,22 @@ public function test_single_comment_feed_should_exclude_notes(): void { $this->assertTrue( $q->is_singular() ); $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); - $this->assertNotContains( $note_id, $comment_ids, 'Singular comments feed should not include notes.' ); + $this->assertNotContains( $internal_id, $comment_ids, "Singular comments feed should not include '{$comment_type}' comments." ); $this->assertSame( 5, $q->comment_count, 'Singular comments feed should include all regular comments.' ); } + /** + * Data provider. + * + * @return array + */ + public function data_internal_comment_types(): array { + return array( + 'note' => array( 'note' ), + 'reaction' => array( 'reaction' ), + ); + } + /** * @ticket 36904 */ diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 1ef7eab22ae6c..181a142d363d2 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -3335,7 +3335,7 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 17, $properties ); + $this->assertCount( 18, $properties ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'author_avatar_urls', $properties ); @@ -3351,6 +3351,7 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'parent', $properties ); $this->assertArrayHasKey( 'post', $properties ); + $this->assertArrayHasKey( 'reaction_summary', $properties ); $this->assertArrayHasKey( 'status', $properties ); $this->assertArrayHasKey( 'type', $properties ); @@ -4283,9 +4284,9 @@ public function test_get_items_type_arg_unauthenticated( $comment_type, $count ) $response = rest_get_server()->dispatch( $request ); // Individual comments using the /comments/ endpoint can be retrieved by - // unauthenticated users - except for the 'note' type which is restricted. + // unauthenticated users - except for the 'note' and 'reaction' types which are restricted. // See https://core.trac.wordpress.org/ticket/44157. - $this->assertSame( 'note' === $comment_type ? 401 : 200, $response->get_status(), 'Individual comment endpoint did not return the expected status' ); + $this->assertSame( in_array( $comment_type, array( 'note', 'reaction' ), true ) ? 401 : 200, $response->get_status(), 'Individual comment endpoint did not return the expected status' ); } } @@ -4300,6 +4301,1900 @@ public function data_comment_type_provider() { 'annotation type' => array( 'annotation', 5 ), 'discussion type' => array( 'discussion', 9 ), 'note type' => array( 'note', 3 ), + 'reaction type' => array( 'reaction', 3 ), ); } + + /** + * @ticket 64638 + */ + public function test_create_reaction() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + // Open, like the editor creates it: an approved note is resolved. + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $new_comment = get_comment( $data['id'] ); + $this->assertSame( '2764', $new_comment->comment_content ); + $this->assertSame( 'reaction', $new_comment->comment_type ); + $this->assertSame( (string) $note_id, $new_comment->comment_parent ); + } + + /** + * @ticket 64638 + */ + public function test_create_reaction_invalid_parent() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'comment', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Regular comment', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $comment_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * @ticket 64638 + */ + public function test_create_reaction_no_parent() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * @ticket 64638 + */ + public function test_create_reaction_invalid_emoji() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'thumbsup', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + } + + /** + * @ticket 64638 + */ + public function test_create_reaction_duplicate() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + // Create first reaction. + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + // Attempt duplicate reaction. + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_duplicate_reaction', $response, 409 ); + } + + /** + * @ticket 64638 + */ + public function test_create_different_reactions_on_same_note() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + // Create first reaction. + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + // Create second, different reaction. + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '1f680', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + } + + /** + * @ticket 64638 + */ + public function test_create_reaction_requires_login() { + wp_set_current_user( 0 ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_login_required', $response, 401 ); + } + + /** + * Each curated reaction emoji is accepted by its hex key: the emoji's + * lowercase code points, padded to four digits. + * + * @ticket 64638 + * + * @dataProvider data_curated_reaction_keys + * + * @param string $key The reaction hex key to submit. + */ + public function test_create_reaction_accepts_curated_key( $key ) { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => $key, + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $new_comment = get_comment( $response->get_data()['id'] ); + $this->assertSame( $key, $new_comment->comment_content ); + } + + public function data_curated_reaction_keys() { + return array( + '2764' => array( '2764' ), + 'celebration' => array( '1f389' ), + 'smile' => array( '1f604' ), + 'eyes' => array( '1f440' ), + '1f680' => array( '1f680' ), + ); + } + + /** + * Raw emoji bytes must be rejected - clients are expected to normalize + * to a curated hex key before submitting. + * + * @ticket 64638 + */ + public function test_create_reaction_rejects_raw_emoji() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '👍', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + } + + /** + * After trashing a reaction, the same user may re-add the same emoji + * to the same note. Trashed reactions are invisible and must not block + * re-adding. + * + * @ticket 64638 + */ + public function test_create_reaction_after_trashing_previous_one() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + // Existing reaction in trash should not block re-adding. + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 'trash', + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + } + + /** + * A reaction whose parent note belongs to a different post is rejected. + * + * @ticket 64638 + */ + public function test_create_reaction_on_note_from_different_post() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $other_post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $other_post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Note on another post', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + + /** + * Only the curated hex keys are accepted. Other emoji, even when + * submitted as well-formed hex keys, are rejected, as are the slugs an + * earlier version of the API accepted and keys that are not lowercase. + * + * @ticket 64638 + * + * @dataProvider data_uncurated_reaction_keys + * + * @param string $key The reaction content to submit. + */ + public function test_create_reaction_rejects_uncurated_key( $key ) { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => $key, + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + } + + public function data_uncurated_reaction_keys() { + return array( + 'uncurated emoji' => array( '1f44d' ), + 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), + 'with variation selector' => array( '2764-fe0f' ), + 'uppercase key' => array( '1F680' ), + 'slug' => array( 'heart' ), + 'empty' => array( '' ), + ); + } + + /** + * The stored reaction content is the validated, canonical key - markup + * around the key must not reach the database, or `reaction_summary` + * grouping would split visually identical reactions. + * + * @ticket 64638 + */ + public function test_create_reaction_stores_canonical_key() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $new_comment = get_comment( $response->get_data()['id'] ); + $this->assertSame( '2764', $new_comment->comment_content ); + } + + /** + * A reaction can be sent in the object form of `content`, like any comment. + * + * @ticket 64638 + */ + public function test_create_reaction_accepts_raw_content_object() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => array( 'raw' => '1f680' ), + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $new_comment = get_comment( $response->get_data()['id'] ); + $this->assertSame( '1f680', $new_comment->comment_content ); + } + + /** + * A reaction can only be created approved. + * + * Held, spammed or trashed reactions are invisible to the uniqueness check + * and the reaction summary, so repeated requests could pile them up. + * + * @ticket 64638 + * + * @dataProvider data_create_reaction_status + * + * @param string $status Requested status. + * @param int $expected_status Expected HTTP status. + */ + public function test_create_reaction_only_allows_approved_status( $status, $expected_status ) { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'status' => $status, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + if ( 201 === $expected_status ) { + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( '1', get_comment( $response->get_data()['id'] )->comment_approved ); + } else { + $this->assertErrorResponse( 'rest_comment_invalid_status', $response, $expected_status ); + $this->assertSame( + array(), + get_comments( + array( + 'parent' => $note_id, + 'type' => 'reaction', + 'status' => 'any', + 'fields' => 'ids', + ) + ), + 'No reaction should have been stored.' + ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_reaction_status() { + return array( + 'approve' => array( 'approve', 201 ), + 'hold' => array( 'hold', 400 ), + 'spam' => array( 'spam', 400 ), + 'trash' => array( 'trash', 400 ), + ); + } + + /** + * The pre-insert uniqueness check is not atomic. Simulate a concurrent + * request winning the race — inserting the same reaction after this + * request's check but before its own insert — and assert the post-insert + * cleanup converges on a single surviving row. + * + * @ticket 64638 + */ + public function test_concurrent_duplicate_reaction_converges_to_single_row() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + /* + * Insert a competing reaction while the request is mid-flight (after + * its pre-insert check, before its own insert). + */ + $injected = false; + $inject = function ( $prepared ) use ( $note_id, $post_id, &$injected ) { + if ( ! $injected && isset( $prepared['comment_type'] ) && 'reaction' === $prepared['comment_type'] ) { + $injected = true; + wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => '2764', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + } + return $prepared; + }; + add_filter( 'rest_pre_insert_comment', $inject ); + + try { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + } finally { + remove_filter( 'rest_pre_insert_comment', $inject ); + } + + // Exactly one approved heart reaction should remain for this user/note. + $remaining = get_comments( + array( + 'parent' => $note_id, + 'user_id' => self::$editor_id, + 'type' => 'reaction', + 'status' => 'approve', + ) + ); + $hearts = array_values( + array_filter( + $remaining, + static function ( $comment ) { + return '2764' === $comment->comment_content; + } + ) + ); + $this->assertCount( 1, $hearts, 'Concurrent duplicate reactions should converge to a single row.' ); + + // The response must reference the surviving (earliest) row. + $this->assertSame( (int) $hearts[0]->comment_ID, $response->get_data()['id'] ); + } + + /** + * The cleanup in create_item() must repoint to the surviving row even when + * a competing request has already deleted this request's own row - the + * losing side of the same race the test above covers from the winner. + * + * @ticket 64638 + */ + public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $survivor_id = 0; + $deleted = false; + + /* + * Stand in for the competing request's cleanup: it keeps the earliest + * row and deletes this request's later one, which lands first. + */ + $race = function ( $comment_id, $comment ) use ( &$survivor_id, &$deleted ) { + if ( ! $deleted && 'reaction' === $comment->comment_type && (int) $comment_id !== $survivor_id ) { + $deleted = true; + wp_delete_comment( $comment_id, true ); + } + }; + + /* + * Insert the competing row after this request's pre-insert uniqueness + * check has passed, so it takes the earlier ID. Arm the cleanup only + * once that row exists, so its own insert does not trigger it. + */ + $inject = function ( $prepared ) use ( $note_id, $post_id, $race, &$survivor_id ) { + if ( ! $survivor_id && isset( $prepared['comment_type'] ) && 'reaction' === $prepared['comment_type'] ) { + $survivor_id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => '2764', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + add_action( 'wp_insert_comment', $race, 10, 2 ); + } + return $prepared; + }; + add_filter( 'rest_pre_insert_comment', $inject ); + + try { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + } finally { + remove_filter( 'rest_pre_insert_comment', $inject ); + remove_action( 'wp_insert_comment', $race, 10 ); + } + + $this->assertTrue( $deleted, "The race injection did not delete this request's row." ); + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $survivor_id, $response->get_data()['id'], 'The response did not repoint to the surviving row.' ); + } + + /** + * Creates an approved reaction on a note, bypassing REST validation. + * + * @param int $post_id Post the parent note belongs to. + * @param int $note_id Parent note comment ID. + * @param int $user_id Reacting user ID. + * @param string $key Reaction hex key. + * @return int Reaction comment ID. + */ + private function create_reaction_for_update_tests( $post_id, $note_id, $user_id, $key = '2764' ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_approved' => 1, + 'comment_content' => $key, + 'user_id' => $user_id, + ) + ); + } + + /** + * Reactions are validated as a set on create - author, parent note, target + * post and canonical key - and the generic update route re-validates none + * of it, so updating a reaction is not allowed at all. + * + * @ticket 64638 + */ + public function test_update_reaction_content_is_not_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = $this->create_reaction_for_update_tests( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => '1f680' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $this->assertSame( '2764', get_comment( $reaction_id )->comment_content ); + } + + /** + * The reactor's identity must not be reassignable through the update route. + * + * @ticket 64638 + */ + public function test_update_reaction_author_is_not_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = $this->create_reaction_for_update_tests( $post_id, $note_id, self::$author_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'author' => self::$editor_id ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $this->assertSame( (string) self::$author_id, get_comment( $reaction_id )->user_id ); + } + + /** + * A reaction must not be movable onto a note on a post the user cannot edit. + * + * @ticket 64638 + */ + public function test_update_reaction_cannot_move_to_note_on_another_post() { + $editable_post = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $other_post = self::factory()->post->create( array( 'post_author' => self::$admin_id ) ); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $editable_post, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $other_note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $other_post, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$admin_id, + 'comment_content' => 'Other note', + ) + ); + + $reaction_id = $this->create_reaction_for_update_tests( $editable_post, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'parent' => $other_note_id, + 'post' => $other_post, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + + $reaction = get_comment( $reaction_id ); + $this->assertSame( (string) $note_id, $reaction->comment_parent ); + $this->assertSame( (string) $editable_post, $reaction->comment_post_ID ); + } + + /** + * Only reactions are locked down; notes stay editable. + * + * @ticket 64638 + */ + public function test_update_note_is_still_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => 'Edited note' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The note response exposes a `reaction_summary` field aggregating + * counts per emoji hex key, plus the current user's reaction ID as + * `current_user_reaction`. + * + * @ticket 64638 + */ + public function test_note_response_includes_reaction_summary() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $heart_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$subscriber_id, + 'comment_content' => '1f680', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reaction_summary', $data ); + $this->assertArrayHasKey( '2764', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); + $this->assertSame( $heart_id, $data['reaction_summary']['2764']['current_user_reaction'] ); + + $this->assertArrayHasKey( '1f680', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['1f680']['count'] ); + $this->assertSame( 0, $data['reaction_summary']['1f680']['current_user_reaction'] ); + } + + /** + * A note's `children` link keeps targeting its reply notes once reactions exist. + * + * Reactions are summarized in `reaction_summary`, so they neither change + * where the link points nor make a note without replies advertise one. + * + * @ticket 64638 + */ + public function test_note_children_link_ignores_reactions() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertArrayNotHasKey( 'children', $response->get_links(), 'A note with only reactions should not advertise children.' ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Reply note', + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $links = $response->get_links(); + + $this->assertArrayHasKey( 'children', $links, 'A note with a reply should advertise children.' ); + $href = $links['children'][0]['href']; + $this->assertStringContainsString( 'type=note', $href ); + $this->assertStringNotContainsString( 'type=reaction', $href ); + } + + /** + * A reaction may only be added on the current user's own behalf. + * + * @ticket 64638 + */ + public function test_create_reaction_cannot_be_attributed_to_another_user() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$admin_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_invalid_author', $response, 403 ); + $this->assertCount( + 0, + get_comments( + array( + 'parent' => $note_id, + 'type' => 'reaction', + 'status' => 'approve', + ) + ), + 'No reaction should have been stored.' + ); + } + + /** + * Author fields in the request must not detach a reaction from its user. + * + * A reaction stored with `user_id` 0 is invisible to the uniqueness check and + * to `reaction_summary`, so it could be added repeatedly and never removed. + * + * @ticket 64638 + */ + public function test_create_reaction_ignores_request_author_fields() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $body = wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author_name' => 'Someone Else', + 'author_email' => 'someone@example.com', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( $body ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $reaction = get_comment( $data['id'] ); + $this->assertSame( (string) self::$editor_id, $reaction->user_id, 'The reaction should belong to the current user.' ); + $this->assertNotSame( 'someone@example.com', $reaction->comment_author_email ); + + // The uniqueness check can now see the stored reaction. + $duplicate = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $duplicate->add_header( 'Content-Type', 'application/json' ); + $duplicate->set_body( $body ); + + $this->assertErrorResponse( 'rest_comment_duplicate_reaction', rest_get_server()->dispatch( $duplicate ), 409 ); + } + + /** + * Removing a reaction takes it out of the note's summary. + * + * @ticket 64638 + */ + public function test_delete_reaction() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ) ); + + $this->assertSame( 200, $response->get_status() ); + + $note = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + $summary = $note->get_data()['reaction_summary']; + $this->assertSame( array(), $summary, 'The removed reaction should no longer be summarized.' ); + } + + /** + * A user who cannot edit the note's post cannot remove a reaction on it. + * + * @ticket 64638 + */ + public function test_delete_reaction_requires_edit_permission() { + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + wp_set_current_user( self::$subscriber_id ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ) ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + $this->assertNotNull( get_comment( $reaction_id ) ); + } + + /** + * Only the user who added a reaction can remove it, even though other + * users who can edit the post can edit the note it belongs to. + * + * @ticket 64638 + */ + public function test_delete_reaction_of_another_user_is_not_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + $this->assertSame( 'Sorry, you can only remove your own reactions.', $response->as_error()->get_error_message() ); + $this->assertNotNull( get_comment( $reaction_id ), 'Another user removed the reaction.' ); + } + + /** + * A reaction's author can remove it from a note somebody else wrote. + * + * @ticket 64638 + */ + public function test_delete_own_reaction_on_another_users_note() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$admin_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_comment( $reaction_id ) ); + } + + /** + * A reaction cannot be added to a trashed or spammed note, where it would + * escape the trash cascade. + * + * @ticket 64638 + * + * @dataProvider data_hidden_note_statuses + * + * @param string $status Status to move the parent note to. + */ + public function test_create_reaction_on_hidden_note( $status ) { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + wp_set_comment_status( $note_id, $status ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * Data provider for test_create_reaction_on_hidden_note(). + * + * @return array[] + */ + public function data_hidden_note_statuses() { + return array( + 'trash' => array( 'trash' ), + 'spam' => array( 'spam' ), + ); + } + + /** + * Resolving a thread approves its root note and the editor disables + * reactions from then on, so the server rejects them too, on the root + * note and on its replies. + * + * @ticket 64638 + * + * @dataProvider data_resolved_thread_targets + * + * @param bool $on_reply Whether to react to a reply rather than the root note. + */ + public function test_create_reaction_on_resolved_thread( $on_reply ) { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $target = $note_id; + if ( $on_reply ) { + $target = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test reply', + ) + ); + } + wp_set_comment_status( $note_id, 'approve' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $target, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + $this->assertSame( 'A reaction cannot be added to a resolved note.', $response->as_error()->get_error_message() ); + } + + /** + * Data provider for test_create_reaction_on_resolved_thread(). + * + * @return array[] + */ + public function data_resolved_thread_targets() { + return array( + 'root note' => array( false ), + 'reply' => array( true ), + ); + } + + /** + * A note on a post the user cannot edit gets the same error whatever its + * status, so a reaction request cannot reveal whether that note is + * trashed, spammed or resolved. + * + * @ticket 64638 + * + * @dataProvider data_other_post_note_states + * + * @param string $state The state to put the other post's note in. + */ + public function test_create_reaction_on_note_from_uneditable_post_does_not_reveal_its_status( $state ) { + $own_post_id = self::factory()->post->create( array( 'post_author' => self::$author_id ) ); + $other_post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $other_post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Note on another post', + ) + ); + if ( 'open' !== $state ) { + wp_set_comment_status( $note_id, $state ); + } + + wp_set_current_user( self::$author_id ); + $this->assertFalse( current_user_can( 'edit_post', $other_post_id ), 'The user should not be able to edit the other post.' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $own_post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + $this->assertSame( 'A reaction must be attached to a note on the same post.', $response->as_error()->get_error_message() ); + } + + /** + * Data provider for test_create_reaction_on_note_from_uneditable_post_does_not_reveal_its_status(). + * + * @return array[] + */ + public function data_other_post_note_states() { + return array( + 'open' => array( 'open' ), + 'trash' => array( 'trash' ), + 'spam' => array( 'spam' ), + 'resolved' => array( 'approve' ), + ); + } + + /** + * Reactions are an internal comment type and are not world-readable, even + * when approved on a public post. Only the reacting user or a user who can + * edit the comment can read one. + * + * @ticket 64638 + */ + public function test_reaction_is_not_publicly_readable() { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$editor_id, + ) + ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $reaction_id ); + + wp_set_current_user( 0 ); + $this->assertErrorResponse( 'rest_cannot_read', rest_get_server()->dispatch( $request ), 401 ); + + wp_set_current_user( self::$subscriber_id ); + $this->assertErrorResponse( 'rest_cannot_read', rest_get_server()->dispatch( $request ), 403 ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'The reacting user should be able to read their reaction.' ); + $this->assertSame( $reaction_id, $response->get_data()['id'] ); + + wp_set_current_user( self::$admin_id ); + $this->assertSame( 200, rest_get_server()->dispatch( $request )->get_status(), 'A user who can edit the reaction should be able to read it.' ); + } + + /** + * A later page of notes summarizes its own notes' reactions with the same + * two queries as the first page, rather than one query per note. + * + * @ticket 64638 + */ + public function test_second_page_of_notes_keeps_reaction_summary_queries_bounded() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_ids = self::factory()->comment->create_many( + 4, + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + foreach ( $note_ids as $note_id ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + } + + $summary_queries = 0; + $count_queries = static function ( $query ) use ( &$summary_queries ) { + if ( str_contains( $query, "comment_type = 'reaction'" ) ) { + ++$summary_queries; + } + return $query; + }; + add_filter( 'query', $count_queries ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'all' ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'per_page', 2 ); + $request->set_param( 'page', 2 ); + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'query', $count_queries ); + + $data = $response->get_data(); + $this->assertCount( 2, $data ); + foreach ( $data as $note ) { + $this->assertSame( 1, $note['reaction_summary']['2764']['count'] ); + $this->assertGreaterThan( 0, $note['reaction_summary']['2764']['current_user_reaction'] ); + } + + // One counts query and one current-user query for the whole page. + $this->assertSame( 2, $summary_queries ); + } + + /** + * A reaction can be added to a reply in an open thread. + * + * @ticket 64638 + */ + public function test_create_reaction_on_reply_in_open_thread() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reply_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test reply', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $reply_id, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + } + + /** + * Listing notes returns each note's reaction summary without a per-note query. + * + * @ticket 64638 + */ + public function test_note_collection_includes_reaction_summary() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_ids = array(); + + for ( $i = 0; $i < 3; $i++ ) { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Note ' . $i, + ) + ); + $note_ids[] = $note_id; + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + } + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'context', 'edit' ); + + $queries_before = get_num_queries(); + $response = rest_get_server()->dispatch( $request ); + $queries_after = get_num_queries(); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 3, $data ); + + foreach ( $data as $note ) { + $this->assertArrayHasKey( 'reaction_summary', $note ); + $this->assertSame( 1, $note['reaction_summary']['2764']['count'] ); + $this->assertGreaterThan( 0, $note['reaction_summary']['2764']['current_user_reaction'] ); + } + + /* + * Summaries are pre-fetched in two aggregated queries for the whole + * collection. Pin a ceiling well under one query per note so a + * regression back to the N+1 path is caught. + */ + $this->assertLessThan( + $queries_before + 20, + $queries_after, + 'Listing notes should not run a reaction query per note.' + ); + } + + /** + * A note is not readable, and so neither is its reaction summary, without permission. + * + * @ticket 64638 + */ + public function test_reaction_summary_is_not_exposed_to_logged_out_users() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + wp_set_current_user( 0 ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + } + + /** + * Reactions from several users are counted together, and the current user's + * own row is the one reported back. + * + * @ticket 64638 + */ + public function test_reaction_summary_counts_reactions_from_multiple_users() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $their_reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$admin_id, + 'comment_content' => '2764', + ) + ); + $my_reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + $summary = $response->get_data()['reaction_summary']; + + $this->assertSame( 2, $summary['2764']['count'], 'Both users should be counted under the same emoji.' ); + $this->assertSame( $my_reaction_id, $summary['2764']['current_user_reaction'] ); + $this->assertNotSame( $their_reaction_id, $summary['2764']['current_user_reaction'] ); + } + + /** + * A trashed reaction drops out of the summary. + * + * @ticket 64638 + */ + public function test_reaction_summary_excludes_trashed_reactions() { + wp_set_current_user( self::$editor_id ); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '2764', + ) + ); + + wp_trash_comment( $reaction_id ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + + $this->assertSame( array(), $response->get_data()['reaction_summary'] ); + } }