Commit 02f9a16b098 for woocommerce
commit 02f9a16b098a918f7f087c7814f77bf04d7c5daf
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Wed Aug 26 23:12:37 2026 +0300
Fix invalid-product fatal errors in REST product write endpoints (#67182)
The product REST controllers built the target product straight from request
input and relied on the constructor succeeding. When the ID did not resolve to
a usable product, WC_Product_Data_Store_CPT::read() threw a bare
'Invalid product.' exception that save_object() does not catch, so the request
fataled with an HTTP 500. Two triggers reached it: a variation ID sent with a
type parameter, deterministically, and a product deleted between the route
guard and preparation, intermittently. One site logged 922 in 12 hours.
Add a shared ProductRequestPreparationTrait, used by the V2, V3 and V4 product
controllers, that validates the target before constructing it:
- Variation IDs return 404 woocommerce_rest_invalid_product_id with the
existing "use the variations endpoint" message, before construction.
- A construction failure converts to a 404 only when it is the core data
store's own invalid-product signal for a nonzero ID whose post is gone.
Everything else is rethrown, so typed failures from extension-backed stores
keep their own codes and statuses.
- A non-scalar type returns 400 woocommerce_rest_invalid_product_type instead
of reaching explode() and raising a TypeError.
- A type that resolves to no class falls back to the product's stored class,
resolved through WC_Product_Factory::get_product_classname() so that
filter-registered classes win. It previously fell back to WC_Product_Simple,
which rewrote the stored product_type term and orphaned variations.
- The duplicate endpoints return preparation errors rather than passing a
WP_Error into product_duplicate(), which had produced a 200 carrying an
empty product.
Backward compatibility: no signature or hook changes. Typed writes invoke
woocommerce_product_type_query once more during preparation, and the
stored-type fallback invokes woocommerce_product_class once more on the
requests that reach it. Both already fire on these requests via
wc_get_product(), so neither is a new context. Previously fatal requests now
return 404s, and the variation-duplicate 200-with-empty-product becomes a 404.
Refs #51209
diff --git a/plugins/woocommerce/changelog/fix-51209-rest-uncaught-invalid-product b/plugins/woocommerce/changelog/fix-51209-rest-uncaught-invalid-product
new file mode 100644
index 00000000000..ba549463cb8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-51209-rest-uncaught-invalid-product
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent REST product update and duplicate requests from fatally erroring when given a variation ID, and preserve the stored product type instead of converting the product to simple when the requested type names no known class.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index ec68f8fb8fc..2d527762008 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
use Automattic\WooCommerce\Internal\Traits\RestApiCache;
use Automattic\WooCommerce\Utilities\I18nUtil;
use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -27,6 +28,7 @@ defined( 'ABSPATH' ) || exit;
*/
class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
+ use ProductRequestPreparationTrait;
use RestApiCache;
/**
@@ -1079,31 +1081,10 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
* @return WP_Error|WC_Data
*/
protected function prepare_object_for_database( $request, $creating = false ) {
- $id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
+ $product = $this->get_product_for_rest_request( $request );
- // Type is the most important part here because we need to be using the correct class and methods.
- if ( isset( $request['type'] ) ) {
- $classname = WC_Product_Factory::get_classname_from_product_type( $request['type'] );
-
- if ( ! class_exists( $classname ) ) {
- $classname = 'WC_Product_Simple';
- }
-
- $product = new $classname( $id );
- } elseif ( isset( $request['id'] ) ) {
- $product = wc_get_product( $id );
- } else {
- $product = new WC_Product_Simple();
- }
-
- if ( ProductType::VARIATION === $product->get_type() ) {
- return new WP_Error(
- "woocommerce_rest_invalid_{$this->post_type}_id",
- __( 'To manipulate product variations you should use the /products/<product_id>/variations/<id> endpoint.', 'woocommerce' ),
- array(
- 'status' => 404,
- )
- );
+ if ( is_wp_error( $product ) ) {
+ return $product;
}
// Post title.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index a133d36cbd0..98de4761783 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Utilities\I18nUtil;
use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -29,6 +30,7 @@ defined( 'ABSPATH' ) || exit;
class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
use CogsAwareRestControllerTrait;
+ use ProductRequestPreparationTrait;
/**
* Endpoint namespace.
@@ -146,7 +148,12 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
}
// Creating product object from request data in preparation for copying.
- $updated_product = $this->prepare_object_for_database( $request );
+ $updated_product = $this->prepare_product_for_duplication( $request );
+
+ if ( is_wp_error( $updated_product ) ) {
+ return $updated_product;
+ }
+
$duplicated_product = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $updated_product );
if ( is_wp_error( $duplicated_product ) ) {
@@ -740,31 +747,10 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
* @return WP_Error|WC_Data
*/
protected function prepare_object_for_database( $request, $creating = false ) {
- $id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
-
- // Type is the most important part here because we need to be using the correct class and methods.
- if ( isset( $request['type'] ) ) {
- $classname = WC_Product_Factory::get_classname_from_product_type( $request['type'] );
-
- if ( ! class_exists( $classname ) ) {
- $classname = 'WC_Product_Simple';
- }
+ $product = $this->get_product_for_rest_request( $request );
- $product = new $classname( $id );
- } elseif ( isset( $request['id'] ) ) {
- $product = wc_get_product( $id );
- } else {
- $product = new WC_Product_Simple();
- }
-
- if ( ProductType::VARIATION === $product->get_type() ) {
- return new WP_Error(
- "woocommerce_rest_invalid_{$this->post_type}_id",
- __( 'To manipulate product variations you should use the /products/<product_id>/variations/<id> endpoint.', 'woocommerce' ),
- array(
- 'status' => 404,
- )
- );
+ if ( is_wp_error( $product ) ) {
+ return $product;
}
// Post title.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0fb4f327e79..7ce68d17b90 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -26760,90 +26760,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
- -
- message: '#^Cannot call method get_type\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_catalog_visibility\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_description\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_featured\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_menu_order\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_name\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_purchase_note\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_reviews_allowed\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_short_description\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_slug\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_status\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_tax_class\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_tax_status\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
- -
- message: '#^Cannot call method set_virtual\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-
message: '#^Method WC_REST_Products_V2_Controller\:\:batch_items\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
identifier: missingType.generics
@@ -26934,12 +26850,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
- -
- message: '#^Parameter \#1 \$classname of function class_exists expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-
message: '#^Parameter \#1 \$date of function wc_rest_prepare_date_response expects string\|WC_DateTime\|null, int\<1, max\> given\.$#'
identifier: argument.type
@@ -26982,12 +26892,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
- -
- message: '#^Parameter \#1 \$product of method WC_REST_Products_V2_Controller\:\:save_product_shipping_data\(\) expects WC_Product, object\|false\|null given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-
message: '#^Parameter \#1 \$quantity of method WC_Product\:\:set_stock_quantity\(\) expects float\|null, string given\.$#'
identifier: argument.type
@@ -29895,96 +29799,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
- -
- message: '#^Cannot call method get_type\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_catalog_visibility\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_description\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_featured\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_menu_order\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_name\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_post_password\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_purchase_note\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_reviews_allowed\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_short_description\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_slug\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_status\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_tax_class\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_tax_status\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Cannot call method set_virtual\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-
message: '#^Method WC_Product\:\:has_options\(\) invoked with 1 parameter, 0 required\.$#'
identifier: arguments.count
@@ -30069,12 +29883,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
- -
- message: '#^Parameter \#1 \$classname of function class_exists expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-
message: '#^Parameter \#1 \$date of function wc_rest_prepare_date_response expects string\|WC_DateTime\|null, int\|false given\.$#'
identifier: argument.type
@@ -30111,18 +29919,6 @@ parameters:
count: 2
path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
- -
- message: '#^Parameter \#1 \$product of method WC_Admin_Duplicate_Product\:\:product_duplicate\(\) expects WC_Product, WC_Data\|WP_Error given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
- -
- message: '#^Parameter \#1 \$product of method WC_REST_Products_V2_Controller\:\:save_product_shipping_data\(\) expects WC_Product, object\|false\|null given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-
message: '#^Parameter \#1 \$quantity of method WC_Product\:\:set_stock_quantity\(\) expects float\|null, string given\.$#'
identifier: argument.type
@@ -64962,96 +64758,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Products/Controller.php
- -
- message: '#^Cannot call method get_type\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_catalog_visibility\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_description\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_featured\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_menu_order\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_name\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_post_password\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_purchase_note\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_reviews_allowed\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_short_description\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_slug\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_status\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_tax_class\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_tax_status\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Cannot call method set_virtual\(\) on object\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-
message: '#^Method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products\\Controller\:\:create_item\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
identifier: missingType.generics
@@ -65148,12 +64854,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Products/Controller.php
- -
- message: '#^Parameter \#1 \$classname of function class_exists expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-
message: '#^Parameter \#1 \$date of function wc_rest_prepare_date_response expects string\|WC_DateTime\|null, int\|false given\.$#'
identifier: argument.type
@@ -65202,18 +64902,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Products/Controller.php
- -
- message: '#^Parameter \#1 \$product of method WC_Admin_Duplicate_Product\:\:product_duplicate\(\) expects WC_Product, Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products\\WC_Data\|WP_Error given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
- -
- message: '#^Parameter \#1 \$product of method WC_REST_Products_V2_Controller\:\:save_product_shipping_data\(\) expects WC_Product, object\|false\|null given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-
message: '#^Parameter \#1 \$quantity of method WC_Product\:\:set_stock_quantity\(\) expects float\|null, string given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/RestApi/ProductRequestPreparationTrait.php b/plugins/woocommerce/src/Internal/RestApi/ProductRequestPreparationTrait.php
new file mode 100644
index 00000000000..7d8f2272c54
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/ProductRequestPreparationTrait.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Product REST request preparation helpers.
+ *
+ * @package WooCommerce\Internal\RestApi
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi;
+
+use Automattic\WooCommerce\Enums\ProductType;
+
+/**
+ * Shared product construction for REST write requests.
+ */
+trait ProductRequestPreparationTrait {
+
+ /**
+ * Get the product instance targeted by a REST write request.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+ * @return \WC_Product|\WP_Error
+ * @throws \Exception When construction fails for any reason other than the core data store's invalid-product failure for an ID that no longer resolves to a product post.
+ */
+ private function get_product_for_rest_request( $request ) {
+ $id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
+
+ if ( isset( $request['type'] ) && ! is_scalar( $request['type'] ) ) {
+ // Falling back to a default class here would silently rewrite the product's type on update.
+ return new \WP_Error(
+ "woocommerce_rest_invalid_{$this->post_type}_type",
+ __( 'Invalid product type.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $existing_product_type = isset( $request['type'] ) && $id ? \WC_Product_Factory::get_product_type( $id ) : false;
+
+ if ( ProductType::VARIATION === $existing_product_type ) {
+ return $this->get_invalid_product_id_error( true );
+ }
+
+ if ( isset( $request['type'] ) ) {
+ $classname = \WC_Product_Factory::get_classname_from_product_type( (string) $request['type'] );
+
+ if ( ! $classname || ! class_exists( $classname ) ) {
+ // Fall back to the stored type rather than silently converting the product to simple.
+ // get_product_classname() resolves it through woocommerce_product_class, so a stored
+ // class registered only by that filter still wins. The requested type is not resolved
+ // that way. The string check guards woocommerce_product_type_query, which can return
+ // any truthy value, and a non-string one would fatal while the class name is built.
+ $classname = is_string( $existing_product_type ) && '' !== $existing_product_type
+ ? \WC_Product_Factory::get_product_classname( $id, $existing_product_type )
+ : 'WC_Product_Simple';
+ }
+
+ try {
+ $product = new $classname( $id );
+ } catch ( \Exception $e ) {
+ // Convert only the core data store's own invalid-product failure for a nonzero
+ // target that is no longer a product post; absence of a post proves deletion
+ // there because wp_delete_post() invalidates the posts cache (unlike
+ // WooCommerce's products cache group). The throw site stands in for that
+ // failure: a store that reads its own backend throws from its own file, so it
+ // is rethrown. A store that delegates to parent::read() throws from the core
+ // file instead and is converted, which is why the post-type check below has to
+ // carry the decision on its own. Everything else is rethrown unchanged, since
+ // typed exceptions carry their own codes and extension-backed stores may fail
+ // transiently for IDs that never had a post.
+ $is_core_invalid_product = ! ( $e instanceof \WC_Data_Exception )
+ && ( new \ReflectionClass( \WC_Product_Data_Store_CPT::class ) )->getFileName() === $e->getFile();
+ $target_post_type = get_post_type( $id );
+
+ if ( ! $is_core_invalid_product || ! $id || 'product' === $target_post_type ) {
+ throw $e;
+ }
+
+ return $this->get_invalid_product_id_error( 'product_variation' === $target_post_type );
+ }
+ } elseif ( isset( $request['id'] ) ) {
+ $product = wc_get_product( $id );
+ } else {
+ $product = new \WC_Product_Simple();
+ }
+
+ if ( ! $product instanceof \WC_Product ) {
+ return $this->get_invalid_product_id_error();
+ }
+
+ return ProductType::VARIATION === $product->get_type()
+ ? $this->get_invalid_product_id_error( true )
+ : $product;
+ }
+
+ /**
+ * Prepare the product targeted by a duplicate request, converting typed failures to errors.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+ * @return \WC_Product|\WP_Error
+ */
+ private function prepare_product_for_duplication( $request ) {
+ try {
+ $product = $this->prepare_object_for_database( $request );
+ } catch ( \WC_Data_Exception $e ) {
+ return new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
+ }
+
+ if ( is_wp_error( $product ) ) {
+ return $product;
+ }
+
+ // The pre-insert filter runs after the trait's guarantees, so the shape must be re-checked.
+ if ( ! $product instanceof \WC_Product ) {
+ return new \WP_Error(
+ "woocommerce_rest_{$this->post_type}_not_created",
+ __( 'Invalid product.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $product;
+ }
+
+ /**
+ * Build the invalid-product error used by product write endpoints.
+ *
+ * @param bool $is_variation Whether the target is a product variation.
+ * @return \WP_Error
+ */
+ private function get_invalid_product_id_error( bool $is_variation = false ): \WP_Error {
+ return new \WP_Error(
+ "woocommerce_rest_invalid_{$this->post_type}_id",
+ $is_variation
+ ? __( 'To manipulate product variations you should use the /products/<product_id>/variations/<id> endpoint.', 'woocommerce' )
+ : __( 'Invalid product ID.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index d4bcdeb2f88..cabee67c3ce 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -20,6 +20,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\WeightUnit;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Utilities\I18nUtil;
use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -32,8 +33,6 @@ use WC_Admin_Duplicate_Product;
use WC_REST_CRUD_Controller;
use WC_Data_Store;
use WC_Product_Attribute;
-use WC_Product_Factory;
-use WC_Product_Simple;
use WC_REST_Exception;
@@ -47,6 +46,7 @@ defined( 'ABSPATH' ) || exit;
class Controller extends WC_REST_Products_V2_Controller {
use CogsAwareRestControllerTrait;
+ use ProductRequestPreparationTrait;
/**
* Fields stripped from the response for users without product management capabilities
@@ -239,7 +239,12 @@ class Controller extends WC_REST_Products_V2_Controller {
}
// Creating product object from request data in preparation for copying.
- $updated_product = $this->prepare_object_for_database( $request );
+ $updated_product = $this->prepare_product_for_duplication( $request );
+
+ if ( is_wp_error( $updated_product ) ) {
+ return $updated_product;
+ }
+
$duplicated_product = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $updated_product );
if ( is_wp_error( $duplicated_product ) ) {
@@ -1012,31 +1017,10 @@ class Controller extends WC_REST_Products_V2_Controller {
* @return WP_Error|WC_Data
*/
protected function prepare_object_for_database( $request, $creating = false ) {
- $id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
-
- // Type is the most important part here because we need to be using the correct class and methods.
- if ( isset( $request['type'] ) ) {
- $classname = WC_Product_Factory::get_classname_from_product_type( $request['type'] );
-
- if ( ! class_exists( $classname ) ) {
- $classname = 'WC_Product_Simple';
- }
+ $product = $this->get_product_for_rest_request( $request );
- $product = new $classname( $id );
- } elseif ( isset( $request['id'] ) ) {
- $product = wc_get_product( $id );
- } else {
- $product = new WC_Product_Simple();
- }
-
- if ( ProductType::VARIATION === $product->get_type() ) {
- return new WP_Error(
- "woocommerce_rest_invalid_{$this->post_type}_id",
- __( 'To manipulate product variations you should use the /products/<product_id>/variations/<id> endpoint.', 'woocommerce' ),
- array(
- 'status' => 404,
- )
- );
+ if ( is_wp_error( $product ) ) {
+ return $product;
}
// Post title.
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
index 823574633c4..71f15444f97 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
@@ -450,4 +450,20 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
}
+
+ /**
+ * @testdox Updating a variation through the products endpoint returns the existing variation endpoint error.
+ */
+ public function test_update_with_variation_id_and_type_returns_error_response(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_id = $variable_product->get_children()[0];
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v2/products/' . $variation_id );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index e56ed02e009..8a62dc18e66 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -2208,4 +2208,474 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
}
+
+ /**
+ * @testdox Updating a product using a variation ID and a type param returns an error response instead of a fatal error.
+ */
+ public function test_update_with_variation_id_and_type_returns_error_response(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_id = $variable_product->get_children()[0];
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $variation_id );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Updating a product can still change its product type.
+ */
+ public function test_update_can_change_product_type(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+ $request->set_body_params( array( 'type' => 'variable' ) );
+
+ $response = $this->server->dispatch( $request );
+ $updated_product = wc_get_product( $product->get_id() );
+
+ $this->assertSame( 200, $response->get_status(), 'Valid product type changes should continue to succeed.' );
+ $this->assertInstanceOf( WC_Product_Variable::class, $updated_product );
+ }
+
+ /**
+ * @testdox Product preparation allows an extension-backed product whose ID collides with a variation post.
+ */
+ public function test_prepare_object_allows_extension_product_when_id_collides_with_variation_post(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $product_id = $variable_product->get_children()[0];
+
+ $custom_data_store = new class() extends WC_Product_Data_Store_CPT {
+ /**
+ * Number of products read through the extension data store.
+ *
+ * @var int
+ */
+ public $read_count = 0;
+
+ /**
+ * Mark the extension-backed product as read without loading the colliding WordPress post.
+ *
+ * @param WC_Product $product Product being read.
+ */
+ public function read( &$product ) {
+ ++$this->read_count;
+ $product->set_object_read( true );
+ }
+ };
+ $register_custom_data_store = static function ( $data_stores ) use ( $custom_data_store ) {
+ $data_stores['product-simple'] = $custom_data_store;
+ return $data_stores;
+ };
+ $resolve_custom_product_type = static function ( $product_type, $queried_product_id ) use ( $product_id ) {
+ return $product_id === $queried_product_id ? ProductType::SIMPLE : $product_type;
+ };
+ add_filter( 'woocommerce_data_stores', $register_custom_data_store );
+ add_filter( 'woocommerce_product_type_query', $resolve_custom_product_type, 10, 2 );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product_id );
+ $request->set_url_params( array( 'id' => $product_id ) );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $result = $this->invoke_prepare( $request, false );
+
+ $this->assertInstanceOf( WC_Product_Simple::class, $result );
+ $this->assertSame( $product_id, $result->get_id() );
+ $this->assertSame( 'product_variation', get_post_type( $product_id ) );
+ $this->assertSame( 1, $custom_data_store->read_count, 'Product type detection should not construct the product twice.' );
+ }
+
+ /**
+ * Invoke the protected prepare_object_for_database() on the endpoint under test.
+ *
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating Whether the request creates a new product.
+ * @return mixed
+ */
+ private function invoke_prepare( WP_REST_Request $request, bool $creating ) {
+ $prepare_method = new ReflectionMethod( $this->endpoint, 'prepare_object_for_database' );
+ $prepare_method->setAccessible( true );
+
+ return $prepare_method->invoke( $this->endpoint, $request, $creating );
+ }
+
+ /**
+ * @testdox Updating a product that no longer exists with a type param returns an error response instead of a fatal error.
+ */
+ public function test_prepare_object_returns_error_when_product_deleted_with_type(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+ $request->set_url_params( array( 'id' => $product->get_id() ) );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ // Simulate a concurrent deletion between the update_item() guard and preparation.
+ wp_delete_post( $product->get_id(), true );
+
+ $result = $this->invoke_prepare( $request, false );
+
+ $this->assertWPError( $result );
+ $this->assertEquals( 'woocommerce_rest_invalid_product_id', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Construction failures on the create path are rethrown instead of misreported as an invalid product ID.
+ */
+ public function test_prepare_object_rethrows_create_path_store_failure(): void {
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products' );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $break_store = function ( $stores ) {
+ $stores['product'] = 'WC_Nonexistent_Data_Store';
+ return $stores;
+ };
+ add_filter( 'woocommerce_data_stores', $break_store );
+
+ $this->expectException( Exception::class );
+ $this->expectExceptionMessage( 'Invalid data store.' );
+
+ $this->invoke_prepare( $request, true );
+ }
+
+ /**
+ * @testdox Duplicating a product preserves the error code of a typed data exception thrown during preparation.
+ */
+ public function test_duplicate_preserves_data_exception_error_code(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ // The route guard is served from the warm product instance cache, so the only
+ // read on this route happens inside the preparation construction.
+ $throw_data_exception = function ( $product_id ) use ( $product ) {
+ if ( $product->get_id() === $product_id ) {
+ throw new WC_Data_Exception( 'custom_duplicate_block', 'Simulated typed failure.', 409 );
+ }
+ };
+ add_action( 'woocommerce_product_read', $throw_data_exception );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/duplicate' );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 409, $response->get_status(), 'A typed exception should keep its own HTTP status on the duplicate route' );
+ $this->assertEquals( 'custom_duplicate_block', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox A non-scalar type value in a batch item is rejected instead of silently rewriting the product type.
+ */
+ public function test_batch_update_with_array_type_is_rejected(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/batch' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'update' => array(
+ array(
+ 'id' => $variable_product->get_id(),
+ 'type' => array( 'simple' ),
+ 'name' => 'Renamed via array-type batch',
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'error', $data['update'][0], 'A non-scalar type must be rejected, not coerced' );
+ $this->assertEquals( 'woocommerce_rest_invalid_product_type', $data['update'][0]['error']['code'] );
+ $this->assertInstanceOf( WC_Product_Variable::class, wc_get_product( $variable_product->get_id() ), 'The product type must not be rewritten' );
+ }
+
+ /**
+ * @testdox Typed exceptions from extension-backed product stores are rethrown instead of becoming a 404.
+ */
+ public function test_prepare_object_rethrows_typed_exception_for_extension_backed_product(): void {
+ $typed_store = new class() extends WC_Product_Data_Store_CPT {
+ /**
+ * Simulate an extension backend that is temporarily unavailable.
+ *
+ * @param WC_Product $product Product being read.
+ * @throws WC_Data_Exception Always, with a typed error code and status.
+ */
+ public function read( &$product ) {
+ throw new WC_Data_Exception( 'ext_backend_unavailable', 'Backend unavailable.', 503 );
+ }
+ };
+ $register = static function ( $data_stores ) use ( $typed_store ) {
+ $data_stores['product-simple'] = $typed_store;
+ return $data_stores;
+ };
+ add_filter( 'woocommerce_data_stores', $register );
+
+ // Extension-backed product: the ID does not correspond to any WordPress post.
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/999999991' );
+ $request->set_url_params( array( 'id' => 999999991 ) );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $this->expectException( WC_Data_Exception::class );
+ $this->expectExceptionMessage( 'Backend unavailable.' );
+
+ $this->invoke_prepare( $request, false );
+ }
+
+ /**
+ * @testdox Generic exceptions from extension-backed product stores are rethrown instead of becoming a 404.
+ */
+ public function test_prepare_object_rethrows_generic_exception_for_extension_backed_product(): void {
+ $failing_store = new class() extends WC_Product_Data_Store_CPT {
+ /**
+ * Simulate a transient failure in an extension backend that stores products outside wp_posts.
+ *
+ * @param WC_Product $product Product being read.
+ * @throws RuntimeException Always, simulating a temporary outage.
+ */
+ public function read( &$product ) {
+ throw new RuntimeException( 'Remote backend timeout.' );
+ }
+ };
+ $register = static function ( $data_stores ) use ( $failing_store ) {
+ $data_stores['product-simple'] = $failing_store;
+ return $data_stores;
+ };
+ add_filter( 'woocommerce_data_stores', $register );
+
+ // Extension-backed product: the ID has no corresponding WordPress post.
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/999999992' );
+ $request->set_url_params( array( 'id' => 999999992 ) );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $this->expectException( RuntimeException::class );
+ $this->expectExceptionMessage( 'Remote backend timeout.' );
+
+ $this->invoke_prepare( $request, false );
+ }
+
+ /**
+ * @testdox An extension store reusing the core invalid-product message is rethrown instead of becoming a 404.
+ */
+ public function test_prepare_object_rethrows_extension_exception_reusing_core_message(): void {
+ $mimicking_store = new class() extends WC_Product_Data_Store_CPT {
+ /**
+ * Simulate an extension store that reuses the core failure message for its own failures.
+ *
+ * @param WC_Product $product Product being read.
+ * @throws RuntimeException Always, with the core store's message text.
+ */
+ public function read( &$product ) {
+ throw new RuntimeException( 'Invalid product.' );
+ }
+ };
+ $register = static function ( $data_stores ) use ( $mimicking_store ) {
+ $data_stores['product-simple'] = $mimicking_store;
+ return $data_stores;
+ };
+ add_filter( 'woocommerce_data_stores', $register );
+
+ // Extension-backed product: the ID has no corresponding WordPress post.
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/999999993' );
+ $request->set_url_params( array( 'id' => 999999993 ) );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $this->expectException( RuntimeException::class );
+
+ $this->invoke_prepare( $request, false );
+ }
+
+ /**
+ * @testdox The invalid-product error keeps one code but distinguishes variation targets in its message.
+ */
+ public function test_invalid_product_id_error_distinguishes_variations_in_message(): void {
+ $error_method = new ReflectionMethod( $this->endpoint, 'get_invalid_product_id_error' );
+ $error_method->setAccessible( true );
+
+ $variation_error = $error_method->invoke( $this->endpoint, true );
+ $generic_error = $error_method->invoke( $this->endpoint, false );
+
+ $this->assertEquals( 'woocommerce_rest_invalid_product_id', $variation_error->get_error_code() );
+ $this->assertEquals( 'woocommerce_rest_invalid_product_id', $generic_error->get_error_code() );
+ $this->assertStringContainsString( 'variations', $variation_error->get_error_message() );
+ $this->assertStringNotContainsString( 'variations', $generic_error->get_error_message() );
+ }
+
+ /**
+ * @testdox A falsy type value in a batch item preserves the stored product type instead of causing a fatal error.
+ */
+ public function test_batch_update_with_falsy_type_preserves_stored_type(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/batch' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'update' => array(
+ array(
+ 'id' => $variable_product->get_id(),
+ 'type' => '',
+ 'name' => 'Renamed via falsy-type batch',
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayNotHasKey( 'error', $data['update'][0], 'A falsy type should not fail the item' );
+ $this->assertEquals( 'Renamed via falsy-type batch', $data['update'][0]['name'] );
+ $this->assertInstanceOf( WC_Product_Variable::class, wc_get_product( $variable_product->get_id() ), 'The stored product type must be preserved' );
+ }
+
+ /**
+ * @testdox A stored type whose class is registered only through woocommerce_product_class keeps that class.
+ */
+ public function test_falsy_type_preserves_filter_registered_product_class(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ add_filter(
+ 'woocommerce_product_type_query',
+ static function ( $override, $product_id ) use ( $product ) {
+ return $product->get_id() === $product_id ? 'acme-widget' : $override;
+ },
+ 10,
+ 2
+ );
+ add_filter(
+ 'woocommerce_product_class',
+ static function ( $classname, $product_type ) {
+ return 'acme-widget' === $product_type ? WC_Product_Grouped::class : $classname;
+ },
+ 10,
+ 2
+ );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+ $request->set_url_params( array( 'id' => $product->get_id() ) );
+ $request->set_body_params( array( 'type' => '' ) );
+
+ $result = $this->invoke_prepare( $request, false );
+
+ $this->assertInstanceOf( WC_Product_Grouped::class, $result, 'The filter-registered class must win over the WC_Product_Simple fallback' );
+ }
+
+ /**
+ * @testdox A non-string stored product type falls back to a simple product instead of causing a fatal error.
+ */
+ public function test_non_string_stored_product_type_does_not_fatal(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ add_filter(
+ 'woocommerce_product_type_query',
+ static function ( $override, $product_id ) use ( $product ) {
+ return $product->get_id() === $product_id ? array( ProductType::SIMPLE ) : $override;
+ },
+ 10,
+ 2
+ );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+ $request->set_url_params( array( 'id' => $product->get_id() ) );
+ $request->set_body_params( array( 'type' => '' ) );
+
+ $result = $this->invoke_prepare( $request, false );
+
+ $this->assertInstanceOf( WC_Product_Simple::class, $result );
+ $this->assertSame( $product->get_id(), $result->get_id() );
+ }
+
+ /**
+ * @testdox Product constructor exceptions are rethrown when the product still exists.
+ */
+ public function test_prepare_object_rethrows_unexpected_constructor_exception(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+ $request->set_url_params( array( 'id' => $product->get_id() ) );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $throw_exception = function ( $product_id ) use ( $product ) {
+ if ( $product->get_id() === $product_id ) {
+ throw new Exception( 'Simulated unexpected read failure.' );
+ }
+ };
+ add_action( 'woocommerce_product_read', $throw_exception );
+
+ $this->expectException( Exception::class );
+ $this->expectExceptionMessage( 'Simulated unexpected read failure.' );
+
+ $this->invoke_prepare( $request, false );
+ }
+
+ /**
+ * @testdox Duplicating a product using a variation ID and a type param returns an error response instead of a fatal error.
+ */
+ public function test_duplicate_with_variation_id_and_type_returns_error_response(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_id = $variable_product->get_children()[0];
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $variation_id . '/duplicate' );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Duplicating a product using a variation ID returns the variation endpoint error instead of an empty product.
+ */
+ public function test_duplicate_with_variation_id_returns_error_response(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_id = $variable_product->get_children()[0];
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $variation_id . '/duplicate' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status(), 'Duplicating a variation should return the variations endpoint error.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Batch updates continue processing valid products when a variation is rejected.
+ */
+ public function test_batch_update_handles_variation_error_per_item(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_id = $variable_product->get_children()[0];
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/batch' );
+ $request->set_body_params(
+ array(
+ 'update' => array(
+ array(
+ 'id' => $variation_id,
+ 'type' => 'simple',
+ ),
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Updated in batch',
+ ),
+ ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $data['update'][0]['error']['code'] );
+ $this->assertSame( 'Updated in batch', $data['update'][1]['name'] );
+ $this->assertSame( 'Updated in batch', wc_get_product( $product->get_id() )->get_name() );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index 3412f18e071..39283df1719 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -2037,6 +2037,22 @@ class ProductsControllerTest extends WC_Unit_Test_Case {
$this->assertEquals( ProductStatus::DRAFT, $duplicated_product->get_status() );
}
+ /**
+ * @testdox Duplicating a product using a variation ID and a type param returns an error response instead of a fatal error.
+ */
+ public function test_duplicate_with_variation_id_and_type_returns_error_response(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_id = $variable_product->get_children()[0];
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/products/' . $variation_id . '/duplicate' );
+ $request->set_body_params( array( 'type' => 'simple' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+ $this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+ }
+
/**
* Test the duplicate product endpoint with variable products.
*/