Commit 8f6de70fd3f for woocommerce
commit 8f6de70fd3fa35236ffaff29fe6aaabc71fb1b78
Author: Francesco <frosso@users.noreply.github.com>
Date: Thu Nov 5 23:38:24 2026 +0100
fix: reject REST order line items for missing products (#70033)
diff --git a/plugins/woocommerce/changelog/wooplug-2499-reject-deleted-products-in-rest-orders b/plugins/woocommerce/changelog/wooplug-2499-reject-deleted-products-in-rest-orders
new file mode 100644
index 00000000000..ce73f2615d0
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-2499-reject-deleted-products-in-rest-orders
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Return an error when REST API orders add line items for deleted or unknown products. To add an item with no product, send a name and total.
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 53c482f73bf..10ab129c3b4 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
@@ -10,6 +10,7 @@
* @since 3.0.0
*/
+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
@@ -27,6 +28,8 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
+ use OrderLineItemValidationTrait;
+
/**
* Endpoint namespace.
*
@@ -453,7 +456,7 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
/**
* Prepare a single order for create.
*
- * @throws WC_REST_Exception If the customer ID is invalid.
+ * @throws WC_REST_Exception If the customer ID, or a new line item's product, is invalid.
*
* @param WP_REST_Request $request Request object.
* @return WP_Error|WC_Order $data Object.
@@ -464,6 +467,8 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
$schema = $this->get_item_schema();
$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );
+ $this->check_new_line_item_products( $request['line_items'] );
+
if ( ! is_null( $request['customer_id'] ) && 0 !== $request['customer_id'] ) {
if ( is_wp_error( Users::get_user_in_current_site( $request['customer_id'] ) ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
@@ -628,10 +633,11 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
$product_id = (int) $posted['product_id'];
} elseif ( ! empty( $posted['variation_id'] ) ) {
$product_id = (int) $posted['variation_id'];
- } elseif ( 'update' === $action ) {
+ } elseif ( 'update' === $action || $this->is_custom_line_item( $posted ) ) {
$product_id = 0;
} else {
- throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required.', 'woocommerce' ), 400 );
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+ throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required. For a custom item, send a name and total instead.', 'woocommerce' ), 400 );
}
return $product_id;
}
@@ -692,6 +698,12 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
&& array_key_exists( 'variation_id', $posted )
&& 0 === (int) $posted['variation_id'];
+ // Only new items are checked, so orders whose products were deleted later stay editable.
+ // prepare_item_for_database() already checks them up front, but subclasses that skip it, like Subscriptions, rely on this check.
+ if ( 'create' === $action ) {
+ $this->check_line_item_product( $posted, $product );
+ }
+
if ( $clear_variation_id && $product instanceof WC_Product_Variation ) {
$product = wc_get_product( $current_product_id );
}
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 d749d815708..b362d0d9ea2 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
@@ -11,6 +11,7 @@
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\OrderLineMetaValidator;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Enums\ProductType;
@@ -29,6 +30,8 @@ use Automattic\WooCommerce\Utilities\StringUtil;
class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
// phpcs:enable
+ use OrderLineItemValidationTrait;
+
/**
* Endpoint namespace.
*
@@ -726,6 +729,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
/**
* Prepare a single order for create or update.
*
+ * @throws WC_REST_Exception When a new line item's product doesn't exist.
* @param WP_REST_Request $request Request object.
* @param bool $creating If is creating a new object.
* @return WP_Error|WC_Data
@@ -736,6 +740,8 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
$schema = $this->get_item_schema();
$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );
+ $this->check_new_line_item_products( $request['line_items'] );
+
$item_types = array( 'shipping_lines', 'line_items', 'fee_lines', 'coupon_lines' );
// Handle all writable props.
@@ -895,10 +901,11 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
$product_id = (int) $posted['product_id'];
} elseif ( ! empty( $posted['variation_id'] ) ) {
$product_id = (int) $posted['variation_id'];
- } elseif ( 'update' === $action ) {
+ } elseif ( 'update' === $action || $this->is_custom_line_item( $posted ) ) {
$product_id = 0;
} else {
- throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required.', 'woocommerce' ), 400 );
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+ throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', __( 'Product ID or SKU is required. For a custom item, send a name and total instead.', 'woocommerce' ), 400 );
}
return $product_id;
}
@@ -972,6 +979,12 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
&& array_key_exists( 'variation_id', $posted )
&& 0 === (int) $posted['variation_id'];
+ // Only new items are checked, so orders whose products were deleted later stay editable.
+ // prepare_object_for_database() already checks them up front, but subclasses that skip it, like Subscriptions, rely on this check.
+ if ( 'create' === $action ) {
+ $this->check_line_item_product( $posted, $product );
+ }
+
if ( $clear_variation_id && $product instanceof WC_Product_Variation ) {
$product = wc_get_product( $current_product_id );
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
index 239be85a839..2b97d7eba8d 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
@@ -10,6 +10,7 @@
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -26,6 +27,7 @@ defined( 'ABSPATH' ) || exit;
*/
class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
use CogsAwareTrait;
+ use OrderLineItemValidationTrait;
/**
* Endpoint namespace.
@@ -115,6 +117,8 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
$schema = $this->get_item_schema();
$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );
+ $this->check_new_line_item_products( $request['line_items'] );
+
$item_types = array( 'shipping_lines', 'line_items', 'fee_lines' );
// Handle all writable props.
diff --git a/plugins/woocommerce/src/Internal/RestApi/OrderLineItemValidationTrait.php b/plugins/woocommerce/src/Internal/RestApi/OrderLineItemValidationTrait.php
new file mode 100644
index 00000000000..c6d99535806
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/OrderLineItemValidationTrait.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Order line item checks for REST write requests.
+ *
+ * @package WooCommerce\Internal\RestApi
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi;
+
+/**
+ * Line item checks shared by the order controllers.
+ *
+ * The checks use the controller's own get_product_id() and item_is_null(), so subclass overrides still apply.
+ */
+trait OrderLineItemValidationTrait {
+
+ /**
+ * Checks whether a line item is a custom item: one with no product, but a name and a total.
+ *
+ * @param array $line_item Line item data from the request.
+ * @return bool
+ */
+ private function is_custom_line_item( $line_item ): bool {
+ $has_product_reference = ! empty( $line_item['product_id'] ) || ! empty( $line_item['variation_id'] ) || ! empty( $line_item['sku'] );
+ $has_name = isset( $line_item['name'] ) && is_string( $line_item['name'] ) && '' !== trim( $line_item['name'] );
+ $has_total = isset( $line_item['total'] ) && is_numeric( wc_format_decimal( $line_item['total'] ) );
+
+ return ! $has_product_reference && $has_name && $has_total;
+ }
+
+ /**
+ * Checks that every new line item, other than custom items, points to an existing product, before any item is saved.
+ * Otherwise a bad item fails the request after earlier items were already saved, along with any stock changes.
+ *
+ * @param mixed $line_items Line items from the request.
+ * @return void
+ * @throws \WC_REST_Exception When a new line item has no product reference, or its product doesn't exist.
+ */
+ private function check_new_line_item_products( $line_items ): void {
+ if ( ! is_array( $line_items ) ) {
+ return;
+ }
+
+ foreach ( $line_items as $posted ) {
+ if ( ! is_array( $posted ) ) {
+ continue;
+ }
+
+ // Existing items can point to products deleted since the order was placed, and must stay editable.
+ if ( ! empty( $posted['id'] ) ) {
+ continue;
+ }
+
+ // Null and zero-quantity items are removals.
+ if ( $this->item_is_null( $posted ) ) {
+ continue;
+ }
+
+ if ( isset( $posted['quantity'] ) && 0 === $posted['quantity'] ) {
+ continue;
+ }
+
+ $this->check_line_item_product( $posted, wc_get_product( $this->get_product_id( $posted ) ) );
+ }
+ }
+
+ /**
+ * Checks that a new line item points to an existing product, unless it's a custom item.
+ *
+ * @param array $line_item Line item data from the request.
+ * @param \WC_Product|false|null $product The product the line item resolves to.
+ * @return void
+ * @throws \WC_REST_Exception When the product doesn't exist.
+ */
+ private function check_line_item_product( $line_item, $product ): void {
+ if ( $this->is_custom_line_item( $line_item ) ) {
+ return;
+ }
+
+ if ( $product && $product->exists() ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+ throw new \WC_REST_Exception( 'woocommerce_rest_invalid_product_id', __( 'Invalid product ID.', 'woocommerce' ), 400 );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/README.md b/plugins/woocommerce/src/Internal/RestApi/README.md
index 5f710885393..854436bd604 100644
--- a/plugins/woocommerce/src/Internal/RestApi/README.md
+++ b/plugins/woocommerce/src/Internal/RestApi/README.md
@@ -8,6 +8,7 @@ Only `Routes/V4/` is the experimental v4 API. Other code in this directory is sh
- `Refunds/`: the version-neutral refund engine used by the wc/v3 refund endpoints and the v4 routes.
- `ProductRequestPreparationTrait.php`: used by the wc/v2 and wc/v3 product endpoints.
+- `OrderLineItemValidationTrait.php`: used by the wc/v1, wc/v2 and wc/v3 order endpoints.
- `InteractiveSessionCheck.php`: used by the wc-admin plugin and onboarding endpoints.
`Routes/V4/Orders/OrderLineMetaValidator.php` is also used by the wc/v2 and wc/v3 order endpoints. Move it out of `Routes/V4/` before removing the v4 routes.
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 adbd55fac80..0d8543f03c8 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
@@ -174,6 +174,88 @@ class WC_REST_Orders_V1_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertEquals( 'woocommerce_rest_invalid_customer_id', $response->get_data()['code'], 'The returned error indicates the customer ID was invalid.' );
}
+ /**
+ * @testdox Creating an order with a custom line item adds an item with no product.
+ */
+ public function test_create_order_with_custom_line_item(): void {
+ $request = new WP_REST_Request( 'POST', '/wc/v1/orders' );
+ $request->set_body_params(
+ array(
+ 'line_items' => array(
+ array(
+ 'name' => 'Gift wrap',
+ 'total' => '5.00',
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status(), 'The order should be created.' );
+ $item = current( wc_get_order( $response->get_data()['id'] )->get_items() );
+ $this->assertSame( 0, $item->get_product_id(), 'The item should have no product.' );
+ $this->assertSame( 'Gift wrap', $item->get_name(), 'The item should keep its name.' );
+ $this->assertSame( 5.0, (float) $item->get_total(), 'The item should keep its total.' );
+ }
+
+ /**
+ * @testdox Updating an order with a deleted product leaves its existing items unchanged.
+ */
+ public function test_update_order_with_deleted_product_leaves_existing_items_unchanged(): void {
+ $order = wc_create_order();
+ $item_id = $order->add_product( WC_Helper_Product::create_simple_product() );
+ $order->save();
+
+ $deleted = WC_Helper_Product::create_simple_product();
+ $deleted_id = $deleted->get_id();
+ $deleted->delete( true );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v1/orders/' . $order->get_id() );
+ $request->set_body_params(
+ array(
+ 'line_items' => array(
+ array(
+ 'id' => $item_id,
+ 'quantity' => 5,
+ ),
+ array( 'product_id' => $deleted_id ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+ $this->assertSame( 1, ( new WC_Order_Item_Product( $item_id ) )->get_quantity(), 'The existing item should keep its quantity.' );
+ }
+
+ /**
+ * @testdox prepare_line_items() rejects a new line item for a deleted product, for subclasses that skip the up-front check.
+ */
+ public function test_prepare_line_items_rejects_new_line_item_with_deleted_product(): void {
+ $deleted = WC_Helper_Product::create_simple_product();
+ $deleted_id = $deleted->get_id();
+ $deleted->delete( true );
+
+ $controller = new class() extends WC_REST_Orders_V1_Controller {
+ /**
+ * Public wrapper for prepare_line_items().
+ *
+ * @param array $posted Line item data.
+ * @return WC_Order_Item_Product
+ */
+ public function call_prepare_line_items( array $posted ) {
+ return $this->prepare_line_items( $posted );
+ }
+ };
+
+ $this->expectException( WC_REST_Exception::class );
+ $this->expectExceptionMessage( 'Invalid product ID.' );
+ $this->expectExceptionCode( 400 );
+
+ $controller->call_prepare_line_items( array( 'product_id' => $deleted_id ) );
+ }
+
/**
* @testdox Updating with the unchanged parent preserves the variation while ignoring view-context product ID filters.
*/
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
index db8590752eb..32d35e7726d 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
@@ -1,5 +1,6 @@
<?php
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
@@ -464,6 +465,49 @@ class WC_REST_Order_V2_Controller_Test extends WC_REST_Unit_Test_case {
);
}
+ /**
+ * @testdox PUT /wc/v2/orders/<id> with a deleted product leaves earlier new items and their stock unchanged.
+ */
+ public function test_v2_update_order_with_deleted_product_saves_no_earlier_items(): void {
+ $order = wc_create_order();
+ $order->add_product( WC_Helper_Product::create_simple_product() );
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+ $stocked = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+
+ $deleted = WC_Helper_Product::create_simple_product();
+ $deleted_id = $deleted->get_id();
+ $deleted->delete( true );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v2/orders/' . $order->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $stocked->get_id(),
+ 'quantity' => 2,
+ ),
+ array( 'product_id' => $deleted_id ),
+ ),
+ )
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+ $this->assertCount( 1, wc_get_order( $order->get_id() )->get_items(), 'The order should keep only its original line item.' );
+ $this->assertSame( 10, wc_get_product( $stocked->get_id() )->get_stock_quantity(), 'The stock should not change.' );
+ }
+
/**
* Dispatch a PUT that posts a serialized value under the reserved `_taxes` meta key of one order item.
*
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 77f2fa1e75c..e83788d6800 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
@@ -1754,6 +1754,177 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertTrue( $line_item_meta['display_key']['readonly'] );
}
+ /**
+ * Line items that point to a missing product.
+ *
+ * @return array<string, array{callable(): array<string, int|string>}>
+ */
+ public function provide_line_items_with_missing_product(): array {
+ return array(
+ 'deleted variation' => array(
+ static function (): array {
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $variable->get_children()[0] );
+ $line_item = array(
+ 'product_id' => $variable->get_id(),
+ 'variation_id' => $variation->get_id(),
+ );
+ $variation->delete( true );
+
+ return $line_item;
+ },
+ ),
+ 'unknown SKU' => array(
+ static function (): array {
+ return array( 'sku' => 'missing-sku' );
+ },
+ ),
+ );
+ }
+
+ /**
+ * @testdox POST /orders rejects a line item for a missing product ($_dataName), and creates no order.
+ * @dataProvider provide_line_items_with_missing_product
+ *
+ * @param callable $build_line_item Builds the line item payload.
+ */
+ public function test_create_order_rejects_line_item_with_missing_product( callable $build_line_item ): void {
+ $line_item = $build_line_item();
+ $query_args = array(
+ 'return' => 'ids',
+ 'limit' => -1,
+ );
+ $order_count = count( wc_get_orders( $query_args ) );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'line_items' => array( $line_item ) ) ) );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+ $this->assertCount( $order_count, wc_get_orders( $query_args ), 'No order should be created.' );
+ }
+
+ /**
+ * @testdox POST /orders adds a custom line item that has a name and total but no product.
+ */
+ public function test_create_order_with_custom_line_item(): void {
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'line_items' => array(
+ array(
+ 'name' => 'Gift wrap',
+ 'quantity' => 2,
+ 'total' => '5.00',
+ ),
+ ),
+ )
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status(), 'The order should be created.' );
+ $item = current( wc_get_order( $response->get_data()['id'] )->get_items() );
+ $this->assertSame( 0, $item->get_product_id(), 'The item should have no product.' );
+ $this->assertSame( 'Gift wrap', $item->get_name(), 'The item should keep its name.' );
+ $this->assertSame( 2, $item->get_quantity(), 'The item should keep its quantity.' );
+ $this->assertSame( 5.0, (float) $item->get_total(), 'The item should keep its total.' );
+ }
+
+ /**
+ * @testdox PUT /orders with a deleted product leaves earlier new items and their stock unchanged.
+ */
+ public function test_update_order_with_deleted_product_saves_no_earlier_items(): void {
+ list( $order ) = $this->create_order_with_line_item();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+ $stocked = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 10,
+ )
+ );
+
+ $deleted = WC_Helper_Product::create_simple_product();
+ $deleted_id = $deleted->get_id();
+ $deleted->delete( true );
+
+ $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(
+ 'product_id' => $stocked->get_id(),
+ 'quantity' => 2,
+ ),
+ array( 'product_id' => $deleted_id ),
+ ),
+ )
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status(), 'The request should be rejected.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'], 'The error should name the invalid product.' );
+ $this->assertCount( 1, wc_get_order( $order->get_id() )->get_items(), 'The order should keep only its original line item.' );
+ $this->assertSame( 10, wc_get_product( $stocked->get_id() )->get_stock_quantity(), 'The stock should not change.' );
+ }
+
+ /**
+ * @testdox PUT /orders still updates an existing line item whose product was deleted.
+ */
+ public function test_update_existing_line_item_with_deleted_product_succeeds(): void {
+ list( $order, $item_id ) = $this->create_order_with_line_item();
+ $product_id = ( new WC_Order_Item_Product( $item_id ) )->get_product_id();
+ wc_get_product( $product_id )->delete( true );
+
+ $response = $this->dispatch_line_item_update(
+ $order->get_id(),
+ array(
+ 'id' => $item_id,
+ 'product_id' => $product_id,
+ 'quantity' => 2,
+ )
+ );
+
+ $this->assertSame( 200, $response->get_status(), 'The update should succeed.' );
+ $this->assertSame( 2, ( new WC_Order_Item_Product( $item_id ) )->get_quantity(), 'The quantity should be updated.' );
+ }
+
+ /**
+ * @testdox prepare_line_items() rejects a new line item for a deleted product, for subclasses that skip the up-front check.
+ */
+ public function test_prepare_line_items_rejects_new_line_item_with_deleted_product(): void {
+ $deleted = WC_Helper_Product::create_simple_product();
+ $deleted_id = $deleted->get_id();
+ $deleted->delete( true );
+
+ $controller = new class() extends WC_REST_Orders_Controller {
+ /**
+ * Public wrapper for prepare_line_items().
+ *
+ * @param array $posted Line item data.
+ * @return WC_Order_Item_Product
+ */
+ public function call_prepare_line_items( array $posted ) {
+ return $this->prepare_line_items( $posted );
+ }
+ };
+
+ $this->expectException( WC_REST_Exception::class );
+ $this->expectExceptionMessage( 'Invalid product ID.' );
+ $this->expectExceptionCode( 400 );
+
+ $controller->call_prepare_line_items( array( 'product_id' => $deleted_id ) );
+ }
+
/**
* Builds a variable product with a single variation carrying a real "color" attribute.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/OrderLineItemValidationTraitTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/OrderLineItemValidationTraitTest.php
new file mode 100644
index 00000000000..a76ea020743
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/OrderLineItemValidationTraitTest.php
@@ -0,0 +1,157 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi;
+
+use Automattic\WooCommerce\Internal\RestApi\OrderLineItemValidationTrait;
+use WC_Helper_Product;
+use WC_REST_Exception;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the OrderLineItemValidationTrait trait.
+ */
+class OrderLineItemValidationTraitTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var object
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new class() {
+ use OrderLineItemValidationTrait;
+
+ /**
+ * Stands in for the controller's lookup.
+ *
+ * @param array $posted Line item data.
+ * @return int
+ */
+ protected function get_product_id( $posted ) {
+ return (int) ( $posted['product_id'] ?? 0 );
+ }
+
+ /**
+ * Stands in for the controller's removal check.
+ *
+ * @param array $item Line item data.
+ * @return bool
+ */
+ protected function item_is_null( $item ) {
+ return array_key_exists( 'product_id', $item ) && is_null( $item['product_id'] );
+ }
+
+ /**
+ * Public wrapper for is_custom_line_item().
+ *
+ * @param array $line_item Line item data.
+ * @return bool
+ */
+ public function call_is_custom_line_item( $line_item ): bool {
+ return $this->is_custom_line_item( $line_item );
+ }
+
+ /**
+ * Public wrapper for check_new_line_item_products().
+ *
+ * @param mixed $line_items Line items from the request.
+ */
+ public function call_check_new_line_item_products( $line_items ): void {
+ $this->check_new_line_item_products( $line_items );
+ }
+ };
+ }
+
+ /**
+ * @testdox Should treat a line item as custom only when it has no product reference, a name and a numeric total.
+ * @testWith [{"name": "Gift wrap", "total": "5.00"}, true]
+ * [{"name": "Gift wrap", "total": "0"}, true]
+ * [{"product_id": 0, "name": "Gift wrap", "total": "5.00"}, true]
+ * [{"product_id": 123, "name": "Gift wrap", "total": "5.00"}, false]
+ * [{"variation_id": 123, "name": "Gift wrap", "total": "5.00"}, false]
+ * [{"sku": "gift-wrap", "name": "Gift wrap", "total": "5.00"}, false]
+ * [{"name": "Gift wrap"}, false]
+ * [{"name": "Gift wrap", "total": null}, false]
+ * [{"name": "Gift wrap", "total": ""}, false]
+ * [{"name": "Gift wrap", "total": "abc"}, false]
+ * [{"total": "5.00"}, false]
+ * [{"name": " ", "total": "5.00"}, false]
+ * [{"name": 123, "total": "5.00"}, false]
+ *
+ * @param array<string, mixed> $line_item Line item payload.
+ * @param bool $expected Whether it should count as a custom item.
+ */
+ public function test_is_custom_line_item( array $line_item, bool $expected ): void {
+ $this->assertSame(
+ $expected,
+ $this->sut->call_is_custom_line_item( $line_item ),
+ 'Unexpected result for ' . wp_json_encode( $line_item )
+ );
+ }
+
+ /**
+ * @testdox Should accept new line items whose product exists.
+ */
+ public function test_check_new_line_item_products_accepts_existing_product(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->expectNotToPerformAssertions();
+
+ $this->sut->call_check_new_line_item_products( array( array( 'product_id' => $product->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Should reject a new line item for a missing product, even after a valid one.
+ */
+ public function test_check_new_line_item_products_rejects_missing_product(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->expectException( WC_REST_Exception::class );
+ $this->expectExceptionMessage( 'Invalid product ID.' );
+ $this->expectExceptionCode( 400 );
+
+ $this->sut->call_check_new_line_item_products(
+ array(
+ array( 'product_id' => $product->get_id() ),
+ array( 'product_id' => 999999999 ),
+ )
+ );
+ }
+
+ /**
+ * @testdox Should skip line items that won't be saved as new product items.
+ * @testWith [{"id": 7, "product_id": 999999999}]
+ * [{"product_id": null}]
+ * [{"product_id": 999999999, "quantity": 0}]
+ * [{"name": "Gift wrap", "total": "5.00"}]
+ * ["not a line item"]
+ *
+ * @param mixed $line_item Line item payload.
+ */
+ public function test_check_new_line_item_products_skips_items_that_are_not_new_products( $line_item ): void {
+ $this->expectNotToPerformAssertions();
+
+ $this->sut->call_check_new_line_item_products( array( $line_item ) );
+ }
+
+ /**
+ * @testdox Should do nothing when the request has no line items.
+ * @testWith [null]
+ * [[]]
+ *
+ * @param mixed $line_items Line items from the request.
+ */
+ public function test_check_new_line_item_products_ignores_missing_line_items( $line_items ): void {
+ $this->expectNotToPerformAssertions();
+
+ $this->sut->call_check_new_line_item_products( $line_items );
+ }
+}