Commit 78f510553b3 for woocommerce

commit 78f510553b30bc8ffe7b102c80e22e1b4fd317a8
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 23:37:39 2026 +0300

    [tests] Move review edit and reply to PHPUnit, keep a browser canary for moderation (#68651)

    * test(products): Move review edit and reply below E2E

    The product reviews spec drove wp-admin nine times. Four of those
    titles were really asking whether an admin-ajax handler persists what
    it was sent: editing a review's content, replying to one, and the list
    table rendering the row it returns.

    ReviewsAjaxTest takes that. It extends WP_Ajax_UnitTestCase, so it
    dispatches the real registered actions rather than calling the handler
    directly, and it asserts both halves: the review reloaded from the
    database has the new content and status, and the XML response carries
    the WooCommerce row markup with its rating and product columns.
    ReviewsListTableTest grows the column and row-action coverage
    underneath it.

    What this commit does NOT move down a layer is the moderation
    actions. ReviewsAjaxTest covers edit-comment and replyto-comment only.
    Approve, spam and trash have no PHP coverage of their state
    transition: ReviewsListTableTest::test_handle_row_actions asserts that
    the row renders `Approve`, `Spam` and `Trash` links with the right
    URLs and nonces, which is a markup contract, not a behaviour. The next
    commit keeps those three in the browser for that reason.

    Two of the carried commits tighten existing assertions rather than
    moving anything: 8019467377 scopes the author assertion to the author
    cell instead of the whole row, and 165415babf asserts the exact author
    email link rather than a substring.

    The spec goes from 9 titles to 5 here, and to 6 with the next commit.

    Carries the mega-branch commits:
    - d51196bcc6 test(products): Move review actions below E2E
    - 8019467377 test(admin): Scope review author assertion to author cell
    - 165415babf test(admin): Assert exact review author email link

    Refs TESTOPS-288
    Refs #68046

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(e2e): Keep a browser canary for review moderation

    The previous commit moved review edit and reply to ReviewsAjaxTest.
    It did not move approve, spam or trash, because nothing below the
    browser asserts those transitions:
    ReviewsListTableTest::test_handle_row_actions checks that the row
    renders `Approve`, `Spam` and `Trash` links with the right URLs and
    nonces, which proves the markup contract and not the behaviour. The
    migration removed all three browser titles anyway, leaving the three
    moderation actions with no test that they do anything.

    Add one title covering all three transitions, replacing three.

    Every step checks the review's new status by loading the matching
    filtered view, not by looking at the row's own buttons. That matters:
    the all view renders both Approve and Unapprove regardless of status,
    and the fixture's reviews are created already approved, so the obvious
    version of this test -- click Approve, expect Unapprove to appear --
    asserts nothing. It was written that way first and survived two
    separate mutations of the approve path before the filtered-view
    assertions replaced it.

    Spam and trash use different rows, because they are not stages of one
    lifecycle: once a review is spammed its actions become Not Spam and
    Delete Permanently, so Trash is only reachable from the main list.

    Refs TESTOPS-288
    Refs #68046

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(reviews): Stop the product review tests restoring base-class state

    The AJAX test snapshotted $_POST, $_REQUEST and the current user in
    set_up() and put them back in tear_down(), tracked which of the four
    AJAX hooks it had added so it could remove them again, and deleted its
    comments, posts and products by hand. WP_Ajax_UnitTestCase::tear_down()
    already clears $_POST and $_GET, WP_UnitTestCase_Base::tear_down()
    rolls the transaction back, restores the hooks and resets the current
    user, and clean_up_global_scope() empties $_REQUEST before the next
    test starts.

    That leaves nothing for this class to tear down, so the override goes
    and the four hook registrations become unconditional. Adding the same
    callback at the same priority twice overwrites a single entry, so the
    has_action() guards were not protecting against anything. They still
    have to run in set_up() rather than once for the class: the hook
    restore rewinds $wp_filter past whatever set_up_before_class()
    registered.

    Same treatment for the list-table row test, which restored the current
    user and deleted its fixtures. The libxml_use_internal_errors() pair
    stays -- that is process state the base class does not own.

    Same 191 tests and 460 assertions before and after.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(reviews): Drop two set_up assertions that restate their own add_action

    set_up registered the two handlers at priority -1 and then asserted
    has_action returned -1 for each, two lines below the calls that made it
    so. They cannot fail.

    They also would not pin the thing worth pinning even if they could:
    has_action returns the lowest priority holding the callback, so a
    production registration at some other priority still reads back as -1.
    What matters is that WooCommerce's handler runs before core's, and the
    two dispatch tests already establish that behaviourally.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(reviews): Add testdox to the product review AJAX tests

    The five tests in ReviewsAjaxTest described themselves in plain
    docblock sentences, where every other test file in this batch uses
    @testdox.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(reviews): Guard the product review trash Undo notice in PHPUnit

    CodeRabbit asked for the E2E moderation canary to click Undo after
    trashing a review, as the removed "can delete a product review" title
    did. Most of that round trip is WordPress core: edit-comments.js builds
    the Undo row, and wp_ajax_delete_comment restores the review.

    WooCommerce feeds it in two places. render_reviews_list_table() calls
    wp_comment_trashnotice(), which prints the holder the Undo row is
    copied from. column_author() wraps the author in <strong>, where the
    script reads the name for "Comment by ... moved to the Trash."
    Deleting either one left all 174 review tests green.

    Assert both in PHPUnit rather than bring the flow back to the browser.
    playwright.config.ts already records the trash/undo/re-trash flow as
    flaky under concurrent review churn. With the new assertions, each
    mutation fails its test.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/testops-288-product-reviews b/plugins/woocommerce/changelog/testops-288-product-reviews
new file mode 100644
index 00000000000..d7d5f0d1eb8
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-product-reviews
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Cut the product reviews E2E spec from 9 titles to 6; review edit and reply now covered by ReviewsAjaxTest, with a retained browser canary for the approve, spam and trash moderation actions.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
index b3a88a1f566..3a07fe70238 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
@@ -71,36 +71,6 @@ const test = baseTest.extend( {

 test.describe( 'Product Reviews', () => {
 	test.describe( 'Merchant manages reviews', () => {
-		test( 'can view products reviews list', async ( { page, reviews } ) => {
-			await page.goto(
-				`wp-admin/edit.php?post_type=product&page=product-reviews`
-			);
-
-			for ( const review of reviews ) {
-				const reviewRow = page.locator( `#comment-${ review.id }` );
-
-				await expect(
-					reviewRow.locator( '[data-colname="Author"]' )
-				).toContainText( review.reviewer_email );
-				await expect(
-					reviewRow
-						.locator( '[data-colname="Rating"]' )
-						.getByLabel( `${ review.rating } out of 5` )
-				).toBeVisible();
-				await expect(
-					reviewRow.locator( '[data-colname="Review"]' )
-				).toContainText( review.review );
-				await expect(
-					reviewRow
-						.locator( '[data-colname="Product"]' )
-						.getByRole( 'link' )
-						.first()
-				).toContainText( review.product_name );
-			}
-
-			expect( reviews.length ).toBeGreaterThan( 0 );
-		} );
-
 		test( 'can filter the reviews by product', async ( {
 			page,
 			reviews,
@@ -126,12 +96,94 @@ test.describe( 'Product Reviews', () => {
 				1
 			);

+			const reviewRow = page.locator( `#comment-${ review.id }` );
+			await expect(
+				reviewRow.locator( '[data-colname="Author"]' )
+			).toContainText( review.reviewer_email );
 			await expect(
-				page
+				reviewRow
+					.locator( '[data-colname="Rating"]' )
+					.getByLabel( `${ review.rating } out of 5` )
+			).toBeVisible();
+			await expect(
+				reviewRow.locator( '[data-colname="Review"]' )
+			).toContainText( review.review );
+			await expect(
+				reviewRow
 					.locator( '[data-colname="Product"]' )
 					.getByRole( 'link' )
-					.filter( { hasText: review.product_name } )
-			).toBeVisible();
+					.first()
+			).toContainText( review.product_name );
+		} );
+
+		// Canary for the moderation actions. This batch moved review edit and reply
+		// to ReviewsAjaxTest, but approve, spam and trash have no PHP coverage of
+		// their state transition: ReviewsListTableTest::test_handle_row_actions
+		// asserts only that the row renders those links. Nothing else in the suite
+		// moderates a review.
+		//
+		// Every step checks the review's new status by loading the matching
+		// filtered view rather than by looking at the row's own buttons. The list
+		// renders both Approve and Unapprove in the all view regardless of status,
+		// so asserting on them proves nothing -- an earlier version of this test
+		// did exactly that and survived two mutations of the approve path.
+		//
+		// Spam and trash use different rows on purpose: once a review is spammed
+		// its actions become Not Spam and Delete Permanently, so Trash is only
+		// reachable from the main list.
+		test( 'can approve, spam and trash a product review', async ( {
+			page,
+			reviews,
+		} ) => {
+			const reviewsUrl =
+				'wp-admin/edit.php?post_type=product&page=product-reviews';
+			const moderated = reviews[ 0 ];
+			const trashed = reviews[ 1 ];
+
+			const rowFor = ( id: number ) => page.locator( `#comment-${ id }` );
+
+			await test.step( 'unapprove, then approve again', async () => {
+				await page.goto( reviewsUrl );
+				await rowFor( moderated.id ).hover();
+				await rowFor( moderated.id )
+					.getByRole( 'button', { name: 'Unapprove' } )
+					.click();
+
+				await page.goto( `${ reviewsUrl }&comment_status=moderated` );
+				await expect( rowFor( moderated.id ) ).toBeVisible();
+
+				await rowFor( moderated.id ).hover();
+				await rowFor( moderated.id )
+					.getByRole( 'button', { name: 'Approve' } )
+					.click();
+
+				await page.goto( `${ reviewsUrl }&comment_status=approved` );
+				await expect( rowFor( moderated.id ) ).toBeVisible();
+			} );
+
+			await test.step( 'mark as spam', async () => {
+				await page.goto( reviewsUrl );
+				await rowFor( moderated.id ).hover();
+				await rowFor( moderated.id )
+					.getByRole( 'button', { name: 'Spam' } )
+					.click();
+				await expect( rowFor( moderated.id ) ).toBeHidden();
+
+				await page.goto( `${ reviewsUrl }&comment_status=spam` );
+				await expect( rowFor( moderated.id ) ).toBeVisible();
+			} );
+
+			await test.step( 'trash', async () => {
+				await page.goto( reviewsUrl );
+				await rowFor( trashed.id ).hover();
+				await rowFor( trashed.id )
+					.getByRole( 'button', { name: 'Trash' } )
+					.click();
+				await expect( rowFor( trashed.id ) ).toBeHidden();
+
+				await page.goto( `${ reviewsUrl }&comment_status=trash` );
+				await expect( rowFor( trashed.id ) ).toBeVisible();
+			} );
 		} );

 		test( 'can quick edit a product review', async ( {
@@ -143,7 +195,6 @@ test.describe( 'Product Reviews', () => {
 			await page.goto(
 				`wp-admin/edit.php?post_type=product&page=product-reviews`
 			);
-
 			const reviewRow = page.locator( `#comment-${ review.id }` );
 			await reviewRow.hover();
 			await reviewRow
@@ -165,105 +216,6 @@ test.describe( 'Product Reviews', () => {
 			).toBeVisible();
 		} );

-		test( 'can edit a product review', async ( { page, reviews } ) => {
-			const review = reviews[ 0 ];
-
-			await page.goto(
-				`wp-admin/comment.php?action=editcomment&c=${ review.id }`
-			);
-			await expect( page.getByText( 'Edit Comment' ) ).toBeVisible();
-
-			const updatedReview = `(edited ${ Date.now() })`;
-			await page
-				.locator( '.wp-editor-area' )
-				.first()
-				.fill( updatedReview );
-
-			await page.click( '#rating' );
-			const updatedRating = ( Math.random() * ( 5 - 1 ) + 1 ).toFixed(
-				0
-			);
-			await page.selectOption( '#rating', {
-				value: updatedRating.toString(),
-			} );
-			await page.getByRole( 'button', { name: 'Update' } ).click();
-			await page.waitForURL( '**/edit-comments.php?**' );
-
-			await page.goto(
-				`wp-admin/edit.php?post_type=product&page=product-reviews`
-			);
-
-			const reviewRow = page.locator( `#comment-${ review.id }` );
-
-			// WordPress 7.1 renders primary list-table cells as row headers.
-			await expect(
-				reviewRow.getByRole( 'cell', { name: updatedReview } ).or(
-					reviewRow.getByRole( 'rowheader', {
-						name: updatedReview,
-					} )
-				)
-			).toBeVisible();
-			await expect(
-				reviewRow.getByLabel( `${ updatedRating } out of 5` )
-			).toBeVisible();
-
-			await reviewRow.locator( 'a.comments-view-item-link' ).click();
-			await page.getByRole( 'tab', { name: 'Reviews' } ).click();
-			await expect(
-				page.locator( '.comment_container' ).first()
-			).toContainText( updatedReview );
-			await expect(
-				page.getByLabel( `${ updatedRating } out of 5` )
-			).toBeVisible();
-		} );
-
-		test( 'can approve a product review', async ( { page, reviews } ) => {
-			const review = reviews[ 0 ]; // Select the first review for approval
-
-			await page.goto(
-				`wp-admin/edit.php?post_type=product&page=product-reviews`
-			);
-
-			const reviewRow = page.locator( `#comment-${ review.id }` );
-
-			const approveButton = reviewRow.getByRole( 'button', {
-				name: 'Approve',
-			} );
-
-			await reviewRow.hover();
-			await approveButton.click();
-			const unapproveButton = reviewRow.getByRole( 'button', {
-				name: 'Unapprove',
-			} );
-			await expect( unapproveButton ).toBeVisible();
-		} );
-
-		test( 'can mark a product review as spam', async ( {
-			page,
-			reviews,
-		} ) => {
-			const review = reviews[ 0 ];
-
-			await page.goto(
-				`wp-admin/edit.php?post_type=product&page=product-reviews`
-			);
-
-			const reviewRow = page.locator( `#comment-${ review.id }` );
-			await reviewRow.hover();
-
-			await reviewRow.getByRole( 'button', { name: 'Spam' } ).click();
-
-			await expect(
-				page.locator( `#comment-${ review.id }` )
-			).toBeHidden();
-
-			await page.click( 'a[href*="comment_status=spam"]' );
-
-			await expect(
-				page.locator( `#comment-${ review.id }` )
-			).toBeVisible();
-		} );
-
 		test( 'can reply to a product review', async ( { page, reviews } ) => {
 			const review = reviews[ 0 ];

@@ -308,69 +260,55 @@ test.describe( 'Product Reviews', () => {
 			await expect( replyReviews ).toBeVisible();
 		} );

-		test( 'can delete a product review', async ( { page, reviews } ) => {
+		test( 'can edit a product review', async ( { page, reviews } ) => {
 			const review = reviews[ 0 ];

 			await page.goto(
-				`wp-admin/edit.php?post_type=product&page=product-reviews`
+				`wp-admin/comment.php?action=editcomment&c=${ review.id }`
 			);
-			const reviewRow = page.locator( `#comment-${ review.id }` );
-			await reviewRow.hover();
-
-			await reviewRow.getByRole( 'button', { name: 'Trash' } ).click();
-			// WordPress wptexturize may convert straight apostrophes (') to
-			// smart quotes (\u2019) in the reviewer name, so check for both.
-			const trashMessage = `Comment by ${ review.reviewer } moved to the Trash`;
-			const trashMessageSmart = trashMessage.replace( /'/g, '\u2019' );
-			const trashNotice = page.locator( '.trash-undo-inside' ).first();
-			const trashNoticeText = await trashNotice.textContent();
-			expect(
-				trashNoticeText?.includes( trashMessage ) ||
-					trashNoticeText?.includes( trashMessageSmart )
-			).toBeTruthy();
-			await page.getByRole( 'button', { name: 'Undo' } ).click();
-
-			// WordPress 7.1 renders primary list-table cells as row headers.
-			await expect(
-				reviewRow.getByRole( 'cell', { name: review.review } ).or(
-					reviewRow.getByRole( 'rowheader', {
-						name: review.review,
-					} )
-				)
-			).toBeVisible();
+			await expect( page.getByText( 'Edit Comment' ) ).toBeVisible();

-			await reviewRow.getByRole( 'button', { name: 'Trash' } ).click();
+			const updatedReview = `(edited ${ Date.now() })`;
+			await page
+				.locator( '.wp-editor-area' )
+				.first()
+				.fill( updatedReview );

-			const trashNotice2 = page.locator( '.trash-undo-inside' ).first();
-			const trashNoticeText2 = await trashNotice2.textContent();
-			expect(
-				trashNoticeText2?.includes( trashMessage ) ||
-					trashNoticeText2?.includes( trashMessageSmart )
-			).toBeTruthy();
+			await page.click( '#rating' );
+			const updatedRating = ( Math.random() * ( 5 - 1 ) + 1 ).toFixed(
+				0
+			);
+			await page.selectOption( '#rating', {
+				value: updatedRating.toString(),
+			} );
+			await page.getByRole( 'button', { name: 'Update' } ).click();
+			await page.waitForURL( '**/edit-comments.php?**' );

-			// The trash notice renders optimistically, before the trash AJAX
-			// commits. Wait for the row to actually leave the approved list so
-			// navigating to the Trash view does not abort the in-flight request.
-			await expect( reviewRow ).toBeHidden();
+			await page.goto(
+				`wp-admin/edit.php?post_type=product&page=product-reviews`
+			);

-			await page.click( 'a[href*="comment_status=trash"]' );
+			const reviewRow = page.locator( `#comment-${ review.id }` );

 			// WordPress 7.1 renders primary list-table cells as row headers.
 			await expect(
-				reviewRow.getByRole( 'cell', { name: review.review } ).or(
+				reviewRow.getByRole( 'cell', { name: updatedReview } ).or(
 					reviewRow.getByRole( 'rowheader', {
-						name: review.review,
+						name: updatedReview,
 					} )
 				)
 			).toBeVisible();
+			await expect(
+				reviewRow.getByLabel( `${ updatedRating } out of 5` )
+			).toBeVisible();

-			await page.goto(
-				`wp-admin/comment.php?action=editcomment&c=${ review.id }`
-			);
+			await reviewRow.locator( 'a.comments-view-item-link' ).click();
+			await page.getByRole( 'tab', { name: 'Reviews' } ).click();
 			await expect(
-				page.getByText(
-					`This comment is in the Trash. Please move it out of the Trash if you want to edit it.`
-				)
+				page.locator( '.comment_container' ).first()
+			).toContainText( updatedReview );
+			await expect(
+				page.getByLabel( `${ updatedRating } out of 5` )
 			).toBeVisible();
 		} );
 	} );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsAjaxTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsAjaxTest.php
new file mode 100644
index 00000000000..d739bf2008b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsAjaxTest.php
@@ -0,0 +1,272 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\ProductReviews;
+
+require_once ABSPATH . 'wp-admin/includes/ajax-actions.php';
+
+use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
+use WC_Helper_Product;
+use WP_Ajax_UnitTestCase;
+use WP_Comment;
+use WPAjaxDieContinueException;
+use WPAjaxDieStopException;
+
+/**
+ * Tests Product Reviews registered AJAX handlers.
+ *
+ * @covers \Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews::handle_edit_review
+ * @covers \Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews::handle_reply_to_review
+ */
+class ReviewsAjaxTest extends WP_Ajax_UnitTestCase {
+	/**
+	 * Registered Product Reviews handler.
+	 *
+	 * @var Reviews
+	 */
+	private $reviews;
+
+	/**
+	 * Set up the registered Woo handlers.
+	 *
+	 * `_restore_hooks()` rewinds `$wp_filter` to the state it had when the suite
+	 * started, so the core AJAX callbacks `WP_Ajax_UnitTestCase::set_up_before_class()`
+	 * registers are gone by the second test in this class. Register both sides again
+	 * here; adding the same callback at the same priority twice overwrites one entry.
+	 */
+	public function set_up(): void {
+		parent::set_up();
+
+		$this->reviews = wc_get_container()->get( Reviews::class );
+
+		add_action( 'wp_ajax_edit-comment', array( $this->reviews, 'handle_edit_review' ), -1 );
+		add_action( 'wp_ajax_replyto-comment', array( $this->reviews, 'handle_reply_to_review' ), -1 );
+		add_action( 'wp_ajax_edit-comment', 'wp_ajax_edit_comment', 1 );
+		add_action( 'wp_ajax_replyto-comment', 'wp_ajax_replyto_comment', 1 );
+	}
+
+	/**
+	 * @testdox Editing a product review uses Woo's registered handler and row renderer.
+	 */
+	public function test_edit_review_via_registered_ajax_persists_and_returns_woo_row(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$review  = $this->create_review( $product->get_id(), 'Original review' );
+		$this->_setRole( 'administrator' );
+
+		$_POST = array(
+			'_ajax_nonce-replyto-comment' => wp_create_nonce( 'replyto-comment' ),
+			'comment_ID'                  => $review->comment_ID,
+			'content'                     => 'Updated review from AJAX',
+			'position'                    => 7,
+			'status'                      => 'approved',
+		);
+
+		$xml           = $this->dispatch_successful_ajax( 'edit-comment' );
+		$response      = $xml->response[0]->edit_comment;
+		$response_data = (string) $response->response_data;
+		$fresh_review  = get_comment( $review->comment_ID );
+
+		$this->assertInstanceOf( WP_Comment::class, $fresh_review );
+		$this->assertSame( 'Updated review from AJAX', $fresh_review->comment_content );
+		$this->assertSame( 'approved', $fresh_review->comment_approved );
+		$this->assertSame( 'edit-comment_' . $review->comment_ID, (string) $xml->response['action'] );
+		$this->assertSame( (string) $review->comment_ID, (string) $response['id'] );
+		$this->assertSame( '7', (string) $response['position'] );
+		$this->assertStringContainsString( 'id="comment-' . $review->comment_ID . '"', $response_data );
+		$this->assertStringContainsString( 'data-colname="Rating"', $response_data );
+		$this->assertStringContainsString( 'data-colname="Product"', $response_data );
+	}
+
+	/**
+	 * @testdox Non-product comments fall through to WordPress core's registered handler.
+	 */
+	public function test_edit_non_product_comment_via_registered_ajax_falls_through_to_core(): void {
+		$post_id = $this->factory()->post->create();
+		$comment = $this->factory()->comment->create_and_get(
+			array(
+				'comment_post_ID' => $post_id,
+				'comment_content' => 'Original core comment',
+			)
+		);
+		$this->_setRole( 'administrator' );
+
+		$_POST = array(
+			'_ajax_nonce-replyto-comment' => wp_create_nonce( 'replyto-comment' ),
+			'comment_ID'                  => $comment->comment_ID,
+			'content'                     => 'Updated by WordPress core',
+			'position'                    => 3,
+		);
+
+		$xml           = $this->dispatch_successful_ajax( 'edit-comment' );
+		$response_data = (string) $xml->response[0]->edit_comment->response_data;
+		$fresh_comment = get_comment( $comment->comment_ID );
+
+		$this->assertInstanceOf( WP_Comment::class, $fresh_comment );
+		$this->assertSame( 'Updated by WordPress core', $fresh_comment->comment_content );
+		$this->assertSame( 'edit-comment_' . $comment->comment_ID, (string) $xml->response['action'] );
+		$this->assertSame( '3', (string) $xml->response[0]->edit_comment['position'] );
+		$this->assertStringNotContainsString( 'data-colname="Rating"', $response_data );
+		$this->assertStringNotContainsString( 'data-colname="Product"', $response_data );
+	}
+
+	/**
+	 * @testdox Editing a review without permission is rejected without persistence.
+	 */
+	public function test_edit_review_via_registered_ajax_rejects_unauthorized_request(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$review  = $this->create_review( $product->get_id(), 'Protected review' );
+		$this->_setRole( 'subscriber' );
+
+		$_POST = array(
+			'_ajax_nonce-replyto-comment' => wp_create_nonce( 'replyto-comment' ),
+			'comment_ID'                  => $review->comment_ID,
+			'content'                     => 'Unauthorized edit',
+		);
+
+		$this->assert_ajax_stops_with( 'edit-comment', '-1' );
+
+		$fresh_review = get_comment( $review->comment_ID );
+		$this->assertInstanceOf( WP_Comment::class, $fresh_review );
+		$this->assertSame( 'Protected review', $fresh_review->comment_content );
+	}
+
+	/**
+	 * @testdox Replying to a product review persists a child and returns Woo's row.
+	 */
+	public function test_reply_to_review_via_registered_ajax_persists_child_and_returns_woo_row(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$review  = $this->create_review( $product->get_id(), 'Parent review' );
+		$this->_setRole( 'administrator' );
+		$current_user = wp_get_current_user();
+
+		$_POST = array(
+			'_ajax_nonce-replyto-comment' => wp_create_nonce( 'replyto-comment' ),
+			'comment_ID'                  => $review->comment_ID,
+			'comment_post_ID'             => $product->get_id(),
+			'comment_type'                => 'comment',
+			'content'                     => 'Store reply from AJAX',
+			'position'                    => 5,
+		);
+
+		$xml           = $this->dispatch_successful_ajax( 'replyto-comment' );
+		$response      = $xml->response[0]->comment;
+		$response_data = (string) $response->response_data;
+		$reply_id      = (int) $response['id'];
+		$fresh_reply   = get_comment( $reply_id );
+
+		$this->assertGreaterThan( 0, $reply_id );
+		$this->assertInstanceOf( WP_Comment::class, $fresh_reply );
+		$this->assertSame( $product->get_id(), (int) $fresh_reply->comment_post_ID );
+		$this->assertSame( (int) $review->comment_ID, (int) $fresh_reply->comment_parent );
+		$this->assertSame( 'comment', $fresh_reply->comment_type );
+		$this->assertSame( 'Store reply from AJAX', $fresh_reply->comment_content );
+		$this->assertSame( $current_user->display_name, $fresh_reply->comment_author );
+		$this->assertSame( $current_user->user_email, $fresh_reply->comment_author_email );
+		$this->assertSame( 'replyto-comment_' . $reply_id, (string) $xml->response['action'] );
+		$this->assertSame( '5', (string) $response['position'] );
+		$this->assertStringContainsString( 'id="comment-' . $reply_id . '"', $response_data );
+		$this->assertStringContainsString( 'data-colname="Product"', $response_data );
+		$this->assertStringContainsString( 'In reply to', $response_data );
+	}
+
+	/**
+	 * @testdox Replying without permission is rejected without creating a child.
+	 */
+	public function test_reply_to_review_via_registered_ajax_rejects_unauthorized_request(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$review  = $this->create_review( $product->get_id(), 'Protected parent review' );
+		$this->_setRole( 'subscriber' );
+
+		$_POST = array(
+			'_ajax_nonce-replyto-comment' => wp_create_nonce( 'replyto-comment' ),
+			'comment_ID'                  => $review->comment_ID,
+			'comment_post_ID'             => $product->get_id(),
+			'content'                     => 'Unauthorized reply',
+		);
+
+		$this->assert_ajax_stops_with( 'replyto-comment', '-1' );
+
+		$this->assertSame(
+			array(),
+			get_comments(
+				array(
+					'parent'  => $review->comment_ID,
+					'post_id' => $product->get_id(),
+				)
+			)
+		);
+	}
+
+	/**
+	 * Create a product review fixture.
+	 *
+	 * @param int    $product_id Product ID.
+	 * @param string $content    Review content.
+	 * @return WP_Comment
+	 */
+	private function create_review( int $product_id, string $content ): WP_Comment {
+		$review = $this->factory()->comment->create_and_get(
+			array(
+				'comment_post_ID'      => $product_id,
+				'comment_author'       => 'Review Author',
+				'comment_author_email' => 'reviewer@example.com',
+				'comment_content'      => $content,
+				'comment_approved'     => '1',
+				'comment_type'         => 'review',
+			)
+		);
+		update_comment_meta( $review->comment_ID, 'rating', 4 );
+
+		return $review;
+	}
+
+	/**
+	 * Dispatch an AJAX request that must return a well-formed XML response.
+	 *
+	 * @param string $action AJAX action.
+	 * @return \SimpleXMLElement
+	 */
+	private function dispatch_successful_ajax( string $action ): \SimpleXMLElement {
+		$this->_last_response = '';
+		$buffer_level         = ob_get_level();
+
+		try {
+			$this->_handleAjax( $action );
+		} catch ( WPAjaxDieContinueException $exception ) {
+			unset( $exception );
+		} finally {
+			while ( ob_get_level() > $buffer_level ) {
+				ob_end_clean();
+			}
+			while ( ob_get_level() < $buffer_level ) {
+				ob_start();
+			}
+		}
+
+		$xml = simplexml_load_string( (string) $this->_last_response, 'SimpleXMLElement', LIBXML_NOCDATA );
+		$this->assertInstanceOf( \SimpleXMLElement::class, $xml, (string) $this->_last_response );
+
+		return $xml;
+	}
+
+	/**
+	 * Assert that a registered AJAX request terminates with the expected error.
+	 *
+	 * @param string $action  AJAX action.
+	 * @param string $message Expected exception message.
+	 */
+	private function assert_ajax_stops_with( string $action, string $message ): void {
+		$this->_last_response = '';
+		$did_stop             = false;
+
+		try {
+			$this->_handleAjax( $action );
+		} catch ( WPAjaxDieStopException $exception ) {
+			$did_stop = true;
+			$this->assertSame( $message, $exception->getMessage() );
+		}
+
+		$this->assertTrue( $did_stop, 'The AJAX request should terminate before reaching the core handler.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php
index 06880fee523..a224f24fed0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php
@@ -1,8 +1,13 @@
 <?php

+declare( strict_types = 1 );
+
 namespace Automattic\WooCommerce\Tests\Internal\Admin\ProductReviews;

 use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsListTable;
+use DOMDocument;
+use DOMElement;
+use DOMXPath;
 use Generator;
 use ReflectionClass;
 use ReflectionException;
@@ -53,19 +58,27 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 	 * @return void
 	 */
 	public function test_single_row(): void {
-		$post_id = $this->factory()->post->create();
-		$review  = $this->factory()->comment->create_and_get(
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_name( 'Exact Review Row Product' );
+		$product->save();
+		wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+		$review = $this->factory()->comment->create_and_get(
 			array(
-				'comment_post_ID' => $post_id,
+				'comment_post_ID'      => $product->get_id(),
+				'comment_author'       => 'Exact Review Author',
+				'comment_author_email' => 'row-reviewer@example.com',
+				'comment_content'      => 'Exact review row content',
+				'comment_approved'     => '1',
+				'comment_type'         => 'review',
 			)
 		);
+		update_comment_meta( $review->comment_ID, 'rating', 4 );

 		$reviews_list_table = $this->get_reviews_list_table();

 		ob_start();
-
 		$reviews_list_table->single_row( $review );
-
 		$row_output = trim( ob_get_clean() );

 		$this->assertStringStartsWith( '<tr id="comment-' . $review->comment_ID . '"', $row_output );
@@ -74,17 +87,52 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 			if ( 'cb' !== $column_id ) {
 				$this->assertStringContainsString( 'data-colname="' . $column_name . '"', $row_output );
 			} else {
-				// WordPress 7.1 changed the list table check column cell from <th> to <td>.
-				// Accept either element; the backreference requires the closing tag to
-				// match the captured opening tag.
 				$this->assertMatchesRegularExpression(
-					'~<(?<cell_tag>th|td)[^>]*\bclass="check-column"></\k<cell_tag>>~',
+					'~<(?<cell_tag>th|td)[^>]*\bclass="check-column"[^>]*>.*?</\k<cell_tag>>~s',
 					$row_output,
-					'The row should contain an empty check-column cell.'
+					'The row should contain the standard review checkbox cell.'
 				);
 			}
 		}

+		$document = new DOMDocument();
+		$errors   = libxml_use_internal_errors( true );
+		$document->loadHTML( '<!doctype html><html><body><table>' . $row_output . '</table></body></html>' );
+		libxml_clear_errors();
+		libxml_use_internal_errors( $errors );
+
+		$xpath        = new DOMXPath( $document );
+		$author_cells = $xpath->query(
+			'//td[contains(concat(" ", normalize-space(@class), " "), " author ") and contains(concat(" ", normalize-space(@class), " "), " column-author ")]'
+		);
+		if ( false === $author_cells ) {
+			throw new \RuntimeException( 'Unable to query the author cell.' );
+		}
+
+		$author_cell = $author_cells->item( 0 );
+		$this->assertInstanceOf( DOMElement::class, $author_cell );
+		if ( ! $author_cell instanceof DOMElement ) {
+			throw new \RuntimeException( 'The author cell was not found.' );
+		}
+		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name.
+		$this->assertStringContainsString( 'Exact Review Author', $author_cell->textContent );
+
+		$email_links = $xpath->query( './/a[normalize-space(text())="row-reviewer@example.com"]', $author_cell );
+		if ( false === $email_links ) {
+			throw new \RuntimeException( 'Unable to query the author email link.' );
+		}
+
+		$email_link = $email_links->item( 0 );
+		$this->assertInstanceOf( DOMElement::class, $email_link );
+		if ( ! $email_link instanceof DOMElement ) {
+			throw new \RuntimeException( 'The author email link was not found.' );
+		}
+		$this->assertSame( 'mailto:row-reviewer@example.com', $email_link->getAttribute( 'href' ) );
+		$this->assertStringContainsString( 'aria-label="4 out of 5"', $row_output );
+		$this->assertStringContainsString( 'Exact review row content', $row_output );
+		$this->assertStringContainsString( 'Exact Review Row Product', $row_output );
+		$this->assertStringContainsString( 'comments-edit-item-link', $row_output );
+		$this->assertStringContainsString( 'comments-view-item-link', $row_output );
 		$this->assertStringEndsWith( '</tr>', $row_output );
 	}

@@ -153,7 +201,72 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {

 			// Should not contain any tags with _only_ a pipe separator, but no label.
 			$this->assertStringNotContainsString( '> | </span>', $actions );
+			$this->assert_row_action_contracts( $actions, (int) $review->comment_ID, $review_status );
+		}
+	}
+
+	/**
+	 * Assert exact URL, nonce, and `data-wp-lists` contracts for status actions.
+	 *
+	 * @param string $actions       Rendered row actions.
+	 * @param int    $review_id     Review ID.
+	 * @param string $review_status Normalized review status.
+	 */
+	private function assert_row_action_contracts( string $actions, int $review_id, string $review_status ): void {
+		$expected = array(
+			'approved'   => array(
+				'unapprovecomment' => "delete:the-comment-list:comment-{$review_id}:e7e7d3:action=dim-comment&new=unapproved",
+				'spamcomment'      => "delete:the-comment-list:comment-{$review_id}::spam=1",
+				'trashcomment'     => "delete:the-comment-list:comment-{$review_id}::trash=1",
+			),
+			'unapproved' => array(
+				'approvecomment' => "delete:the-comment-list:comment-{$review_id}:e7e7d3:action=dim-comment&new=approved",
+				'spamcomment'    => "delete:the-comment-list:comment-{$review_id}::spam=1",
+				'trashcomment'   => "delete:the-comment-list:comment-{$review_id}::trash=1",
+			),
+			'spam'       => array(
+				'unspamcomment' => "delete:the-comment-list:comment-{$review_id}:66cc66:unspam=1",
+				'deletecomment' => "delete:the-comment-list:comment-{$review_id}::delete=1",
+			),
+			'trash'      => array(
+				'spamcomment'    => "delete:the-comment-list:comment-{$review_id}::spam=1",
+				'untrashcomment' => "delete:the-comment-list:comment-{$review_id}:66cc66:untrash=1",
+				'deletecomment'  => "delete:the-comment-list:comment-{$review_id}::delete=1",
+			),
+		);
+
+		$document = new DOMDocument();
+		$errors   = libxml_use_internal_errors( true );
+		$document->loadHTML( '<!doctype html><html><body>' . $actions . '</body></html>' );
+		libxml_clear_errors();
+		libxml_use_internal_errors( $errors );
+
+		$actual = array();
+		foreach ( $document->getElementsByTagName( 'a' ) as $link ) {
+			if ( ! $link->hasAttribute( 'data-wp-lists' ) ) {
+				continue;
+			}
+
+			$query = array();
+			parse_str( (string) wp_parse_url( $link->getAttribute( 'href' ), PHP_URL_QUERY ), $query );
+			$action = $query['action'] ?? '';
+
+			$this->assertSame( (string) $review_id, $query['c'] ?? '' );
+			$this->assertArrayHasKey( '_wpnonce', $query );
+			$this->assertSame(
+				1,
+				wp_verify_nonce(
+					$query['_wpnonce'],
+					in_array( $action, array( 'approvecomment', 'unapprovecomment' ), true )
+						? "approve-comment_{$review_id}"
+						: "delete-comment_{$review_id}"
+				)
+			);
+
+			$actual[ $action ] = $link->getAttribute( 'data-wp-lists' );
 		}
+
+		$this->assertSame( $expected[ $review_status ], $actual );
 	}

 	/** @see test_handle_row_actions */
@@ -400,6 +513,11 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 		$author = get_comment_author( $review->comment_ID );

 		$this->assertStringContainsString( $author, $author_output );
+		$this->assertMatchesRegularExpression(
+			'#<strong>.*' . preg_quote( $author, '#' ) . '</strong>#s',
+			$author_output,
+			'Core comment list script reads the author name for the trash Undo notice from this <strong>.'
+		);

 		if ( $should_contain_avatar ) {
 			$this->assertStringContainsString( "<img alt='' src='", $author_output );
@@ -1936,7 +2054,7 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 		$method->invoke( $list_table, $product_id, $pending_review_count );
 		$actual_html = ob_get_clean();

-		$this->assertSame( str_replace( 'PRODUCT_ID', $product_id, $expected_html ), $actual_html );
+		$this->assertSame( str_replace( 'PRODUCT_ID', (string) $product_id, $expected_html ), $actual_html );
 	}

 	/** @see test_comments_bubble */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
index 46e809e29fe..03cd4613ca8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
@@ -226,6 +226,7 @@ class ReviewsTest extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( '<input type="hidden" name="page" value="' . Reviews::MENU_SLUG . '" />', $output );
 		$this->assertStringContainsString( '<input type="hidden" name="post_type" value="product" />', $output );
 		$this->assertStringContainsString( '<input type="hidden" name="pagegen_timestamp" value="', $output );
+		$this->assertStringContainsString( 'id="trash-undo-holder"', $output, 'Core comment list script copies the trash Undo notice from this holder.' );
 		$this->assertStringEndsWith( 'custom additional content', $output );

 		remove_all_filters( 'woocommerce_product_reviews_list_table' );