Commit 0ada82c55e1 for woocommerce

commit 0ada82c55e135d692575062ece6598e327fcd347
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Wed Aug 5 16:51:13 2026 +0300

    Preserve variation IDs in partial REST order updates (#67343)

    * Fix partial REST updates clearing variation IDs

    * Add changelog entry for REST variation preservation

    * Fix REST variation round-trip edge cases

    * Honor SKU resolution in REST order item updates

    * Honor authoritative SKU aliases in REST order updates

    * Harden REST variation identity matching

    * Cover malformed parent SKUs in REST v1

    * Fix zero SKU fallback for order item creation

    * Narrow REST variation preservation to same-parent updates

    * Harden REST variation preservation against filtered IDs

    * Harden REST variation update boundaries

    * Fix explicit REST variation demotion metadata

    * Remove unused REST test binding

diff --git a/plugins/woocommerce/changelog/67119-preserve-rest-variation b/plugins/woocommerce/changelog/67119-preserve-rest-variation
new file mode 100644
index 00000000000..ae740dc9478
--- /dev/null
+++ b/plugins/woocommerce/changelog/67119-preserve-rest-variation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve variation IDs when partially updating order line items through the REST API.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php
index 5b09651f462..2fc39a5d6e0 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller.php
@@ -670,11 +670,35 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 	 * @throws WC_REST_Exception Invalid data, server error.
 	 */
 	protected function prepare_line_items( $posted, $action = 'create' ) {
-		$item    = new WC_Order_Item_Product( ! empty( $posted['id'] ) ? $posted['id'] : '' );
-		$product = wc_get_product( $this->get_product_id( $posted, $action ) );
+		$item                 = new WC_Order_Item_Product( ! empty( $posted['id'] ) ? $posted['id'] : '' );
+		$product              = wc_get_product( $this->get_product_id( $posted, $action ) );
+		$current_product_id   = (int) $item->get_product_id( 'edit' );
+		$current_variation_id = (int) $item->get_variation_id( 'edit' );
+		// set_product() clears a variation when given its parent. REST partial updates restore it only
+		// when the posted parent and SKU-resolved product still identify the current item.
+		// An explicit variation_id of 0 still demotes the item.
+		$same_product_update  = 'update' === $action
+			&& array_key_exists( 'product_id', $posted )
+			&& $product instanceof WC_Product
+			&& (int) $posted['product_id'] === $current_product_id
+			&& in_array( $product->get_id(), array( $current_product_id, $current_variation_id ), true );
+		$restore_variation_id = $same_product_update
+			&& $current_variation_id
+			&& ( ! array_key_exists( 'variation_id', $posted ) || (int) $posted['variation_id'] === $current_variation_id );
+		$clear_variation_id   = $same_product_update
+			&& $current_variation_id
+			&& array_key_exists( 'variation_id', $posted )
+			&& 0 === (int) $posted['variation_id'];
+
+		if ( $clear_variation_id && $product instanceof WC_Product_Variation ) {
+			$product = wc_get_product( $current_product_id );
+		}

 		if ( $product && $product !== $item->get_product() ) {
 			$item->set_product( $product );
+			if ( $restore_variation_id ) {
+				$item->set_variation_id( $current_variation_id );
+			}

 			if ( 'create' === $action ) {
 				$quantity = isset( $posted['quantity'] ) ? $posted['quantity'] : 1;
@@ -683,6 +707,9 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
 				$item->set_subtotal( $total );
 			}
 		}
+		if ( $clear_variation_id ) {
+			$item->set_variation_id( 0 );
+		}

 		$this->maybe_set_item_props( $item, array( 'name', 'quantity', 'total', 'subtotal', 'tax_class' ), $posted );

diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
index 3ceb00eed27..325725df428 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
@@ -938,11 +938,36 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 	 * @throws WC_REST_Exception Invalid data, server error.
 	 */
 	protected function prepare_line_items( $posted, $action = 'create', $item = null ) {
-		$item    = is_null( $item ) ? new WC_Order_Item_Product( ! empty( $posted['id'] ) ? $posted['id'] : '' ) : $item;
-		$product = wc_get_product( $this->get_product_id( $posted, $action ) );
+		$item                 = is_null( $item ) ? new WC_Order_Item_Product( ! empty( $posted['id'] ) ? $posted['id'] : '' ) : $item;
+		$product              = wc_get_product( $this->get_product_id( $posted, $action ) );
+		$product_item         = $item instanceof WC_Order_Item_Product ? $item : null;
+		$current_product_id   = $product_item ? (int) $product_item->get_product_id( 'edit' ) : 0;
+		$current_variation_id = $product_item ? (int) $product_item->get_variation_id( 'edit' ) : 0;
+		// set_product() clears a variation when given its parent. REST partial updates restore it only
+		// when the posted parent and SKU-resolved product still identify the current item.
+		// An explicit variation_id of 0 still demotes the item.
+		$same_product_update  = 'update' === $action
+			&& array_key_exists( 'product_id', $posted )
+			&& $product instanceof WC_Product
+			&& (int) $posted['product_id'] === $current_product_id
+			&& in_array( $product->get_id(), array( $current_product_id, $current_variation_id ), true );
+		$restore_variation_id = $same_product_update
+			&& $current_variation_id
+			&& ( ! array_key_exists( 'variation_id', $posted ) || (int) $posted['variation_id'] === $current_variation_id );
+		$clear_variation_id   = $same_product_update
+			&& $current_variation_id
+			&& array_key_exists( 'variation_id', $posted )
+			&& 0 === (int) $posted['variation_id'];
+
+		if ( $clear_variation_id && $product instanceof WC_Product_Variation ) {
+			$product = wc_get_product( $current_product_id );
+		}

 		if ( $product && $product !== $item->get_product() ) {
 			$item->set_product( $product );
+			if ( $restore_variation_id && $product_item ) {
+				$product_item->set_variation_id( $current_variation_id );
+			}

 			if ( 'create' === $action ) {
 				$quantity = isset( $posted['quantity'] ) ? $posted['quantity'] : 1;
@@ -951,6 +976,9 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 				$item->set_subtotal( $total );
 			}
 		}
+		if ( $clear_variation_id && $product_item ) {
+			$product_item->set_variation_id( 0 );
+		}

 		$this->maybe_set_item_props( $item, array( 'name', 'quantity', 'total', 'subtotal', 'tax_class' ), $posted );
 		$this->maybe_set_item_meta_data( $item, $posted );
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php
index 2777bc4c582..baf0041defd 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-orders-v1-controller-tests.php
@@ -48,6 +48,25 @@ class WC_REST_Orders_V1_Controller_Tests extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Creates an order containing a variation line item.
+	 *
+	 * @return array{WC_Product_Variable, WC_Product_Variation, WC_Order, WC_Order_Item_Product}
+	 */
+	private function create_order_with_variation_line_item(): array {
+		$parent    = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$order     = new WC_Order();
+		$item      = new WC_Order_Item_Product();
+		$item->set_product( $variation );
+		$item->set_quantity( 1 );
+		$item->set_total( 10 );
+		$order->add_item( $item );
+		$order->save();
+
+		return array( $parent, $variation, $order, $item );
+	}
+
 	/**
 	 * Test that an order can be fetched via REST API V1 without triggering a deprecation notice.
 	 *
@@ -122,4 +141,118 @@ class WC_REST_Orders_V1_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( 400, $response->get_status(), 'The order was not updated, as the specified customer does not belong to the blog.' );
 		$this->assertEquals( 'woocommerce_rest_invalid_customer_id', $response->get_data()['code'], 'The returned error indicates the customer ID was invalid.' );
 	}
+
+	/**
+	 * @testdox Updating with the unchanged parent preserves the variation while ignoring view-context product ID filters.
+	 */
+	public function test_update_line_item_with_unchanged_parent_preserves_variation_id(): void {
+		list( $parent, $variation, $order, $item ) = $this->create_order_with_variation_line_item();
+		$parent->set_name( 'Updated parent name' );
+		$parent->set_tax_class( 'reduced-rate' );
+		$parent->save();
+		$item->set_name( 'Historical line item name' );
+		$item->set_tax_class( '' );
+		$item->save();
+		$filtered_product     = WC_Helper_Product::create_simple_product();
+		$product_id_filter    = static function ( $product_id, $order_item ) use ( $filtered_product, $item ) {
+			return $item->get_id() === $order_item->get_id() ? $filtered_product->get_id() : $product_id;
+		};
+		$product_id_hook_name = 'woocommerce_order_item_get_product_id';
+		add_filter( $product_id_hook_name, $product_id_filter, 10, 2 );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/orders/' . $order->get_id() );
+		$request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'id'         => $item->get_id(),
+						'product_id' => $parent->get_id(),
+					),
+				),
+			)
+		);
+
+		try {
+			$response = $this->server->dispatch( $request );
+		} finally {
+			remove_filter( $product_id_hook_name, $product_id_filter, 10 );
+		}
+		$this->assertSame( 200, $response->get_status(), 'The partial update should succeed.' );
+
+		$reloaded = new WC_Order_Item_Product( $item->get_id() );
+		$this->assertSame( $variation->get_id(), $reloaded->get_variation_id(), 'The existing variation ID should be preserved.' );
+		$this->assertSame( $variation->get_id(), $reloaded->get_product()->get_id(), 'The line item should continue to resolve to the variation.' );
+		$this->assertSame( $parent->get_name(), $reloaded->get_name(), 'The line-item name should retain its pre-regression resynchronization behavior.' );
+		$this->assertSame( $parent->get_tax_class(), $reloaded->get_tax_class(), 'The line-item tax class should retain its pre-regression resynchronization behavior.' );
+	}
+
+	/**
+	 * @testdox Updating with an explicit zero variation ID clears the variation even when SKU resolves to it.
+	 */
+	public function test_update_line_item_with_zero_variation_id_and_current_sku_switches_to_parent(): void {
+		list( $parent, $variation, $order, $item ) = $this->create_order_with_variation_line_item();
+
+		$parent->set_name( 'REST V1 parent product' );
+		$parent->set_tax_class( '' );
+		$parent->save();
+		$variation_sku = 'REST-V1-VARIATION-' . wp_generate_uuid4();
+		$variation->set_sku( $variation_sku );
+		$variation->set_tax_class( 'reduced-rate' );
+		$variation->save();
+		$item->set_name( $variation->get_name() );
+		$item->set_tax_class( $variation->get_tax_class() );
+		$item->save();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/orders/' . $order->get_id() );
+		$request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $item->get_id(),
+						'product_id'   => $parent->get_id(),
+						'variation_id' => 0,
+						'sku'          => $variation_sku,
+					),
+				),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), 'Explicitly demoting the line item should succeed.' );
+
+		$reloaded = new WC_Order_Item_Product( $item->get_id() );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'An explicit zero variation ID should clear the existing variation.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the submitted parent product ID.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the parent product.' );
+		$this->assertSame( $parent->get_name(), $reloaded->get_name(), 'The line-item name should be synchronized with the parent product.' );
+		$this->assertSame( $parent->get_tax_class(), $reloaded->get_tax_class(), 'The line-item tax class should be synchronized with the parent product.' );
+	}
+
+	/**
+	 * @testdox Updating with the parent product as variation_id does not restore the existing variation.
+	 */
+	public function test_update_line_item_with_parent_as_variation_id_does_not_restore_variation(): void {
+		list( $parent, , $order, $item ) = $this->create_order_with_variation_line_item();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v1/orders/' . $order->get_id() );
+		$request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $item->get_id(),
+						'product_id'   => $parent->get_id(),
+						'variation_id' => $parent->get_id(),
+					),
+				),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), 'The update should succeed.' );
+
+		$reloaded = new WC_Order_Item_Product( $item->get_id() );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'A parent product ID should not be restored as a variation.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the parent product ID.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the parent product.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
index d7c5e16fe21..61c54b86f90 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
@@ -1179,6 +1179,21 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 		return array( $order, $item->get_id() );
 	}

