Commit 7ebb4338f26 for woocommerce
commit 7ebb4338f26b8361f0411516a4736b9b831f04c6
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Wed Apr 15 11:22:54 2026 +0200
Fix: missing handling for null meta keys/values in POST/PUT endpoints (#63971)
* Fix: missing handling for null meta keys/values in POST/PUT endpoints.
- Meta entries with a missing key were creating an entry with an empty
key. Now these are discarded.
- Meta entries with a missing value were throwing an "undefined array
key" warning in PHP 8, these are now explicitly converted to null.
Unit tests are added too.
Affected endpoints:
PUT /wc/v2/products/{id}
PUT /wc/v2/products/{parent_id}/variations/{id}
PUT /wc/v2/coupons/{id} (via V3 which extends V2)
PUT /wc/v2/customers/{id}
POST /wc/v2/orders/{order_id}/refunds
PUT /wc/v2/orders/{id}
(line items meta; already had the fix, just cleaned up to use ??)
PUT /wc/v3/products/{id}
PUT /wc/v3/products/{parent_id}/variations/{id}
PUT /wc/v3/orders/{id}
POST /wc/v3/orders/{order_id}/refunds
PUT /wc/v3/coupons/{id} (inherits from V2)
POST /wc/v4/refunds
PUT /wc/v4/products/{id}
PUT /wc/v4/orders/{id} (via UpdateUtils)
PATCH /wc/v3/orders/{order_id}/fulfillments/{fulfillment_id}
PATCH /wc/v3/orders/{order_id}/fulfillments/{fulfillment_id}/metadata
diff --git a/plugins/woocommerce/changelog/pr-63971 b/plugins/woocommerce/changelog/pr-63971
new file mode 100644
index 00000000000..320f6829838
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-63971
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix: missing handling for null meta keys/values in POST/PUT endpoints
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
index a7f8a98eeea..d98bf5d73ce 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
@@ -9,6 +9,7 @@
* @since 2.6.0
*/
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
defined( 'ABSPATH' ) || exit;
@@ -309,11 +310,7 @@ class WC_REST_Coupons_V2_Controller extends WC_REST_CRUD_Controller {
$coupon->set_code( $coupon_code );
break;
case 'meta_data':
- if ( is_array( $value ) ) {
- foreach ( $value as $meta ) {
- $coupon->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ MetaDataUtil::update( $value, $coupon );
break;
case 'description':
$coupon->set_description( wp_filter_post_kses( $value ) );
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php
index 22d9f8d9f36..fda9febe41d 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php
@@ -8,6 +8,8 @@
* @since 2.6.0
*/
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -130,14 +132,11 @@ class WC_REST_Customers_V2_Controller extends WC_REST_Customers_V1_Controller {
// Meta data.
if ( isset( $request['meta_data'] ) ) {
- if ( is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- if ( is_protected_meta( $meta['key'], 'user' ) ) { // bypass internal keys.
- continue;
- }
- $customer->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ $meta_data = array_filter(
+ MetaDataUtil::normalize( $request['meta_data'] ),
+ fn( $meta ) => ! is_protected_meta( $meta['key'], 'user' )
+ );
+ MetaDataUtil::update( $meta_data, $customer );
}
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php
index 0f9320876df..0ad619622e1 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Internal\Utilities\Types;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
/**
* REST API Order Refunds controller class.
@@ -306,10 +307,8 @@ class WC_REST_Order_Refunds_V2_Controller extends WC_REST_Orders_V2_Controller {
return new WP_Error( 'woocommerce_rest_cannot_create_order_refund', __( 'Cannot create order refund, please try again.', 'woocommerce' ), 500 );
}
- if ( ! empty( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
+ if ( ! empty( $request['meta_data'] ) ) {
+ MetaDataUtil::update( $request['meta_data'], $refund );
$refund->save_meta_data();
}
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 024b0566782..742e1c83b08 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
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
// phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps -- Legacy class name, can't change without breaking backward compat.
@@ -924,14 +925,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
* @param array $posted Request data.
*/
protected function maybe_set_item_meta_data( $item, $posted ) {
- if ( ! empty( $posted['meta_data'] ) && is_array( $posted['meta_data'] ) ) {
- foreach ( $posted['meta_data'] as $meta ) {
- if ( isset( $meta['key'] ) ) {
- $value = isset( $meta['value'] ) ? $meta['value'] : null;
- $item->update_meta_data( $meta['key'], $value, isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
- }
+ MetaDataUtil::update( $posted['meta_data'] ?? null, $item );
}
/**
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
index 1159329d4b7..98e8a499f81 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
defined( 'ABSPATH' ) || exit;
@@ -561,11 +562,7 @@ class WC_REST_Product_Variations_V2_Controller extends WC_REST_Products_V2_Contr
}
// Meta data.
- if ( is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $variation->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ MetaDataUtil::update( $request['meta_data'], $variation );
/**
* Filters an object before it is inserted via the REST API.
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 ebc70f0de0f..5fb0e0ed512 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
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\Traits\RestApiCache;
use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
defined( 'ABSPATH' ) || exit;
@@ -1424,11 +1425,7 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
}
// Allow set meta_data.
- if ( is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ MetaDataUtil::update( $request['meta_data'], $product );
/**
* Filters an object before it is inserted via the REST API.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
index bf224ae7f3b..b43ff4c7c2f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\RestApiParameterUtil;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
/**
* REST API Order Refunds controller class.
@@ -70,10 +71,8 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
return new WP_Error( 'woocommerce_rest_cannot_create_order_refund', __( 'Cannot create order refund, please try again.', 'woocommerce' ), 500 );
}
- if ( ! empty( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
+ if ( ! empty( $request['meta_data'] ) ) {
+ MetaDataUtil::update( $request['meta_data'], $refund );
$refund->save_meta_data();
}
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 f8f3f326528..4387452878a 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
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
defined( 'ABSPATH' ) || exit;
@@ -150,11 +151,7 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
}
break;
case 'meta_data':
- if ( is_array( $value ) ) {
- foreach ( $value as $meta ) {
- $order->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ MetaDataUtil::update( $value, $order );
break;
default:
if ( is_callable( array( $order, "set_{$key}" ) ) ) {
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 89ea057c8b5..1582a04d0b9 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
defined( 'ABSPATH' ) || exit;
@@ -389,11 +390,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
}
// Meta data.
- if ( is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $variation->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ MetaDataUtil::update( $request['meta_data'], $variation );
if ( $this->cogs_is_enabled() ) {
$this->set_cogs_info_in_product_object( $request, $variation );
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 8941ec00ec1..7abd1ac3518 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
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
defined( 'ABSPATH' ) || exit;
@@ -1134,11 +1135,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
}
// Allow set meta_data.
- if ( is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ MetaDataUtil::update( $request['meta_data'], $product );
if ( ! empty( $request['date_created'] ) ) {
$date = rest_parse_date( $request['date_created'] );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4ca0a17cf95..aab8436e6d3 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -68376,12 +68376,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Products/Controller.php
- -
- message: '#^Call to method update_meta_data\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products\\WC_Product\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-
message: '#^Cannot access property \$public on WP_Post_Type\|null\.$#'
identifier: property.nonObject
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 0d3a3916fc3..430c34ffb03 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
use WC_Order;
use WP_Http;
@@ -385,17 +386,19 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
$fulfillment->set_props( $request->get_json_params() );
$next_state = $fulfillment->get_is_fulfilled();
- if ( isset( $request->get_json_params()['meta_data'] ) && is_array( $request->get_json_params()['meta_data'] ) ) {
- // Update the meta data keys that exist in the request.
- foreach ( $request->get_json_params()['meta_data'] as $meta ) {
- $fulfillment->update_meta_data( $meta['key'], $meta['value'], $meta['id'] ?? 0 );
- }
-
- // Remove the meta data keys that don't exist in the request, by matching their keys.
- $existing_meta_data = $fulfillment->get_meta_data();
- foreach ( $existing_meta_data as $meta ) {
- if ( ! in_array( $meta->key, array_column( $request->get_json_params()['meta_data'], 'key' ), true ) ) {
- $fulfillment->delete_meta_data( $meta->key );
+ if ( isset( $request->get_json_params()['meta_data'] ) ) {
+ $meta_data = $request->get_json_params()['meta_data'];
+ $normalized_keys = is_array( $meta_data ) ? array_column( MetaDataUtil::normalize( $meta_data, 0 ), 'key' ) : array();
+ MetaDataUtil::update( $meta_data, $fulfillment, 0 );
+
+ // Remove meta keys not in the request. Skip if all entries were malformed
+ // (non-empty input but no valid keys), to avoid accidental data loss.
+ if ( empty( $meta_data ) || ! empty( $normalized_keys ) ) {
+ $existing_meta_data = $fulfillment->get_meta_data();
+ foreach ( $existing_meta_data as $meta ) {
+ if ( ! in_array( $meta->key, $normalized_keys, true ) ) {
+ $fulfillment->delete_meta_data( $meta->key );
+ }
}
}
}
@@ -560,15 +563,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
// Update the meta data keys that exist in the request.
- foreach ( $request->get_json_params()['meta_data'] as $meta ) {
- $fulfillment->update_meta_data( $meta['key'], $meta['value'], $meta['id'] ?? 0 );
- }
+ $meta_data = $request->get_json_params()['meta_data'];
+ $normalized_keys = is_array( $meta_data ) ? array_column( MetaDataUtil::normalize( $meta_data, 0 ), 'key' ) : array();
+ MetaDataUtil::update( $meta_data, $fulfillment, 0 );
- // Remove the meta data keys that don't exist in the request, by matching their keys.
- $existing_meta_data = $fulfillment->get_meta_data();
- foreach ( $existing_meta_data as $meta ) {
- if ( ! in_array( $meta->key, array_column( $request->get_json_params()['meta_data'], 'key' ), true ) ) {
- $fulfillment->delete_meta_data( $meta->key );
+ // Remove meta keys not in the request. Skip if all entries were malformed
+ // (non-empty input but no valid keys), to avoid accidental data loss.
+ if ( empty( $meta_data ) || ! empty( $normalized_keys ) ) {
+ $existing_meta_data = $fulfillment->get_meta_data();
+ foreach ( $existing_meta_data as $meta ) {
+ if ( ! in_array( $meta->key, $normalized_keys, true ) ) {
+ $fulfillment->delete_meta_data( $meta->key );
+ }
}
}
$fulfillment->save();
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
index 3489f01ce5c..69eb0a49a50 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
@@ -17,6 +17,7 @@ use Automattic\WooCommerce\Enums\OrderItemType;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Utilities\ArrayUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Internal\Utilities\Users;
use WC_REST_Exception;
@@ -147,9 +148,7 @@ class UpdateUtils {
* @param array $meta_data Posted data.
*/
protected function update_meta_data( WC_Order $order, array $meta_data ) {
- foreach ( $meta_data as $meta ) {
- $order->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
+ MetaDataUtil::update( $meta_data, $order );
}
/**
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 ff2cb711175..dd0d89107c5 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -19,6 +19,7 @@ use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
use WC_REST_Products_V2_Controller;
use WP_REST_Server;
use WP_REST_Request;
@@ -1215,11 +1216,7 @@ class Controller extends WC_REST_Products_V2_Controller {
}
// Allow set meta_data.
- if ( is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
- }
+ MetaDataUtil::update( $request['meta_data'], $product ); // @phpstan-ignore argument.type (missing `use WC_Product` causes phantom namespace-local type)
if ( ! empty( $request['date_created'] ) ) {
$date = rest_parse_date( $request['date_created'] );
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
index 01b6a786419..37a8ce3d5ca 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -16,6 +16,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
use Automattic\WooCommerce\Utilities\NumberUtil;
use WP_Http;
use WP_Error;
@@ -341,10 +342,8 @@ class Controller extends AbstractController {
return $this->get_route_error_response( 'cannot_create_refund', $refund->get_error_message() );
}
- if ( ! empty( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
- foreach ( $request['meta_data'] as $meta ) {
- $refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
- }
+ if ( ! empty( $request['meta_data'] ) ) {
+ MetaDataUtil::update( $request['meta_data'], $refund );
$refund->save_meta_data();
}
diff --git a/plugins/woocommerce/src/Utilities/MetaDataUtil.php b/plugins/woocommerce/src/Utilities/MetaDataUtil.php
new file mode 100644
index 00000000000..11685bdfe0e
--- /dev/null
+++ b/plugins/woocommerce/src/Utilities/MetaDataUtil.php
@@ -0,0 +1,64 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Utilities;
+
+use WC_Data;
+
+/**
+ * Utility methods for handling meta data in REST API requests.
+ *
+ * @since 10.8.0
+ */
+class MetaDataUtil {
+
+ /**
+ * Normalize and process meta data entries from a REST API request.
+ *
+ * Skips entries without a key, applies defaults for missing 'value' and 'id'
+ * fields, then calls update_meta_data on the given WC_Data object
+ * for each valid entry.
+ *
+ * @since 10.8.0
+ *
+ * @param mixed $meta_data Raw meta data from the request (non-array values are ignored).
+ * @param WC_Data $target A WC_Data object to call update_meta_data on.
+ * @param mixed $default_id Default value for 'id' when not provided (default '').
+ */
+ public static function update( $meta_data, WC_Data $target, $default_id = '' ): void {
+ if ( ! is_array( $meta_data ) ) {
+ return;
+ }
+
+ foreach ( self::normalize( $meta_data, $default_id ) as $meta ) {
+ $target->update_meta_data( $meta['key'], $meta['value'], $meta['id'] );
+ }
+ }
+
+ /**
+ * Normalize an array of raw meta data entries from a REST API request.
+ *
+ * Filters out entries without a key and applies default values for
+ * missing 'value' and 'id' fields. Each returned entry is guaranteed
+ * to have 'key', 'value', and 'id' set.
+ *
+ * @since 10.8.0
+ *
+ * @param array $meta_data Raw meta data array from the request.
+ * @param mixed $default_id Default value for 'id' when not provided (default '').
+ * @return array[] Normalized meta data entries.
+ */
+ public static function normalize( array $meta_data, $default_id = '' ): array {
+ $normalized = array();
+ foreach ( $meta_data as $meta ) {
+ if ( is_array( $meta ) && isset( $meta['key'] ) ) {
+ $normalized[] = array(
+ 'key' => $meta['key'],
+ 'value' => $meta['value'] ?? null,
+ 'id' => $meta['id'] ?? $default_id,
+ );
+ }
+ }
+ return $normalized;
+ }
+}
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index a8ede904145..32d2fa0d9cc 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -298,6 +298,7 @@ class WC_Unit_Tests_Bootstrap {
require_once dirname( $this->tests_dir ) . '/php/helpers/HPOSToggleTrait.php';
require_once dirname( $this->tests_dir ) . '/php/helpers/SerializingCacheTrait.php';
require_once dirname( $this->tests_dir ) . '/php/helpers/LoggerSpyTrait.php';
+ require_once dirname( $this->tests_dir ) . '/php/helpers/MetaDataAssertionTrait.php';
}
/**
diff --git a/plugins/woocommerce/tests/php/helpers/MetaDataAssertionTrait.php b/plugins/woocommerce/tests/php/helpers/MetaDataAssertionTrait.php
new file mode 100644
index 00000000000..81a0443993d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/MetaDataAssertionTrait.php
@@ -0,0 +1,59 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Helpers;
+
+use WC_Data;
+
+/**
+ * Trait MetaDataAssertionTrait.
+ *
+ * Provides shared test input and assertions for verifying that REST API
+ * controllers handle incomplete meta_data entries correctly.
+ */
+trait MetaDataAssertionTrait {
+
+ /**
+ * Returns meta_data input covering all incomplete-entry cases.
+ *
+ * @return array[]
+ */
+ private function get_incomplete_meta_data_input(): array {
+ return array(
+ array( 'value' => 'orphan_value' ),
+ array( 'key' => 'key_missing_value' ),
+ array(
+ 'key' => 'key_explicit_null',
+ 'value' => null,
+ ),
+ array(),
+ array(
+ 'key' => 'complete_key',
+ 'value' => 'complete_value',
+ ),
+ );
+ }
+
+ /**
+ * Asserts that a WC_Data object processed incomplete meta_data entries correctly:
+ * - Complete entries are saved.
+ * - Entries without a key are not processed.
+ * - Entries with a missing value behave the same as passing null explicitly.
+ *
+ * @param WC_Data $wc_data The object whose meta data to check.
+ */
+ private function assert_incomplete_meta_data_handled_correctly( WC_Data $wc_data ): void {
+ $meta_by_key = array();
+ foreach ( $wc_data->get_meta_data() as $meta ) {
+ $meta_by_key[ $meta->key ] = $meta->value;
+ }
+
+ $this->assertEquals( 'complete_value', $meta_by_key['complete_key'] ?? null, 'Complete entry should be saved' );
+ $this->assertArrayNotHasKey( '', $meta_by_key, 'Entry without key should not create a meta data row' );
+ $this->assertSame(
+ $meta_by_key['key_missing_value'] ?? 'NOT_FOUND',
+ $meta_by_key['key_explicit_null'] ?? 'NOT_FOUND',
+ 'Missing value should be equivalent to explicit null'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php
index 443809a0f56..4f5a2533e86 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php
@@ -2,10 +2,14 @@
declare( strict_types = 1 );
// phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps -- legacy conventions.
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
/**
* Tests relating to WC_REST_Customers_V2_Controller.
*/
class WC_REST_Customers_V2_Controller_Tests extends WC_Unit_Test_Case {
+ use MetaDataAssertionTrait;
/**
* @var WC_REST_Customers_V2_Controller System under test.
@@ -193,4 +197,24 @@ class WC_REST_Customers_V2_Controller_Tests extends WC_Unit_Test_Case {
$this->assertEquals( 'test_value', $customer->get_meta( 'test_key' ) );
$this->assertEmpty( $customer->get_meta( '_internal_test_key' ) );
}
+
+ /**
+ * @testdox Updating a customer with incomplete meta_data entries does not cause errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $api_request = new WP_REST_Request( 'PUT', '/wc/v2/customers/' );
+ $api_request->set_body_params(
+ array(
+ 'id' => $this->admin_id,
+ 'meta_data' => $this->get_incomplete_meta_data_input(),
+ )
+ );
+
+ $response = $this->sut->update_item( $api_request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( new \WC_Customer( $this->admin_id ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php
index 27fbc017dac..267e817d4bf 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php
@@ -3,7 +3,14 @@
/**
* Class WC_REST_Order_Refunds_Controller_Test.
*/
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
+/**
+ * Tests for the V2 Order Refunds REST API controller.
+ */
class WC_REST_Order_Refunds_V2_Controller_Test extends WC_REST_Unit_Test_Case {
+ use MetaDataAssertionTrait;
/**
* Test if line, fees and shipping items are all included in refund response.
@@ -56,4 +63,31 @@ class WC_REST_Order_Refunds_V2_Controller_Test extends WC_REST_Unit_Test_Case {
$this->assertContains( 'shipping_lines', array_keys( $data ) );
$this->assertEquals( -20, $data['shipping_lines'][0]['total'] );
}
+
+ /**
+ * @testdox Creating a V2 refund with incomplete meta_data entries does not cause errors.
+ */
+ public function test_create_refund_meta_data_with_incomplete_entries(): void {
+ wp_set_current_user( 1 );
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v2/orders/' . $order->get_id() . '/refunds' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'amount' => '1.00',
+ 'api_refund' => false,
+ 'meta_data' => $this->get_incomplete_meta_data_input(),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 201, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( wc_get_order( $response->get_data()['id'] ) );
+ }
}
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 ee019e04905..bf072c76013 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
@@ -1,12 +1,15 @@
<?php
use Automattic\WooCommerce\Utilities\ArrayUtil;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
/**
* class WC_REST_Products_Controller_Tests.
* Product Controller tests for V2 REST API.
*/
class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
+ use MetaDataAssertionTrait;
+
/**
* @var WC_Product_Simple[]
*/
@@ -423,4 +426,20 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
$this->assertEqualsCanonicalizing( $expected_obtained_data, $actual_data );
}
+
+ /**
+ * @testdox Updating a product via V2 with incomplete meta_data entries does not cause errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v2/products/' . $product->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
index 370e3496a69..b8d1b89218b 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
@@ -4,7 +4,14 @@
* class WC_REST_Coupons_Controller_Tests.
* Coupons Controller tests for V3 REST API.
*/
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
+/**
+ * Coupons Controller tests for V3 REST API.
+ */
class WC_REST_Coupons_Controller_Tests extends WC_REST_Unit_Test_Case {
+ use MetaDataAssertionTrait;
@@ -320,4 +327,21 @@ class WC_REST_Coupons_Controller_Tests extends WC_REST_Unit_Test_Case {
$coupon = new WC_Coupon( $data['id'] );
$this->assertEquals( 'draft', $coupon->get_status() );
}
+
+ /**
+ * @testdox Updating a coupon with incomplete meta_data entries does not cause errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ wp_set_current_user( $this->user );
+ $coupon = \WC_Helper_Coupon::create_coupon();
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/coupons/' . $coupon->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( new \WC_Coupon( $coupon->get_id() ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
index c306efcfa71..723e8a0cbb1 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
@@ -3,7 +3,14 @@
/**
* Class WC_REST_Order_Refunds_Controller_Test.
*/
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
+/**
+ * Tests for the V3 Order Refunds REST API controller.
+ */
class WC_REST_Order_Refunds_Controller_Test extends WC_REST_Unit_Test_Case {
+ use MetaDataAssertionTrait;
/**
* Test if line, fees and shipping items are all included in refund response.
@@ -56,4 +63,31 @@ class WC_REST_Order_Refunds_Controller_Test extends WC_REST_Unit_Test_Case {
$this->assertContains( 'shipping_lines', array_keys( $data ) );
$this->assertEquals( -20, $data['shipping_lines'][0]['total'] );
}
+
+ /**
+ * @testdox Creating a refund with incomplete meta_data entries does not cause errors.
+ */
+ public function test_create_refund_meta_data_with_incomplete_entries(): void {
+ wp_set_current_user( 1 );
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/refunds' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'amount' => '1.00',
+ 'api_refund' => false,
+ 'meta_data' => $this->get_incomplete_meta_data_input(),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 201, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( wc_get_order( $response->get_data()['id'] ) );
+ }
}
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 58fea1ad66b..eed4b0bf2eb 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
@@ -6,6 +6,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableControlle
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
/**
* class WC_REST_Orders_Controller_Tests.
@@ -14,6 +15,7 @@ use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
use HPOSToggleTrait;
use CogsAwareUnitTestSuiteTrait;
+ use MetaDataAssertionTrait;
/**
* Setup our test server, endpoints, and user info.
@@ -922,4 +924,20 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
$item->save();
$order->add_item( $item );
}
+
+ /**
+ * @testdox Updating an order with incomplete meta_data entries does not cause errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ $order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order( $this->user );
+
+ $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( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( wc_get_order( $order->get_id() ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index b9a964662b7..3482165fc2e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -2,12 +2,14 @@
declare( strict_types=1 );
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
/**
* Variations Controller tests for V3 REST API.
*/
class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case {
use CogsAwareUnitTestSuiteTrait;
+ use MetaDataAssertionTrait;
/**
* Runs before each test.
@@ -840,4 +842,21 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case
$this->assertContains( $variations[0]->get_id(), $variation_ids );
$this->assertContains( $variations[1]->get_id(), $variation_ids );
}
+
+ /**
+ * @testdox Updating a variation with incomplete meta_data entries does not cause errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $parent->get_children()[0] );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $parent->get_id() . '/variations/' . $variation->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $variation->get_id() ) );
+ }
}
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 3decf623f2b..896dd63fa95 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
@@ -3,6 +3,7 @@
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
/**
* class WC_REST_Products_Controller_Tests.
@@ -10,6 +11,7 @@ use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
*/
class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case {
use CogsAwareUnitTestSuiteTrait;
+ use MetaDataAssertionTrait;
/**
* Saves the `woocommerce_hide_out_of_stock_items` option value for restoration after tests that modify it.
@@ -2114,4 +2116,20 @@ class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertContains( $visible_product->get_id(), $product_ids );
$this->assertContains( $hidden_product->get_id(), $product_ids );
}
+
+ /**
+ * @testdox Updating a product with incomplete meta_data entries does not cause errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
index 738f48ffe7e..5b68c8f1fd5 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
@@ -3,6 +3,7 @@ declare( strict_types=1 );
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller as RefundsController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery;
@@ -15,6 +16,7 @@ use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
*/
class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
use HPOSToggleTrait;
+ use MetaDataAssertionTrait;
/**
* Endpoint instance.
@@ -1310,4 +1312,28 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
// Clean up product.
$product->delete( true );
}
+
+ /**
+ * @testdox Creating a V4 refund with incomplete meta_data entries does not cause errors.
+ */
+ public function test_create_refund_meta_data_with_incomplete_entries(): void {
+ $order = $this->create_test_order();
+
+ $request = new \WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 1.00,
+ 'meta_data' => $this->get_incomplete_meta_data_input(),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 201, $response->get_status() );
+
+ $refund = wc_get_order( $response->get_data()['id'] );
+ $this->created_refunds[] = $refund->get_id();
+
+ $this->assert_incomplete_meta_data_handled_correctly( $refund );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/UpdateUtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/UpdateUtilsTest.php
new file mode 100644
index 00000000000..881aaf99cc2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/UpdateUtilsTest.php
@@ -0,0 +1,91 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Orders;
+
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\UpdateUtils;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the UpdateUtils class.
+ */
+class UpdateUtilsTest extends WC_Unit_Test_Case {
+ use MetaDataAssertionTrait;
+
+ /**
+ * The System Under Test.
+ *
+ * @var TestableUpdateUtils
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new TestableUpdateUtils();
+ }
+
+ /**
+ * @testdox Should handle incomplete meta_data entries without errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ $order = wc_create_order();
+
+ $this->sut->call_update_meta_data( $order, $this->get_incomplete_meta_data_input() );
+
+ $this->assert_incomplete_meta_data_handled_correctly( $order );
+ }
+
+ /**
+ * @testdox Should skip meta entries where key is explicitly null.
+ */
+ public function test_update_meta_data_with_explicit_null_key(): void {
+ $order = wc_create_order();
+
+ $this->sut->call_update_meta_data(
+ $order,
+ array(
+ array(
+ 'key' => null,
+ 'value' => 'null_key_value',
+ ),
+ array(
+ 'key' => 'valid_key',
+ 'value' => 'valid_value',
+ ),
+ )
+ );
+
+ $meta_by_key = array();
+ foreach ( $order->get_meta_data() as $meta ) {
+ $meta_by_key[ $meta->key ] = $meta->value;
+ }
+
+ $this->assertEquals( 'valid_value', $meta_by_key['valid_key'] ?? null, 'Valid entry should be saved' );
+ $this->assertArrayNotHasKey( '', $meta_by_key, 'Explicit null key should not create a meta data row' );
+ }
+}
+
+/**
+ * Testable subclass that exposes the protected update_meta_data method.
+ *
+ * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
+ * phpcs:disable Squiz.Classes.ClassFileName.NoMatch
+ * phpcs:disable Suin.Classes.PSR4.IncorrectClassName
+ */
+class TestableUpdateUtils extends UpdateUtils {
+
+ /**
+ * Public wrapper for the protected update_meta_data method.
+ *
+ * @param WC_Order $order Order object.
+ * @param array $meta_data Meta data array.
+ */
+ public function call_update_meta_data( WC_Order $order, array $meta_data ) {
+ $this->update_meta_data( $order, $meta_data );
+ }
+}
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 f339e591f8b..febf312589b 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
@@ -7,6 +7,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Products\Controller as ProductsController;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
use WC_Helper_Product;
use WC_REST_Unit_Test_Case;
use WP_REST_Request;
@@ -19,6 +20,7 @@ use WP_REST_Request;
*/
class ProductsControllerTest extends WC_REST_Unit_Test_Case {
use CogsAwareUnitTestSuiteTrait;
+ use MetaDataAssertionTrait;
/**
@@ -2127,6 +2129,20 @@ class ProductsControllerTest extends WC_REST_Unit_Test_Case {
$this->assertEquals( 'woocommerce_rest_cannot_view', $response->get_data()['code'] );
}
+ /**
+ * @testdox Updating a product via V4 with incomplete meta_data entries does not cause errors.
+ */
+ public function test_update_meta_data_with_incomplete_entries(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->update_product_via_post_request(
+ $product,
+ array( 'meta_data' => $this->get_incomplete_meta_data_input() )
+ );
+
+ $this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
+ }
+
/**
* @testdox Should strip sensitive fields from response when author views a published product.
*/
diff --git a/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php
new file mode 100644
index 00000000000..f93463e522e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php
@@ -0,0 +1,186 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Utilities;
+
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the MetaDataUtil class.
+ */
+class MetaDataUtilTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox `normalize` keeps complete entries and applies all fields.
+ */
+ public function test_normalize_keeps_complete_entries(): void {
+ $result = MetaDataUtil::normalize(
+ array(
+ array(
+ 'key' => 'color',
+ 'value' => 'red',
+ 'id' => 42,
+ ),
+ )
+ );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'color', $result[0]['key'] );
+ $this->assertSame( 'red', $result[0]['value'] );
+ $this->assertSame( 42, $result[0]['id'] );
+ }
+
+ /**
+ * @testdox `normalize` filters out entries without a key.
+ */
+ public function test_normalize_filters_entries_without_key(): void {
+ $result = MetaDataUtil::normalize(
+ array(
+ array( 'value' => 'orphan' ),
+ array(),
+ array(
+ 'key' => 'valid',
+ 'value' => 'val',
+ ),
+ )
+ );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'valid', $result[0]['key'] );
+ }
+
+ /**
+ * @testdox `normalize` filters out entries where key is explicitly null.
+ */
+ public function test_normalize_filters_entries_with_null_key(): void {
+ $result = MetaDataUtil::normalize(
+ array(
+ array(
+ 'key' => null,
+ 'value' => 'val',
+ ),
+ )
+ );
+
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * @testdox `normalize` defaults missing value to null.
+ */
+ public function test_normalize_defaults_missing_value_to_null(): void {
+ $result = MetaDataUtil::normalize(
+ array(
+ array( 'key' => 'flag' ),
+ )
+ );
+
+ $this->assertCount( 1, $result );
+ $this->assertNull( $result[0]['value'] );
+ }
+
+ /**
+ * @testdox `normalize` defaults missing id to empty string.
+ */
+ public function test_normalize_defaults_missing_id_to_empty_string(): void {
+ $result = MetaDataUtil::normalize(
+ array(
+ array(
+ 'key' => 'k',
+ 'value' => 'v',
+ ),
+ )
+ );
+
+ $this->assertSame( '', $result[0]['id'] );
+ }
+
+ /**
+ * @testdox `normalize` uses the provided default_id.
+ */
+ public function test_normalize_uses_custom_default_id(): void {
+ $result = MetaDataUtil::normalize(
+ array(
+ array(
+ 'key' => 'k',
+ 'value' => 'v',
+ ),
+ ),
+ 0
+ );
+
+ $this->assertSame( 0, $result[0]['id'] );
+ }
+
+ /**
+ * @testdox `update` calls update_meta_data on a WC_Data object for each valid entry.
+ */
+ public function test_update_with_wc_data_object(): void {
+ $order = wc_create_order();
+
+ MetaDataUtil::update(
+ array(
+ array( 'value' => 'orphan' ),
+ array(
+ 'key' => 'color',
+ 'value' => 'blue',
+ ),
+ ),
+ $order
+ );
+
+ $meta_by_key = array();
+ foreach ( $order->get_meta_data() as $meta ) {
+ $meta_by_key[ $meta->key ] = $meta->value;
+ }
+
+ $this->assertArrayHasKey( 'color', $meta_by_key );
+ $this->assertSame( 'blue', $meta_by_key['color'] );
+ $this->assertArrayNotHasKey( '', $meta_by_key, 'Keyless entry should not be processed' );
+ }
+
+ /**
+ * @testdox `update` does nothing when meta_data is not an array.
+ */
+ public function test_update_ignores_non_array_meta_data(): void {
+ $order = wc_create_order();
+
+ MetaDataUtil::update( null, $order );
+ MetaDataUtil::update( 'string', $order );
+
+ $this->assertEmpty( $order->get_meta_data(), 'No meta should be added for non-array meta_data' );
+ }
+
+ /**
+ * @testdox `update` throws TypeError when target is not a WC_Data instance.
+ */
+ public function test_update_throws_for_invalid_target(): void {
+ $this->expectException( \TypeError::class );
+
+ MetaDataUtil::update( array(), 'not_a_wc_data_object' );
+ }
+
+ /**
+ * @testdox `update` passes custom default_id through to normalize.
+ */
+ public function test_update_passes_default_id(): void {
+ $order = wc_create_order();
+
+ MetaDataUtil::update(
+ array(
+ array(
+ 'key' => 'k',
+ 'value' => 'v',
+ ),
+ ),
+ $order,
+ 99
+ );
+
+ $meta_data = $order->get_meta_data();
+ $this->assertCount( 1, $meta_data );
+ $this->assertSame( 'k', $meta_data[0]->key );
+ $this->assertSame( 'v', $meta_data[0]->value );
+ }
+}