Commit d336f959377 for woocommerce

commit d336f9593774e276225843ee77cd6be0900dc59a
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Wed Sep 2 13:14:54 2026 +0300

    Fix product rating aggregates lagging on REST review changes (#67778)

    * Fix product rating aggregates lagging on REST review changes

    The REST product review controllers persist the comment row before the
    review's rating meta. WordPress fires wp_update_comment_count() during the
    insert or update, and WC_Comments::clear_transients() recomputes and stores
    the product's average rating, rating counts and review count right then --
    from comment meta that doesn't yet hold the new rating. Nothing recomputes
    afterwards, so the stored aggregates permanently lag one review behind: a
    product with a single REST-created review keeps an average of 0.

    Recompute explicitly once the rating meta is in place, on both the create
    and the update path, in the v3 controller and in the v1 controller that
    wc/v1 and wc/v2 share.

    The rating-filter Blocks spec asserted on the buggy values: the seeded Cap
    product has reviews rated 1 and 2, stored as 1.00 instead of 1.5, which put
    it in rating bucket 1 rather than 2. Its expectations move to bucket 2.

    Refs #29906

    * Add changelog entry for the REST review rating recompute fix

    * Skip the product rating recompute for reviews created without a rating

    * Recompute the product a v1 REST review was actually written to

    * Recompute the aggregates of the product a review moves away from

    * Remove the review recompute workarounds from the API tests

    * Update changelog entry to cover moved reviews

    * Cover the filter-redirected product in the v1 create test

    * fix(rest-api): persist review ratings during comment updates

    WordPress stores update-side comment meta before refreshing the comment
    count. Passing the rating into that update lets the existing WooCommerce
    callback calculate from the new value without a second product save.

    Treat only false as an update failure so rating-only v1 and v2 requests can
    succeed, while genuine failures stop before any rating write.

    Refs #29906

    * fix(rest-api): refresh source counts after review moves

    WordPress refreshes the destination after comment_post_ID changes but leaves the source product untouched. A WooCommerce-only refresh also leaves the source post's core comment count stale.

    Count only the product the review actually left so core and WooCommerce values agree, public count hooks fire consistently, and deferred counting is respected.

    Refs #29906

    * perf(rest-api): skip aggregate refreshes for held reviews

    Product rating aggregates include only approved reviews. Refreshing them
    after storing rating meta for a held review cannot change the values but
    still saves the product and fires its save hooks.

    Keep the required post-meta refresh for approved creates and skip it when the
    persisted review is not approved.

    Refs #29906

    * docs: clarify the REST review aggregate scope

    The public route-level move belongs to wc/v3, while review creation and
    rating edits apply across the REST controller families. Describe those
    guarantees separately so the changelog does not overstate v1 or v2.

    Refs #29906

    * chore(phpstan): remove obsolete REST review suppression

    The new update guard narrows the prepared review before wp_update_comment(), so the full analysis now reports its previous ignore as unmatched.\n\nRemove the resolved baseline entry to keep the baseline shrinking.\n\nRefs #29906

    * fix(rest-api): handle zero review destinations

    A supplied zero product ID can orphan a review, while a filter-driven move to post zero can leave the source product counts stale.

    Reject zero destinations on wc/v3 and refresh the source whenever the updated comment was re-read with a different post ID.

    Refs #29906

    * fix(rest-api): reject malformed review metadata

    Public preprocess filters can supply non-array comment_meta values. Rated updates now index that value while preparing the atomic comment update, which would otherwise throw a TypeError.

    Return the existing REST update error before indexing malformed metadata so extensions cannot turn rated updates into fatal requests.

    Refs #29906

    * fix: Validate REST product review update inputs

    Preprocess filters may return scalar data, causing fatal errors in the update controllers. A zero destination can also resolve through the global post before detaching a review.

    Reject malformed filtered payloads at the controller boundary. Require positive destination IDs before resolving the product type.

    Refs #29906

    * fix(rest-api): recount products after filtered review moves

    WordPress selects its comment-count target before wp_update_comment_data runs. A filter that redirects the persisted review therefore caused the source to be counted twice while the destination remained stale.

    Capture Core's prepared target before updating, then recount each unique original or persisted product Core missed. Strengthen controller coverage around redirect moves, malformed filters, create saves, and order-independent recounts.

    Refs #29906

    * docs: Expand REST review aggregate changelog

    The existing entry described only wc/v3 review moves even though filter-driven moves can reach the shared wc/v1 and wc/v2 controller behavior.

    Describe aggregate updates consistently across the three REST controller versions.

    Refs #29906

    * fix: Preserve review update hook compatibility

diff --git a/plugins/woocommerce/changelog/29906-fix-rest-review-rating-recompute b/plugins/woocommerce/changelog/29906-fix-rest-review-rating-recompute
new file mode 100644
index 00000000000..9f74d6dae81
--- /dev/null
+++ b/plugins/woocommerce/changelog/29906-fix-rest-review-rating-recompute
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update product rating aggregates when REST API reviews are created, re-rated, or moved between products in wc/v1, wc/v2, and wc/v3.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
index 72bacd6bf30..3ecc4124312 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
@@ -307,6 +307,19 @@ class WC_REST_Product_Reviews_V1_Controller extends WC_REST_Controller {
 		update_comment_meta( $product_review_id, 'rating', ( ! empty( $request['rating'] ) ? $request['rating'] : '0' ) );

 		$product_review = get_comment( $product_review_id );
+
+		/*
+		 * Core has already updated wp_posts.comment_count, but the rating meta above was unavailable
+		 * during that update. Refresh the post-meta aggregates directly now that the meta is in place:
+		 * repeating wp_update_comment_count() would add count queries, post writes, edit hooks, and
+		 * deferral where an immediate post-meta aggregate refresh is required. Only an approved rated
+		 * review can change the aggregates, and the refresh targets the comment's own product because
+		 * a rest_pre_insert_product_review callback can send the review to a different one.
+		 */
+		if ( ! empty( $request['rating'] ) && $product_review instanceof WP_Comment && '1' === $product_review->comment_approved ) {
+			WC_Comments::clear_transients( (int) $product_review->comment_post_ID );
+		}
+
 		$this->update_additional_fields_for_object( $product_review, $request );

 		/**
@@ -343,18 +356,54 @@ class WC_REST_Product_Reviews_V1_Controller extends WC_REST_Controller {
 			return $review;
 		}

+		$original_product_id = (int) $review->comment_post_ID;
+
 		$prepared_review = $this->prepare_item_for_database( $request );
+		if ( is_wp_error( $prepared_review ) ) {
+			return $prepared_review;
+		}
+		if ( ! is_array( $prepared_review ) ) {
+			return new WP_Error( 'rest_product_review_failed_edit', __( 'Updating product review failed.', 'woocommerce' ), array( 'status' => 500 ) );
+		}

+		/*
+		 * Core stores update-side comment meta before it updates the comment count. Passing the
+		 * rating here lets WooCommerce's count callback see the new value in the same product save.
+		 */
+		if ( ! empty( $request['rating'] ) ) {
+			if ( array_key_exists( 'comment_meta', $prepared_review ) && ! is_array( $prepared_review['comment_meta'] ) ) {
+				return new WP_Error( 'rest_product_review_failed_edit', __( 'Updating product review failed.', 'woocommerce' ), array( 'status' => 500 ) );
+			}
+
+			$prepared_review['comment_meta']['rating'] = (int) $request['rating'];
+		}
+
+		$core_counted_product_id = (int) ( $prepared_review['comment_post_ID'] ?? $original_product_id );
+
+		/*
+		 * Zero means no comment-table row changed; false is the actual update failure. A rating-only
+		 * request legitimately returns zero while still storing comment_meta and refreshing counts.
+		 */
 		$updated = wp_update_comment( $prepared_review );
-		if ( 0 === $updated ) {
+		if ( false === $updated ) {
 			return new WP_Error( 'rest_product_review_failed_edit', __( 'Updating product review failed.', 'woocommerce' ), array( 'status' => 500 ) );
 		}

-		if ( ! empty( $request['rating'] ) ) {
-			update_comment_meta( $product_review_id, 'rating', $request['rating'] );
+		$product_review     = get_comment( $product_review_id );
+		$current_product_id = $product_review instanceof WP_Comment ? (int) $product_review->comment_post_ID : 0;
+
+		/*
+		 * Core chooses its count target before wp_update_comment_data can redirect the persisted review.
+		 * Refresh each affected product that Core did not count.
+		 */
+		if ( $product_review instanceof WP_Comment ) {
+			foreach ( array_unique( array( $original_product_id, $current_product_id ) ) as $product_id_to_count ) {
+				if ( $product_id_to_count !== $core_counted_product_id ) {
+					wp_update_comment_count( $product_id_to_count );
+				}
+			}
 		}

-		$product_review = get_comment( $product_review_id );
 		$this->update_additional_fields_for_object( $product_review, $request );

 		/**
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
index cccab8f099d..f959dcc559e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
@@ -478,6 +478,17 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {

 		$review = get_comment( $review_id );

+		/*
+		 * Core has already updated wp_posts.comment_count, but the rating meta above was unavailable
+		 * during that update. Refresh the post-meta aggregates directly now that the meta is in place:
+		 * repeating wp_update_comment_count() would add count queries, post writes, edit hooks, and
+		 * deferral where an immediate post-meta aggregate refresh is required. Only an approved rated
+		 * review can change the aggregates.
+		 */
+		if ( ! empty( $request['rating'] ) && $review instanceof WP_Comment && '1' === $review->comment_approved ) {
+			WC_Comments::clear_transients( (int) $review->comment_post_ID );
+		}
+
 		/**
 		 * Fires after a comment is created or updated via the REST API.
 		 *
@@ -536,6 +547,10 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {

 		$id = (int) $review->comment_ID;

+		// Captured before the update: $review still holds the pre-update comment, so this is the
+		// product the review currently belongs to.
+		$original_product_id = (int) $review->comment_post_ID;
+
 		if ( isset( $request['type'] ) && 'review' !== get_comment_type( $id ) ) {
 			return new WP_Error( 'woocommerce_rest_review_invalid_type', __( 'Sorry, you are not allowed to change the comment type.', 'woocommerce' ), array( 'status' => 404 ) );
 		}
@@ -544,13 +559,19 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {
 		if ( is_wp_error( $prepared_args ) ) {
 			return $prepared_args;
 		}
+		if ( ! is_array( $prepared_args ) ) {
+			return new WP_Error( 'woocommerce_rest_comment_failed_edit', __( 'Updating review failed.', 'woocommerce' ), array( 'status' => 500 ) );
+		}

-		if ( ! empty( $prepared_args['comment_post_ID'] ) ) {
-			if ( 'product' !== get_post_type( (int) $prepared_args['comment_post_ID'] ) ) {
+		if ( isset( $prepared_args['comment_post_ID'] ) ) {
+			$product_id = (int) $prepared_args['comment_post_ID'];
+			if ( 0 >= $product_id || 'product' !== get_post_type( $product_id ) ) {
 				return new WP_Error( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), array( 'status' => 404 ) );
 			}
 		}

+		$core_counted_product_id = (int) ( $prepared_args['comment_post_ID'] ?? $original_product_id );
+
 		if ( empty( $prepared_args ) && isset( $request['status'] ) ) {
 			// Only the comment status is being changed.
 			$change = $this->handle_status_param( $request['status'], $id );
@@ -559,10 +580,6 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {
 				return new WP_Error( 'woocommerce_rest_review_failed_edit', __( 'Updating review status failed.', 'woocommerce' ), array( 'status' => 500 ) );
 			}
 		} elseif ( ! empty( $prepared_args ) ) {
-			if ( is_wp_error( $prepared_args ) ) {
-				return $prepared_args;
-			}
-
 			if ( isset( $prepared_args['comment_content'] ) && empty( $prepared_args['comment_content'] ) ) {
 				return new WP_Error( 'woocommerce_rest_review_content_invalid', __( 'Invalid review content.', 'woocommerce' ), array( 'status' => 400 ) );
 			}
@@ -575,6 +592,18 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {
 				return new WP_Error( 'woocommerce_rest_' . $error_code, __( 'Product review field exceeds maximum length allowed.', 'woocommerce' ), array( 'status' => 400 ) );
 			}

+			/*
+			 * Core stores update-side comment meta before it updates the comment count. Keep this value
+			 * as an integer so wp_slash() below cannot change its shape.
+			 */
+			if ( ! empty( $request['rating'] ) ) {
+				if ( array_key_exists( 'comment_meta', $prepared_args ) && ! is_array( $prepared_args['comment_meta'] ) ) {
+					return new WP_Error( 'woocommerce_rest_comment_failed_edit', __( 'Updating review failed.', 'woocommerce' ), array( 'status' => 500 ) );
+				}
+
+				$prepared_args['comment_meta']['rating'] = (int) $request['rating'];
+			}
+
 			$updated = wp_update_comment( wp_slash( (array) $prepared_args ) );

 			if ( false === $updated ) {
@@ -586,16 +615,29 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {
 			}
 		}

-		if ( ! empty( $request['rating'] ) ) {
-			update_comment_meta( $id, 'rating', $request['rating'] );
+		// Re-read the comment because the same request can move the review to a different product.
+		$updated_review     = get_comment( $id );
+		$current_product_id = $updated_review instanceof WP_Comment ? (int) $updated_review->comment_post_ID : 0;
+
+		/*
+		 * Core chooses its count target before wp_update_comment_data can redirect the persisted review.
+		 * Refresh each affected product that Core did not count.
+		 */
+		if ( $updated_review instanceof WP_Comment ) {
+			foreach ( array_unique( array( $original_product_id, $current_product_id ) ) as $product_id_to_count ) {
+				if ( $product_id_to_count !== $core_counted_product_id ) {
+					wp_update_comment_count( $product_id_to_count );
+				}
+			}
 		}

+		$review = $updated_review;
+
 		if ( isset( $request['verified'] ) && ! empty( $request['verified'] ) ) {
 			update_comment_meta( $id, 'verified', $request['verified'] );
+			$review = get_comment( $id );
 		}

-		$review = get_comment( $id );
-
 		/** This action is documented in includes/api/class-wc-rest-product-reviews-controller.php */
 		do_action( 'woocommerce_rest_insert_product_review', $review, $request, false );

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index be52b24f566..c7c63c7af9b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -24222,12 +24222,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php

-		-
-			message: '#^Parameter \#1 \$commentarr of function wp_update_comment expects array, array\|WP_Error given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
-
 		-
 			message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, WP_Comment\|null given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/tests/e2e/tests/api-tests/orders/orders.test.ts b/plugins/woocommerce/tests/e2e/tests/api-tests/orders/orders.test.ts
index b32b8107043..55d025d758a 100644
--- a/plugins/woocommerce/tests/e2e/tests/api-tests/orders/orders.test.ts
+++ b/plugins/woocommerce/tests/e2e/tests/api-tests/orders/orders.test.ts
@@ -2005,18 +2005,6 @@ test.describe.serial( 'Orders API tests', () => {
 			);
 			const review1JSON = await review1.json();

-			// We need to update the review in order for the product's
-			// average_rating to be recalculated.
-			// See: https://github.com/woocommerce/woocommerce/issues/29906.
-			//await updateProductReview(review1.id);
-			await request.post(
-				`./wp-json/wc/v3/products/reviews/${ review1JSON.id }`,
-				{
-					data: {},
-					failOnStatusCode: true,
-				}
-			);
-
 			const review2 = await request.post(
 				'./wp-json/wc/v3/products/reviews',
 				{
@@ -2032,15 +2020,6 @@ test.describe.serial( 'Orders API tests', () => {
 			);
 			const review2JSON = await review2.json();

-			//await updateProductReview(review2.id);
-			await request.post(
-				`./wp-json/wc/v3/products/reviews/${ review2JSON.id }`,
-				{
-					data: {},
-					failOnStatusCode: true,
-				}
-			);
-
 			const review3 = await request.post(
 				'./wp-json/wc/v3/products/reviews',
 				{
@@ -2056,14 +2035,6 @@ test.describe.serial( 'Orders API tests', () => {
 			);
 			const review3JSON = await review3.json();

-			await request.post(
-				`./wp-json/wc/v3/products/reviews/${ review3JSON.id }`,
-				{
-					data: {},
-					failOnStatusCode: true,
-				}
-			);
-
 			return [ review1JSON.id, review2JSON.id, review3JSON.id ];
 		};

diff --git a/plugins/woocommerce/tests/e2e/tests/api-tests/products/product-list.test.ts b/plugins/woocommerce/tests/e2e/tests/api-tests/products/product-list.test.ts
index b9caac9d39a..5e9df462690 100644
--- a/plugins/woocommerce/tests/e2e/tests/api-tests/products/product-list.test.ts
+++ b/plugins/woocommerce/tests/e2e/tests/api-tests/products/product-list.test.ts
@@ -1992,16 +1992,6 @@ test.describe( 'Products API tests: List All Products', () => {
 			);
 			const review1JSON = await review1.json();

-			// We need to update the review in order for the product's
-			// average_rating to be recalculated.
-			// See: https://github.com/woocommerce/woocommerce/issues/29906.
-			await request.post(
-				`./wp-json/wc/v3/products/reviews/${ review1JSON.id }`,
-				{
-					data: {},
-				}
-			);
-
 			const review2 = await request.post(
 				'./wp-json/wc/v3/products/reviews',
 				{
@@ -2016,13 +2006,6 @@ test.describe( 'Products API tests: List All Products', () => {
 			);
 			const review2JSON = await review2.json();

-			await request.post(
-				`./wp-json/wc/v3/products/reviews/${ review2JSON.id }`,
-				{
-					data: {},
-				}
-			);
-
 			const review3 = await request.post(
 				'./wp-json/wc/v3/products/reviews',
 				{
@@ -2037,13 +2020,6 @@ test.describe( 'Products API tests: List All Products', () => {
 			);
 			const review3JSON = await review3.json();

-			await request.post(
-				`./wp-json/wc/v3/products/reviews/${ review3JSON.id }`,
-				{
-					data: {},
-				}
-			);
-
 			return [ review1JSON.id, review2JSON.id, review3JSON.id ];
 		};

diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts
index 635e082a4c6..974bf381921 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts
@@ -9,10 +9,12 @@ import {
 	BLOCK_THEME_SLUG,
 } from '@woocommerce/e2e-utils';

+// The Cap is the only seeded product in this bucket: its reviews are rated 1
+// and 2, averaging 1.5.
 const blockData = {
 	name: 'Filter by Rating',
 	slug: 'woocommerce/rating-filter',
-	urlSearchParamWhenFilterIsApplied: 'rating_filter=1',
+	urlSearchParamWhenFilterIsApplied: 'rating_filter=2',
 };

 const test = base.extend< { templateCompiler: TemplateCompiler } >( {
@@ -58,7 +60,7 @@ test.describe( `${ blockData.name } Block`, () => {
 		await editor.selectBlocks( stockFilter );

 		await expect(
-			editor.canvas.getByRole( 'checkbox', { name: 'Rated 1 out of 5' } )
+			editor.canvas.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
 		).toBeVisible();

 		await page.getByLabel( 'DropDown' ).click();
@@ -70,7 +72,7 @@ test.describe( `${ blockData.name } Block`, () => {
 		).toBeHidden();

 		await expect(
-			editor.canvas.getByRole( 'checkbox', { name: 'Rated 1 out of 5' } )
+			editor.canvas.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
 		).toBeHidden();

 		await expect( editor.canvas.getByRole( 'combobox' ) ).toBeVisible();
@@ -141,7 +143,7 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
 		await expect( products ).toHaveCount( 16 );

 		await expect(
-			page.getByRole( 'checkbox', { name: 'Rated 1 out of 5' } )
+			page.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
 		).toBeVisible();
 	} );

@@ -150,7 +152,7 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
 		page,
 	} ) => {
 		await page
-			.getByRole( 'checkbox', { name: 'Rated 1 out of 5' } )
+			.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
 			.click();

 		const legacyTemplate = await frontendUtils.getBlockByName(
@@ -189,7 +191,7 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {

 		await page.goto( '/shop' );
 		await page
-			.getByRole( 'checkbox', { name: 'Rated 1 out of 5' } )
+			.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
 			.click();

 		await expect( page ).toHaveURL(
@@ -231,7 +233,7 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {
 		await page.goto( '/shop' );

 		await page
-			.getByRole( 'checkbox', { name: 'Rated 1 out of 5' } )
+			.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
 			.click();
 		await page.getByRole( 'button', { name: 'Apply' } ).click();

diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller-tests.php
index a5bacaa6852..08f5157fa1b 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller-tests.php
@@ -170,4 +170,454 @@ class WC_REST_Product_Reviews_V1_Controller_Tests extends WC_Unit_Test_Case {
 			'Comments that are not product reviews (including other types of comments belonging to products) cannot be deleted via this endpoint.'
 		);
 	}
+
+	/**
+	 * @testdox Creating a review updates the product rating aggregates within the same request.
+	 */
+	public function test_create_item_updates_the_product_rating_aggregates() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$response = $this->create_review( $product_id, 'Holds up to daily use.', 5 );
+		$this->assertEquals( 201, $response->get_status(), 'The review is created successfully.' );
+
+		$product = wc_get_product( $product_id );
+		$this->assertEquals( 5, $product->get_average_rating(), 'The average rating includes the review created in the same request.' );
+		$this->assertEquals( array( 5 => 1 ), $product->get_rating_counts(), 'The rating counts include the review created in the same request.' );
+		$this->assertEquals( 1, $product->get_review_count(), 'The review count includes the review created in the same request.' );
+	}
+
+	/**
+	 * @testdox Creating a held review does not save the product after rating meta is written.
+	 */
+	public function test_create_item_does_not_save_the_product_for_a_review_held_for_moderation() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$hold_review = function ( $prepared_review ) {
+			$prepared_review['comment_approved'] = 0;
+			return $prepared_review;
+		};
+		$saves       = 0;
+		$count_save  = function ( $updated_product_id ) use ( &$saves, $product_id ) {
+			if ( $product_id === (int) $updated_product_id ) {
+				++$saves;
+			}
+		};
+
+		add_filter( 'rest_pre_insert_product_review', $hold_review );
+		add_action( 'woocommerce_update_product', $count_save );
+		try {
+			$this->create_review( $product_id, 'Waiting for moderation.', 4 );
+		} finally {
+			remove_action( 'woocommerce_update_product', $count_save );
+			remove_filter( 'rest_pre_insert_product_review', $hold_review );
+		}
+
+		$this->assertSame( 0, $saves );
+		$this->assertEquals( 0, wc_get_product( $product_id )->get_average_rating() );
+	}
+
+	/**
+	 * @testdox Creating an unrated review saves the product only for Core's comment-count update.
+	 */
+	public function test_create_item_without_a_rating_saves_the_product_once() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$saves      = 0;
+		$count_save = function ( $updated_product_id ) use ( &$saves, $product_id ) {
+			if ( $product_id === (int) $updated_product_id ) {
+				++$saves;
+			}
+		};
+
+		add_action( 'woocommerce_update_product', $count_save );
+		try {
+			$response = $this->create_review( $product_id, 'Arrived on time.', null );
+		} finally {
+			remove_action( 'woocommerce_update_product', $count_save );
+		}
+
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertSame( 1, $saves, 'An unrated review does not trigger a second aggregate refresh.' );
+
+		$product = wc_get_product( $product_id );
+		$this->assertEquals( 0, $product->get_average_rating() );
+		$this->assertSame( array(), $product->get_rating_counts() );
+		$this->assertEquals( 1, $product->get_review_count() );
+	}
+
+	/**
+	 * @testdox A rating-only edit stores the rating and refreshes aggregates in one product save.
+	 */
+	public function test_update_item_accepts_an_edit_that_changes_only_the_rating() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$this->create_review( $product_id, 'Holds up to daily use.', 5 );
+		$review_id = $this->create_review( $product_id, 'Fell apart in a week.', 1 )->get_data()['id'];
+
+		$saves      = 0;
+		$count_save = function ( $updated_product_id ) use ( &$saves, $product_id ) {
+			if ( $product_id === (int) $updated_product_id ) {
+				++$saves;
+			}
+		};
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $product_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $product_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'rating', 3 );
+
+		add_action( 'woocommerce_update_product', $count_save );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_action( 'woocommerce_update_product', $count_save );
+		}
+
+		$this->assertNotWPError( $response );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 3, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertSame( 1, $saves, 'The core count callback sees the new rating, so no second save is needed.' );
+
+		$product = wc_get_product( $product_id );
+		$this->assertEquals( 4, $product->get_average_rating(), 'The average rating reflects the new rating, not the one it replaced.' );
+		$this->assertEquals(
+			array(
+				3 => 1,
+				5 => 1,
+			),
+			$product->get_rating_counts(),
+			'The rating counts drop the replaced rating.'
+		);
+	}
+
+	/**
+	 * @testdox A genuine comment update failure returns an error and does not write the rating.
+	 */
+	public function test_update_item_does_not_write_rating_after_a_comment_update_failure() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+		$review_id  = $this->create_review( $product_id, 'Original review.', 1 )->get_data()['id'];
+
+		$fail_update = static function () {
+			return new WP_Error( 'forced_comment_update_failure' );
+		};
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $product_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $product_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'review', 'This must not be stored.' );
+		$request->set_param( 'rating', 3 );
+
+		add_filter( 'wp_update_comment_data', $fail_update );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_filter( 'wp_update_comment_data', $fail_update );
+		}
+
+		$this->assertWPError( $response );
+		$this->assertSame( 'rest_product_review_failed_edit', $response->get_error_code() );
+		$this->assertSame( 'Original review.', get_comment( $review_id )->comment_content );
+		$this->assertSame( 1, (int) get_comment_meta( $review_id, 'rating', true ) );
+	}
+
+	/**
+	 * @testdox A WP_Error from the preprocess filter is returned unchanged.
+	 */
+	public function test_update_item_returns_filtered_wp_error_unchanged() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+		$review_id  = $this->create_review( $product_id, 'Original review.', 5 )->get_data()['id'];
+
+		$filtered_error = new WP_Error( 'filtered_product_review_error', 'The filter rejected this review.' );
+		$return_error   = static function () use ( $filtered_error ) {
+			return $filtered_error;
+		};
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $product_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $product_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'rating', 3 );
+
+		add_filter( 'rest_preprocess_product_review', $return_error );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_filter( 'rest_preprocess_product_review', $return_error );
+		}
+
+		$this->assertSame( $filtered_error, $response, 'The controller preserves the filter error object.' );
+		$this->assertSame( 5, (int) get_comment_meta( $review_id, 'rating', true ) );
+	}
+
+	/**
+	 * @testdox A zero rating remains a successful no-op.
+	 */
+	public function test_update_item_keeps_zero_rating_as_a_no_op() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+		$review_id  = $this->create_review( $product_id, 'Still five stars.', 5 )->get_data()['id'];
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $product_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $product_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'rating', 0 );
+
+		$response = $this->sut->update_item( $request );
+
+		$this->assertNotWPError( $response );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 5, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertEquals( 5, wc_get_product( $product_id )->get_average_rating() );
+	}
+
+	/**
+	 * Provides malformed review values returned by the preprocess filter.
+	 *
+	 * @return array<string, array{callable}> Malformed review callbacks.
+	 */
+	public function data_provider_for_test_update_item_rejects_malformed_filtered_review(): array {
+		return array(
+			'scalar review'       => array(
+				static function () {
+					return 'not-an-array';
+				},
+			),
+			'scalar comment meta' => array(
+				static function ( $prepared_review ) {
+					$prepared_review['comment_meta'] = 'not-an-array';
+					return $prepared_review;
+				},
+			),
+		);
+	}
+
+	/**
+	 * @testdox A malformed review from the preprocess filter returns an update error.
+	 * @dataProvider data_provider_for_test_update_item_rejects_malformed_filtered_review
+	 *
+	 * @param callable $filter_callback Callback that returns a malformed review value.
+	 */
+	public function test_update_item_rejects_malformed_filtered_review( callable $filter_callback ) {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id            = ProductHelper::create_simple_product()->get_id();
+		$review_id             = $this->create_review( $product_id, 'Still five stars.', 5 )->get_data()['id'];
+		$average_rating_before = wc_get_product( $product_id )->get_average_rating();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $product_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $product_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'rating', 3 );
+
+		add_filter( 'rest_preprocess_product_review', $filter_callback );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_filter( 'rest_preprocess_product_review', $filter_callback );
+		}
+
+		$this->assertWPError( $response );
+		$this->assertSame( 'rest_product_review_failed_edit', $response->get_error_code() );
+		$this->assertSame( 5, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertSame( $average_rating_before, wc_get_product( $product_id )->get_average_rating() );
+	}
+
+	/**
+	 * @testdox Creating a review updates the aggregates of the product the review was written to.
+	 */
+	public function test_create_item_updates_the_aggregates_of_the_product_the_review_was_written_to() {
+		wp_set_current_user( $this->shop_manager_id );
+		$requested_product_id = ProductHelper::create_simple_product()->get_id();
+		$written_product_id   = ProductHelper::create_simple_product()->get_id();
+
+		// Third party code can send the review to a different product than the request named.
+		$send_to_other_product = function ( $prepared_review ) use ( $written_product_id ) {
+			$prepared_review['comment_post_ID'] = $written_product_id;
+			return $prepared_review;
+		};
+
+		add_filter( 'rest_pre_insert_product_review', $send_to_other_product );
+		try {
+			$this->create_review( $requested_product_id, 'Holds up to daily use.', 5 );
+		} finally {
+			remove_filter( 'rest_pre_insert_product_review', $send_to_other_product );
+		}
+
+		$written_product = wc_get_product( $written_product_id );
+		$this->assertEquals( 5, $written_product->get_average_rating(), 'The product the review was written to has the new average rating.' );
+		$this->assertEquals( array( 5 => 1 ), $written_product->get_rating_counts(), 'The product the review was written to has the new rating counts.' );
+		$this->assertEquals( 1, $written_product->get_review_count(), 'The product the review was written to counts the review.' );
+	}
+
+	/**
+	 * @testdox A filter-driven move refreshes both products and their core comment counts.
+	 */
+	public function test_update_item_recalculates_both_products_when_a_filter_moves_the_review() {
+		wp_set_current_user( $this->shop_manager_id );
+		$source_id      = ProductHelper::create_simple_product()->get_id();
+		$destination_id = ProductHelper::create_simple_product()->get_id();
+
+		$this->create_review( $destination_id, 'Already here.', 1 );
+		$review_id = $this->create_review( $source_id, 'Starts at five.', 5 )->get_data()['id'];
+
+		$send_to_destination = function ( $prepared_review ) use ( $destination_id ) {
+			$prepared_review['comment_post_ID'] = $destination_id;
+			return $prepared_review;
+		};
+		$counted_products    = array();
+		$record_count        = function ( $post_id ) use ( &$counted_products ) {
+			$counted_products[] = (int) $post_id;
+		};
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $source_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $source_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'rating', 2 );
+
+		add_filter( 'rest_preprocess_product_review', $send_to_destination );
+		add_action( 'wp_update_comment_count', $record_count );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_action( 'wp_update_comment_count', $record_count );
+			remove_filter( 'rest_preprocess_product_review', $send_to_destination );
+		}
+
+		$this->assertNotWPError( $response );
+		$this->assertSame( $destination_id, (int) get_comment( $review_id )->comment_post_ID );
+		$this->assertSame( 2, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertEqualsCanonicalizing( array( $source_id, $destination_id ), $counted_products );
+
+		$source = wc_get_product( $source_id );
+		$this->assertEquals( 0, $source->get_average_rating() );
+		$this->assertSame( array(), $source->get_rating_counts() );
+		$this->assertEquals( 0, $source->get_review_count() );
+
+		$destination = wc_get_product( $destination_id );
+		$this->assertEquals( 1.5, $destination->get_average_rating() );
+		$this->assertEquals(
+			array(
+				1 => 1,
+				2 => 1,
+			),
+			$destination->get_rating_counts()
+		);
+		$this->assertEquals( 2, $destination->get_review_count() );
+
+		clean_post_cache( $source_id );
+		clean_post_cache( $destination_id );
+		$this->assertSame( 0, (int) get_post( $source_id )->comment_count );
+		$this->assertSame( 2, (int) get_post( $destination_id )->comment_count );
+	}
+
+	/**
+	 * @testdox A Core filter redirect recounts the original and persisted products exactly once.
+	 */
+	public function test_update_item_recounts_both_products_when_wp_update_comment_data_moves_the_review() {
+		wp_set_current_user( $this->shop_manager_id );
+		$source_id      = ProductHelper::create_simple_product()->get_id();
+		$destination_id = ProductHelper::create_simple_product()->get_id();
+		$review_id      = $this->create_review( $source_id, 'Starts at five.', 5 )->get_data()['id'];
+
+		$redirect_review  = static function ( $comment_data ) use ( $destination_id ) {
+			$comment_data['comment_post_ID'] = $destination_id;
+			return $comment_data;
+		};
+		$counted_products = array();
+		$record_count     = static function ( $post_id ) use ( &$counted_products ) {
+			$counted_products[] = (int) $post_id;
+		};
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $source_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $source_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'review', 'Moved by a Core filter.' );
+
+		add_filter( 'wp_update_comment_data', $redirect_review );
+		add_action( 'wp_update_comment_count', $record_count );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_action( 'wp_update_comment_count', $record_count );
+			remove_filter( 'wp_update_comment_data', $redirect_review );
+		}
+
+		$this->assertNotWPError( $response );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $destination_id, (int) get_comment( $review_id )->comment_post_ID );
+		$this->assertEqualsCanonicalizing( array( $source_id, $destination_id ), $counted_products );
+
+		$source = wc_get_product( $source_id );
+		$this->assertEquals( 0, $source->get_average_rating() );
+		$this->assertEquals( 0, $source->get_review_count() );
+
+		$destination = wc_get_product( $destination_id );
+		$this->assertEquals( 5, $destination->get_average_rating() );
+		$this->assertEquals( 1, $destination->get_review_count() );
+
+		clean_post_cache( $source_id );
+		clean_post_cache( $destination_id );
+		$this->assertSame( 0, (int) get_post( $source_id )->comment_count );
+		$this->assertSame( 1, (int) get_post( $destination_id )->comment_count );
+	}
+
+	/**
+	 * @testdox A filter-driven move to post zero still refreshes the source product.
+	 */
+	public function test_update_item_refreshes_the_source_when_a_filter_moves_the_review_to_post_zero() {
+		wp_set_current_user( $this->shop_manager_id );
+		$source_id = ProductHelper::create_simple_product()->get_id();
+		$review_id = $this->create_review( $source_id, 'Starts at five.', 5 )->get_data()['id'];
+
+		$send_to_post_zero = static function ( $prepared_review ) {
+			$prepared_review['comment_post_ID'] = 0;
+			return $prepared_review;
+		};
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/products/' . $source_id . '/reviews/' . $review_id );
+		$request->set_param( 'product_id', $source_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'rating', 2 );
+
+		add_filter( 'rest_preprocess_product_review', $send_to_post_zero );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_filter( 'rest_preprocess_product_review', $send_to_post_zero );
+		}
+
+		$this->assertNotWPError( $response );
+		$this->assertSame( 0, (int) get_comment( $review_id )->comment_post_ID );
+		$this->assertEquals( 0, wc_get_product( $source_id )->get_average_rating() );
+		$this->assertEquals( 0, wc_get_product( $source_id )->get_review_count() );
+
+		clean_post_cache( $source_id );
+		$this->assertSame( 0, (int) get_post( $source_id )->comment_count );
+	}
+
+	/**
+	 * Creates a product review through the controller.
+	 *
+	 * @param int      $product_id ID of the product being reviewed.
+	 * @param string   $content    Review content.
+	 * @param int|null $rating     Rating to submit, or null to omit the rating field.
+	 * @return WP_REST_Response
+	 */
+	private function create_review( int $product_id, string $content, ?int $rating ) {
+		$request = new WP_REST_Request( 'POST', '/wc/v1/products/' . $product_id . '/reviews' );
+		$request->set_param( 'product_id', $product_id );
+		$request->set_param( 'review', $content );
+		$request->set_param( 'name', 'Jane Smith' );
+		$request->set_param( 'email', 'jane.smith@example.org' );
+
+		if ( null !== $rating ) {
+			$request->set_param( 'rating', $rating );
+		}
+
+		return $this->sut->create_item( $request );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-product-reviews-v2-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-product-reviews-v2-controller-tests.php
index 592fadf31b3..6c063daf6d0 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-product-reviews-v2-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-product-reviews-v2-controller-tests.php
@@ -1,5 +1,7 @@
 <?php

+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
+
 /**
  * Tests relating to the Product Reviews controller in APIv2.
  */
@@ -46,4 +48,41 @@ class WC_REST_Product_Reviews_V2_Controller_Test extends WC_REST_Unit_Test_case
 			'A user (such as a shop manager) who has the edit_products permission can perform batch requests for product reviews.'
 		);
 	}
+
+	/**
+	 * @testdox The wc/v2 route accepts rating-only updates and keeps zero as a no-op.
+	 */
+	public function test_wc_v2_route_handles_rating_only_updates() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$create = new WP_REST_Request( 'POST', '/wc/v2/products/' . $product_id . '/reviews' );
+		$create->set_body_params(
+			array(
+				'review' => 'A v2 review.',
+				'name'   => 'Jane Smith',
+				'email'  => 'jane.smith@example.org',
+				'rating' => 5,
+			)
+		);
+		$created = $this->server->dispatch( $create );
+
+		$this->assertSame( 201, $created->get_status() );
+		$review_id = $created->get_data()['id'];
+
+		$update = new WP_REST_Request( 'PUT', '/wc/v2/products/' . $product_id . '/reviews/' . $review_id );
+		$update->set_body_params( array( 'rating' => 3 ) );
+		$updated = $this->server->dispatch( $update );
+
+		$this->assertSame( 200, $updated->get_status() );
+		$this->assertSame( 3, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertEquals( 3, wc_get_product( $product_id )->get_average_rating() );
+
+		$zero = new WP_REST_Request( 'PUT', '/wc/v2/products/' . $product_id . '/reviews/' . $review_id );
+		$zero->set_body_params( array( 'rating' => 0 ) );
+		$zero_response = $this->server->dispatch( $zero );
+
+		$this->assertSame( 200, $zero_response->get_status() );
+		$this->assertSame( 3, (int) get_comment_meta( $review_id, 'rating', true ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller-tests.php
index 26facca1553..90ea473cdd8 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller-tests.php
@@ -200,4 +200,436 @@ class WC_REST_Product_Reviews_Controller_Tests extends WC_REST_Unit_Test_Case {
 			'Comments that are not product reviews (including other types of comments belonging to products) cannot be deleted via this endpoint.'
 		);
 	}
+
+	/**
+	 * @testdox Creating each review updates the product rating aggregates within the same request.
+	 */
+	public function test_create_item_updates_the_product_rating_aggregates_after_every_review() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$response = $this->create_review( $product_id, 'Holds up to daily use.', 5 );
+		$this->assertEquals( 201, $response->get_status(), 'The first review is created successfully.' );
+
+		$product = wc_get_product( $product_id );
+		$this->assertEquals( 5, $product->get_average_rating(), 'The average includes the first review immediately.' );
+		$this->assertEquals( array( 5 => 1 ), $product->get_rating_counts(), 'The rating counts include the first review immediately.' );
+		$this->assertEquals( 1, $product->get_review_count(), 'The first review is counted immediately.' );
+
+		$this->create_review( $product_id, 'Fell apart in a week.', 1 );
+
+		$product = wc_get_product( $product_id );
+		$this->assertEquals( 3, $product->get_average_rating(), 'The average is refreshed after a later review.' );
+		$this->assertEquals(
+			array(
+				1 => 1,
+				5 => 1,
+			),
+			$product->get_rating_counts(),
+			'The rating counts include both reviews.'
+		);
+		$this->assertEquals( 2, $product->get_review_count(), 'Both reviews are counted.' );
+	}
+
+	/**
+	 * @testdox Creating a rated review on hold does not save the product.
+	 */
+	public function test_create_item_does_not_save_the_product_for_a_held_status() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$saves      = 0;
+		$count_save = function ( $updated_product_id ) use ( &$saves, $product_id ) {
+			if ( $product_id === (int) $updated_product_id ) {
+				++$saves;
+			}
+		};
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/reviews' );
+		$request->set_body_params(
+			array(
+				'product_id'     => $product_id,
+				'review'         => 'Waiting for moderation.',
+				'reviewer'       => 'Jane Smith',
+				'reviewer_email' => 'jane.smith@example.org',
+				'rating'         => 4,
+				'status'         => 'hold',
+			)
+		);
+
+		add_action( 'woocommerce_update_product', $count_save );
+		try {
+			$response = $this->server->dispatch( $request );
+		} finally {
+			remove_action( 'woocommerce_update_product', $count_save );
+		}
+
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertSame( 0, $saves );
+		$this->assertEquals( 0, wc_get_product( $product_id )->get_average_rating() );
+	}
+
+	/**
+	 * @testdox Creating an unrated review skips the extra aggregate refresh.
+	 */
+	public function test_create_item_without_a_rating_skips_the_extra_product_save() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$this->create_review( $product_id, 'Holds up to daily use.', 4 );
+
+		$saves      = 0;
+		$count_save = function ( $updated_product_id ) use ( &$saves, $product_id ) {
+			if ( $product_id === (int) $updated_product_id ) {
+				++$saves;
+			}
+		};
+
+		add_action( 'woocommerce_update_product', $count_save );
+		try {
+			$this->create_review( $product_id, 'Arrived on time.', null );
+		} finally {
+			remove_action( 'woocommerce_update_product', $count_save );
+		}
+
+		$this->assertSame( 1, $saves, 'An unrated review does not trigger a second aggregate refresh.' );
+
+		$product = wc_get_product( $product_id );
+		$this->assertEquals( 4, $product->get_average_rating(), 'An unrated review does not affect the average.' );
+		$this->assertEquals( array( 4 => 1 ), $product->get_rating_counts(), 'An unrated review is not counted as a rating.' );
+		$this->assertEquals( 2, $product->get_review_count(), 'An unrated review is still counted as a review.' );
+	}
+
+	/**
+	 * @testdox A rating-only edit stores the rating and refreshes aggregates in one product save.
+	 */
+	public function test_update_item_with_only_a_rating_recalculates_aggregates_in_one_product_save() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+
+		$this->create_review( $product_id, 'Holds up to daily use.', 5 );
+		$review_id = $this->create_review( $product_id, 'Fell apart in a week.', 1 )->get_data()['id'];
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_body_params( array( 'rating' => 3 ) );
+
+		$saves      = 0;
+		$count_save = function ( $updated_product_id ) use ( &$saves, $product_id ) {
+			if ( $product_id === (int) $updated_product_id ) {
+				++$saves;
+			}
+		};
+
+		add_action( 'woocommerce_update_product', $count_save );
+		try {
+			$response = $this->server->dispatch( $request );
+		} finally {
+			remove_action( 'woocommerce_update_product', $count_save );
+		}
+
+		$this->assertEquals( 200, $response->get_status(), 'The review is updated successfully.' );
+		$this->assertSame( 3, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertSame( 1, $saves, 'The core count callback sees the new rating, so no second save is needed.' );
+
+		$product = wc_get_product( $product_id );
+		$this->assertEquals( 4, $product->get_average_rating(), 'The average rating reflects the new rating, not the one it replaced.' );
+		$this->assertEquals(
+			array(
+				3 => 1,
+				5 => 1,
+			),
+			$product->get_rating_counts(),
+			'The rating counts drop the replaced rating.'
+		);
+	}
+
+	/**
+	 * @testdox A zero rating remains a successful no-op in wc/v3.
+	 */
+	public function test_update_item_keeps_zero_rating_as_a_no_op() {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id = ProductHelper::create_simple_product()->get_id();
+		$review_id  = $this->create_review( $product_id, 'Still five stars.', 5 )->get_data()['id'];
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_body_params( array( 'rating' => 0 ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 5, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertEquals( 5, wc_get_product( $product_id )->get_average_rating() );
+	}
+
+	/**
+	 * Provides malformed review values returned by the preprocess filter.
+	 *
+	 * @return array<string, array{callable}> Malformed review callbacks.
+	 */
+	public function data_provider_for_test_update_item_rejects_malformed_filtered_review(): array {
+		return array(
+			'scalar review'       => array(
+				static function () {
+					return 'not-an-array';
+				},
+			),
+			'scalar comment meta' => array(
+				static function ( $prepared_review ) {
+					$prepared_review['comment_meta'] = 'not-an-array';
+					return $prepared_review;
+				},
+			),
+		);
+	}
+
+	/**
+	 * @testdox A malformed review from the preprocess filter returns an update error.
+	 * @dataProvider data_provider_for_test_update_item_rejects_malformed_filtered_review
+	 *
+	 * @param callable $filter_callback Callback that returns a malformed review value.
+	 */
+	public function test_update_item_rejects_malformed_filtered_review( callable $filter_callback ) {
+		wp_set_current_user( $this->shop_manager_id );
+		$product_id            = ProductHelper::create_simple_product()->get_id();
+		$review_id             = $this->create_review( $product_id, 'Still five stars.', 5 )->get_data()['id'];
+		$average_rating_before = wc_get_product( $product_id )->get_average_rating();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_param( 'id', $review_id );
+		$request->set_param( 'rating', 3 );
+
+		add_filter( 'woocommerce_rest_preprocess_product_review', $filter_callback );
+		try {
+			$response = $this->sut->update_item( $request );
+		} finally {
+			remove_filter( 'woocommerce_rest_preprocess_product_review', $filter_callback );
+		}
+
+		$this->assertWPError( $response );
+		$this->assertSame( 'woocommerce_rest_comment_failed_edit', $response->get_error_code() );
+		$this->assertSame( 5, (int) get_comment_meta( $review_id, 'rating', true ) );
+		$this->assertSame( $average_rating_before, wc_get_product( $product_id )->get_average_rating() );
+	}
+
+	/**
+	 * @testdox Moving a review to another product updates the aggregates of both products.
+	 */
+	public function test_update_item_recalculates_the_aggregates_of_both_products_when_the_review_moves() {
+		wp_set_current_user( $this->shop_manager_id );
+		list( $source_id, $destination_id, $review_id ) = $this->create_review_move_fixture( 5 );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_body_params(
+			array(
+				'product_id' => $destination_id,
+				'rating'     => 3,
+			)
+		);
+		$counted_products = array();
+		$record_count     = function ( $post_id ) use ( &$counted_products ) {
+			$counted_products[] = (int) $post_id;
+		};
+
+		add_action( 'wp_update_comment_count', $record_count );
+		try {
+			$response = $this->server->dispatch( $request );
+		} finally {
+			remove_action( 'wp_update_comment_count', $record_count );
+		}
+		$this->assertEquals( 200, $response->get_status(), 'The review is moved successfully.' );
+		$this->assertEqualsCanonicalizing( array( $source_id, $destination_id ), $counted_products );
+
+		$source = wc_get_product( $source_id );
+		$this->assertEquals( 0, $source->get_average_rating(), 'The product the review left no longer counts its rating.' );
+		$this->assertEquals( array(), $source->get_rating_counts(), 'The product the review left no longer counts it in the rating counts.' );
+		$this->assertEquals( 0, $source->get_review_count(), 'The product the review left no longer counts it as a review.' );
+
+		$destination = wc_get_product( $destination_id );
+		$this->assertEquals( 3, $destination->get_average_rating(), 'The product the review moved to picks up the new rating.' );
+		$this->assertEquals( array( 3 => 1 ), $destination->get_rating_counts(), 'The product the review moved to picks up the rating counts.' );
+		$this->assertEquals( 1, $destination->get_review_count(), 'The product the review moved to counts the review.' );
+
+		clean_post_cache( $source_id );
+		clean_post_cache( $destination_id );
+		$this->assertSame( 0, (int) get_post( $source_id )->comment_count );
+		$this->assertSame( 1, (int) get_post( $destination_id )->comment_count );
+	}
+
+	/**
+	 * @testdox A Core filter redirect recounts the original and persisted products exactly once.
+	 */
+	public function test_update_item_recounts_both_products_when_wp_update_comment_data_moves_the_review() {
+		wp_set_current_user( $this->shop_manager_id );
+		list( $source_id, $destination_id, $review_id ) = $this->create_review_move_fixture( 5 );
+
+		$redirect_review  = static function ( $comment_data ) use ( $destination_id ) {
+			$comment_data['comment_post_ID'] = $destination_id;
+			return $comment_data;
+		};
+		$counted_products = array();
+		$record_count     = static function ( $post_id ) use ( &$counted_products ) {
+			$counted_products[] = (int) $post_id;
+		};
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_body_params( array( 'review' => 'Moved by a Core filter.' ) );
+
+		add_filter( 'wp_update_comment_data', $redirect_review );
+		add_action( 'wp_update_comment_count', $record_count );
+		try {
+			$response = $this->server->dispatch( $request );
+		} finally {
+			remove_action( 'wp_update_comment_count', $record_count );
+			remove_filter( 'wp_update_comment_data', $redirect_review );
+		}
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $destination_id, (int) get_comment( $review_id )->comment_post_ID );
+		$this->assertEqualsCanonicalizing( array( $source_id, $destination_id ), $counted_products );
+
+		$source = wc_get_product( $source_id );
+		$this->assertEquals( 0, $source->get_average_rating() );
+		$this->assertEquals( 0, $source->get_review_count() );
+
+		$destination = wc_get_product( $destination_id );
+		$this->assertEquals( 5, $destination->get_average_rating() );
+		$this->assertEquals( 1, $destination->get_review_count() );
+
+		clean_post_cache( $source_id );
+		clean_post_cache( $destination_id );
+		$this->assertSame( 0, (int) get_post( $source_id )->comment_count );
+		$this->assertSame( 1, (int) get_post( $destination_id )->comment_count );
+	}
+
+	/**
+	 * @testdox A supplied zero product ID is rejected without moving the review.
+	 */
+	public function test_update_item_rejects_a_zero_product_id() {
+		global $post;
+
+		wp_set_current_user( $this->shop_manager_id );
+		$source_id = ProductHelper::create_simple_product()->get_id();
+		$review_id = $this->create_review( $source_id, 'Stays with its product.', 5 )->get_data()['id'];
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_body_params( array( 'product_id' => 0 ) );
+
+		$average_rating_before = wc_get_product( $source_id )->get_average_rating();
+
+		$previous_post = $post;
+		$post          = get_post( $source_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Make a zero ID resolve through Core's global-post fallback if the explicit zero-ID guard regresses.
+		try {
+			$response = $this->server->dispatch( $request );
+		} finally {
+			$post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the global after exercising the guard.
+		}
+
+		$this->assertSame( 404, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_product_invalid_id', $response->get_data()['code'] );
+		$this->assertSame( $source_id, (int) get_comment( $review_id )->comment_post_ID );
+		$this->assertSame( $average_rating_before, wc_get_product( $source_id )->get_average_rating() );
+		$this->assertEquals( 1, wc_get_product( $source_id )->get_review_count() );
+
+		clean_post_cache( $source_id );
+		$this->assertSame( 1, (int) get_post( $source_id )->comment_count );
+	}
+
+	/**
+	 * @testdox Moving a review without changing its rating still updates the aggregates of both products.
+	 */
+	public function test_update_item_recalculates_the_aggregates_when_the_review_moves_without_a_rating_change() {
+		wp_set_current_user( $this->shop_manager_id );
+		list( $source_id, $destination_id, $review_id ) = $this->create_review_move_fixture( 4 );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_body_params( array( 'product_id' => $destination_id ) );
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status(), 'The review is moved successfully.' );
+
+		$source = wc_get_product( $source_id );
+		$this->assertEquals( 0, $source->get_average_rating(), 'The product the review left drops it even though the rating did not change.' );
+		$this->assertEquals( 0, $source->get_review_count(), 'The product the review left no longer counts it as a review.' );
+
+		$destination = wc_get_product( $destination_id );
+		$this->assertEquals( 4, $destination->get_average_rating(), 'The product the review moved to keeps the existing rating.' );
+		$this->assertEquals( 1, $destination->get_review_count(), 'The product the review moved to counts the review.' );
+
+		clean_post_cache( $source_id );
+		clean_post_cache( $destination_id );
+		$this->assertSame( 0, (int) get_post( $source_id )->comment_count );
+		$this->assertSame( 1, (int) get_post( $destination_id )->comment_count );
+	}
+
+	/**
+	 * @testdox Moving a review honours deferred comment counting for both products.
+	 */
+	public function test_update_item_defers_both_products_when_a_review_moves() {
+		wp_set_current_user( $this->shop_manager_id );
+		list( $source_id, $destination_id, $review_id ) = $this->create_review_move_fixture( 4 );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/reviews/' . $review_id );
+		$request->set_body_params( array( 'product_id' => $destination_id ) );
+
+		wp_defer_comment_counting( true );
+		try {
+			$response = $this->server->dispatch( $request );
+			$this->assertSame( 200, $response->get_status() );
+
+			clean_post_cache( $source_id );
+			clean_post_cache( $destination_id );
+			$this->assertSame( 1, (int) get_post( $source_id )->comment_count );
+			$this->assertSame( 0, (int) get_post( $destination_id )->comment_count );
+		} finally {
+			wp_defer_comment_counting( false );
+		}
+
+		clean_post_cache( $source_id );
+		clean_post_cache( $destination_id );
+		$this->assertSame( 0, (int) get_post( $source_id )->comment_count );
+		$this->assertSame( 1, (int) get_post( $destination_id )->comment_count );
+
+		$source      = wc_get_product( $source_id );
+		$destination = wc_get_product( $destination_id );
+		$this->assertEquals( 0, $source->get_average_rating() );
+		$this->assertEquals( 4, $destination->get_average_rating() );
+	}
+
+	/**
+	 * Creates two products and a review that can be moved between them.
+	 *
+	 * @param int $rating Rating for the review.
+	 * @return array{int, int, int} Source product ID, destination product ID, and review ID.
+	 */
+	private function create_review_move_fixture( int $rating ): array {
+		$source_id      = ProductHelper::create_simple_product()->get_id();
+		$destination_id = ProductHelper::create_simple_product()->get_id();
+		$review_id      = $this->create_review( $source_id, 'Holds up to daily use.', $rating )->get_data()['id'];
+
+		return array( $source_id, $destination_id, $review_id );
+	}
+
+	/**
+	 * Creates a product review through the REST API.
+	 *
+	 * @param int      $product_id ID of the product being reviewed.
+	 * @param string   $content    Review content. Must differ between reviews, as WordPress rejects duplicates.
+	 * @param int|null $rating     Rating to submit, or null to omit the rating field.
+	 * @return WP_REST_Response
+	 */
+	private function create_review( int $product_id, string $content, ?int $rating ) {
+		$body = array(
+			'product_id'     => $product_id,
+			'review'         => $content,
+			'reviewer'       => 'Jane Smith',
+			'reviewer_email' => 'jane.smith@example.org',
+		);
+
+		if ( null !== $rating ) {
+			$body['rating'] = $rating;
+		}
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/reviews' );
+		$request->set_body_params( $body );
+
+		return $this->server->dispatch( $request );
+	}
 }