+	/**
+	 * Dispatches a v3 line-item update.
+	 *
+	 * @param int   $order_id Order ID.
+	 * @param array $line_item Line-item payload.
+	 * @return WP_REST_Response
+	 */
+	private function dispatch_line_item_update( int $order_id, array $line_item ): WP_REST_Response {
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_id );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'line_items' => array( $line_item ) ) ) );
+
+		return $this->server->dispatch( $request );
+	}
+
 	/**
 	 * @testdox PUT /orders that switches a variation line item to a simple product clears variation_id over the REST round trip.
 	 */
@@ -1190,22 +1205,13 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {

 		$this->assertSame( $variation->get_id(), (int) wc_get_order_item_meta( $item_id, '_variation_id' ), 'Precondition: the line item stored the variation ID.' );

-		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
-		$request->set_header( 'content-type', 'application/json' );
-		$request->set_body(
-			wp_json_encode(
-				array(
-					'line_items' => array(
-						array(
-							'id'         => $item_id,
-							'product_id' => $simple->get_id(),
-						),
-					),
-				)
+		$response = $this->dispatch_line_item_update(
+			$order->get_id(),
+			array(
+				'id'         => $item_id,
+				'product_id' => $simple->get_id(),
 			)
 		);
-
-		$response = $this->server->dispatch( $request );
 		$this->assertSame( 200, $response->get_status(), 'The update should succeed.' );

 		$reloaded = new WC_Order_Item_Product( $item_id );
@@ -1215,32 +1221,183 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox PUT /orders with a product_id-only payload targeting the variable parent demotes the item and clears variation_id.
+	 * @testdox PUT /orders with an unchanged parent preserves the variation while ignoring view-context variation ID filters.
 	 */
-	public function test_update_line_item_to_variable_parent_clears_variation_id(): void {
+	public function test_update_line_item_with_unchanged_parent_preserves_variation_id(): void {
 		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
-		list( $order, $item_id )    = $this->create_order_with_variation_line_item( $variation );
-
-		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
-		$request->set_header( 'content-type', 'application/json' );
-		$request->set_body(
-			wp_json_encode(
+		$parent->set_name( 'Updated parent name' );
+		$parent->set_tax_class( 'reduced-rate' );
+		$parent->save();
+		list( $order, $item_id ) = $this->create_order_with_variation_line_item( $variation );
+		$item                    = new WC_Order_Item_Product( $item_id );
+		$item->set_name( 'Historical line item name' );
+		$item->set_tax_class( '' );
+		$item->save();
+		$variation_id_filter    = static function ( $variation_id, $order_item ) use ( $item_id ) {
+			return $item_id === $order_item->get_id() ? 0 : $variation_id;
+		};
+		$variation_id_hook_name = 'woocommerce_order_item_get_variation_id';
+		add_filter( $variation_id_hook_name, $variation_id_filter, 10, 2 );
+
+		try {
+			$response = $this->dispatch_line_item_update(
+				$order->get_id(),
 				array(
-					'line_items' => array(
-						array(
-							'id'         => $item_id,
-							'product_id' => $parent->get_id(),
-						),
-					),
+					'id'         => $item_id,
+					'product_id' => $parent->get_id(),
 				)
+			);
+		} finally {
+			remove_filter( $variation_id_hook_name, $variation_id_filter, 10 );
+		}
+		$this->assertSame( 200, $response->get_status(), 'A product_id-only payload targeting the variable parent succeeds with no error.' );
+
+		$response_item = $response->get_data()['line_items'][0];
+		$reloaded      = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( $variation->get_id(), $reloaded->get_variation_id(), 'Omitting variation_id should preserve the existing variation.' );
+		$this->assertSame( $variation->get_id(), (int) wc_get_order_item_meta( $item_id, '_variation_id' ), 'The persisted _variation_id meta should remain unchanged.' );
+		$this->assertSame( $variation->get_id(), $reloaded->get_product()->get_id(), 'get_product() should continue to resolve to the variation.' );
+		$this->assertSame( $parent->get_id(), $response_item['product_id'], 'The response should retain the variable parent product ID.' );
+		$this->assertSame( $variation->get_id(), $response_item['variation_id'], 'The response should retain the variation ID.' );
+		$this->assertSame( $parent->get_name(), $reloaded->get_name(), 'The line-item name should retain its pre-regression resynchronization behavior.' );
+		$this->assertSame( $parent->get_tax_class(), $reloaded->get_tax_class(), 'The line-item tax class should retain its pre-regression resynchronization behavior.' );
+	}
+
+	/**
+	 * @testdox PUT /orders with an explicit zero variation ID demotes a variation even when SKU resolves to it.
+	 */
+	public function test_update_line_item_with_zero_variation_id_and_current_sku_switches_to_parent(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+
+		$parent->set_name( 'REST V3 parent product' );
+		$parent->set_tax_class( '' );
+		$parent->save();
+		$variation_sku = 'REST-V3-VARIATION-' . wp_generate_uuid4();
+		$variation->set_sku( $variation_sku );
+		$variation->set_tax_class( 'reduced-rate' );
+		$variation->save();
+
+		list( $order, $item_id ) = $this->create_order_with_variation_line_item( $variation );
+
+		$response = $this->dispatch_line_item_update(
+			$order->get_id(),
+			array(
+				'id'           => $item_id,
+				'product_id'   => $parent->get_id(),
+				'variation_id' => 0,
+				'sku'          => $variation_sku,
 			)
 		);
+		$this->assertSame( 200, $response->get_status(), 'Explicitly demoting the line item should succeed.' );

-		$response = $this->server->dispatch( $request );
-		$this->assertSame( 200, $response->get_status(), 'A product_id-only payload targeting the variable parent succeeds with no error.' );
+		$reloaded = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'An explicit zero variation ID should clear the existing variation.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the submitted parent product ID.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the parent product.' );
+		$this->assertSame( $parent->get_name(), $reloaded->get_name(), 'The line-item name should be synchronized with the parent product.' );
+		$this->assertSame( $parent->get_tax_class(), $reloaded->get_tax_class(), 'The line-item tax class should be synchronized with the parent product.' );
+	}
+
+	/**
+	 * @testdox PUT /orders with the parent product as variation_id does not restore the existing variation.
+	 */
+	public function test_update_line_item_with_parent_as_variation_id_does_not_restore_variation(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+		list( $order, $item_id )    = $this->create_order_with_variation_line_item( $variation );
+
+		$response = $this->dispatch_line_item_update(
+			$order->get_id(),
+			array(
+				'id'           => $item_id,
+				'product_id'   => $parent->get_id(),
+				'variation_id' => $parent->get_id(),
+			)
+		);
+		$this->assertSame( 200, $response->get_status(), 'The update should succeed.' );
+
+		$reloaded = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'A parent product ID should not be restored as a variation.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the parent product ID.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the parent product.' );
+	}
+
+	/**
+	 * @testdox Round-tripping a variation with its inherited parent SKU preserves the variation ID.
+	 */
+	public function test_round_trip_line_item_with_inherited_parent_sku_preserves_variation_id(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+		$parent_sku                 = 'REST-V3-PARENT-' . wp_generate_uuid4();
+		$parent->set_sku( $parent_sku );
+		$parent->save();
+		$variation->set_sku( '' );
+		$variation->save();
+		list( $order, $item_id ) = $this->create_order_with_variation_line_item( $variation );
+
+		$get_request  = new WP_REST_Request( 'GET', '/wc/v3/orders/' . $order->get_id() );
+		$get_response = $this->server->dispatch( $get_request );
+		$this->assertSame( 200, $get_response->get_status(), 'Fetching the order should succeed.' );
+
+		$round_trip_item = $get_response->get_data()['line_items'][0];
+		$this->assertSame( $parent_sku, $round_trip_item['sku'], 'The response should expose the SKU inherited from the parent.' );
+
+		$put_response = $this->dispatch_line_item_update( $order->get_id(), $round_trip_item );
+		$this->assertSame( 200, $put_response->get_status(), 'Round-tripping the line item should succeed.' );
+
+		$reloaded = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( $variation->get_id(), $reloaded->get_variation_id(), 'The inherited parent SKU should not demote the variation.' );
+		$this->assertSame( $variation->get_id(), $reloaded->get_product()->get_id(), 'The line item should continue to resolve to the variation.' );
+	}
+
+	/**
+	 * @testdox PUT /orders does not preserve the variation when product_id conflicts with a SKU resolving to the current parent.
+	 */
+	public function test_update_line_item_with_different_product_id_and_current_parent_sku_does_not_preserve_variation(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+		$parent_sku                 = 'REST-V3-PARENT-' . wp_generate_uuid4();
+		$parent->set_sku( $parent_sku );
+		$parent->save();
+		list( $order, $item_id ) = $this->create_order_with_variation_line_item( $variation );
+		$different_product       = WC_Helper_Product::create_simple_product();
+
+		$response = $this->dispatch_line_item_update(
+			$order->get_id(),
+			array(
+				'id'         => $item_id,
+				'product_id' => $different_product->get_id(),
+				'sku'        => $parent_sku,
+			)
+		);
+		$this->assertSame( 200, $response->get_status(), 'The SKU-selected product update should succeed.' );
+
+		$reloaded = new WC_Order_Item_Product( $item_id );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'A conflicting product ID should prevent restoring the variation.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product_id(), 'The line item should retain the product selected by SKU.' );
+		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the product selected by SKU.' );
+	}
+
+	/**
+	 * @testdox PUT /orders still switches products when SKU conflicts with the unchanged parent product_id.
+	 */
+	public function test_update_line_item_with_different_product_sku_switches_products(): void {
+		list( $parent, $variation ) = $this->create_variable_product_with_color_variation();
+		list( $order, $item_id )    = $this->create_order_with_variation_line_item( $variation );
+		$simple                     = WC_Helper_Product::create_simple_product();
+		$simple_sku                 = 'REST-V3-SIMPLE-' . wp_generate_uuid4();
+		$simple->set_sku( $simple_sku );
+		$simple->save();
+
+		$response = $this->dispatch_line_item_update(
+			$order->get_id(),
+			array(
+				'id'         => $item_id,
+				'product_id' => $parent->get_id(),
+				'sku'        => $simple_sku,
+			)
+		);
+		$this->assertSame( 200, $response->get_status(), 'Switching by SKU should succeed.' );

 		$reloaded = new WC_Order_Item_Product( $item_id );
-		$this->assertSame( 0, $reloaded->get_variation_id(), 'Omitting variation_id demotes the item to its parent and clears variation_id.' );
-		$this->assertSame( $parent->get_id(), $reloaded->get_product()->get_id(), 'get_product() should resolve to the variable parent.' );
+		$this->assertSame( 0, $reloaded->get_variation_id(), 'Switching to a simple product by SKU should clear variation_id.' );
+		$this->assertSame( $simple->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the product selected by SKU.' );
 	}
 }