Commit c9da0f786a1 for woocommerce
commit c9da0f786a1514fb94193d2c8fbf05ba7968dfdc
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Tue Nov 3 15:28:06 2026 -0300
Send empty extension objects as {} and return amounts as strings (#70445)
* Send empty extension objects as {} and return amounts as strings
An extension field sends an empty array for an `object` as `{}`, at any
depth. A get_callback value that does not match the output schema fails
the call with woocommerce_ability_field_output_invalid, and a callback
that throws fails it with woocommerce_ability_extension_failed. The
output schema needs one type other than null. Product and order amounts
are returned as strings. An error after the save names the saved object.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Name the field error builders with an _error suffix
Rename field_output_invalid() and field_callback_failed() to match
saved_write_error(). Report the new output check with version 11.4.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooai-1628-extension-field-json-rules b/plugins/woocommerce/changelog/wooai-1628-extension-field-json-rules
new file mode 100644
index 00000000000..0270e9a180a
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1628-extension-field-json-rules
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send an empty extension field object as `{}`, and return Core ability amount fields as strings to match their output schema. An extension field value that does not match its schema, or a field callback that throws, now fails the ability call with a named error instead of being left out.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 021b781b597..3dd1da798b6 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -14,8 +14,11 @@ defined( 'ABSPATH' ) || exit;
* a subclass as the `ability_class`. The execute callback loads the object,
* checks the `extensions` input against the field input schemas, changes the object
* in memory, writes the fields with their `set_callback`, runs the validators
- * that ExtensibleAbilities::register_validator() adds, and then saves it. A
- * rejection from any step saves nothing.
+ * that ExtensibleAbilities::register_validator() adds, saves it, and then
+ * prepares the response. A rejection before the save saves nothing. When
+ * prepare_response() returns a WP_Error, the change is already saved, so the
+ * error message names the saved object, and the error data gets `saved` set
+ * to true and the `id` of the object.
*
* The input schema gets the `extensions` property of the resource, so the
* ability accepts the fields with no extra code.
@@ -70,7 +73,7 @@ abstract class ActionableAbility extends \WP_Ability {
* @since 11.3.0
*
* @param \WC_Data $subject Saved object.
- * @return mixed
+ * @return mixed|\WP_Error A WP_Error fails the call, but the change stays saved.
*/
abstract protected function prepare_response( $subject );
@@ -148,7 +151,12 @@ abstract class ActionableAbility extends \WP_Ability {
return $saved;
}
- return $this->prepare_response( $subject );
+ $response = $this->prepare_response( $subject );
+ if ( is_wp_error( $response ) ) {
+ return ExtensibleAbilities::saved_write_error( $response, $this->get_resource(), $subject->get_id() );
+ }
+
+ return $response;
}
/**
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index a50ba416cbf..26878c0c66a 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -54,13 +54,6 @@ class ExtensibleAbilities {
*/
private static array $validators = array();
- /**
- * Fields already reported for a value that does not match the schema, keyed by resource, namespace and field.
- *
- * @var array<string, bool>
- */
- private static array $reported = array();
-
/**
* Whether the store enabled the experimental feature.
*
@@ -127,7 +120,8 @@ class ExtensibleAbilities {
* @type string $namespace Slug of the extension that owns the field, such as `subscriptions`.
* @type string $field Field name inside the namespace, such as `trial_length`.
* @type array $output_schema Optional. JSON schema of the value that `get_callback` returns. Required
- * with `get_callback`. It needs a `type`. Core publishes it as declared. Its
+ * with `get_callback`. It needs one `type` other than null, because clients
+ * expect one type for each field. Core publishes it as declared. Its
* `title` is the label clients show.
* @type string[] $object_types Optional. The object types the field applies to, as `get_type()` of the
* object returns them, such as `simple` and `subscription` for a product or
@@ -137,9 +131,14 @@ class ExtensibleAbilities {
* @type callable $get_callback Optional. Receives the object and returns the value. A field without it
* is not in the output. Required with `output_schema`. Return null when the
* object has no value, and the field is left out with no error. Any other
- * value that `output_schema` does not allow is left out and logged. It reads
- * the object it is given, not the database, because an ability can format
- * an object before it is saved.
+ * value that `output_schema` does not allow fails the call with
+ * `woocommerce_ability_field_output_invalid`. A callback that throws fails
+ * the call with `woocommerce_ability_extension_failed`. It reads the object
+ * it is given, not the database, because an ability can format an object
+ * before it is saved. An empty array for an `object` is sent as `{}`. The
+ * callback must not return a list for an `object` or a `date-time` without
+ * a UTC offset: the schema check accepts them, and a strict client refuses
+ * the whole result.
* @type array $input_schema Optional. JSON schema of the value that `set_callback` receives. It needs
* a `type`. Core publishes it as declared. A value can be null only where it
* declares null. Required with `set_callback`.
@@ -202,6 +201,10 @@ class ExtensibleAbilities {
wc_doing_it_wrong( __METHOD__, 'The "output_schema" argument must be an array with a "type".', '11.4.0' );
return;
}
+ if ( ! is_string( $args['output_schema']['type'] ) || 'null' === $args['output_schema']['type'] ) {
+ wc_doing_it_wrong( __METHOD__, 'The "type" of the "output_schema" argument must be one type name other than "null". Return null to leave the field out.', '11.4.0' );
+ return;
+ }
}
if ( $writable ) {
if ( ! is_callable( $args['set_callback'] ?? null ) ) {
@@ -559,8 +562,11 @@ class ExtensibleAbilities {
/**
* Add the field values of an object to its formatted output, under
* `extensions.<namespace>.<field>`. A value is cast to its schema type, so
- * `'12'` becomes `12` for an integer field. A field that throws, returns null
- * or returns a value that its schema does not allow is left out. A namespace with no
+ * `'12'` becomes `12` for an integer field. A field that returns null is
+ * left out. Any other value that its output schema
+ * does not allow returns a `woocommerce_ability_field_output_invalid` error,
+ * and a get_callback that throws returns a `woocommerce_ability_extension_failed`
+ * error. The ability returns the error. A namespace with no
* values is left out, and `extensions` is left out when no namespace has
* values or the feature is off.
*
@@ -569,9 +575,9 @@ class ExtensibleAbilities {
* @param array<string, mixed> $output Formatted object.
* @param string $resource_name Resource.
* @param object $subject Object to read.
- * @return array<string, mixed>
+ * @return array<string, mixed>|\WP_Error
*/
- public static function add_fields_to_object( array $output, string $resource_name, $subject ): array {
+ public static function add_fields_to_object( array $output, string $resource_name, $subject ) {
if ( empty( self::$fields[ $resource_name ] ) || ! self::is_enabled() ) {
return $output;
}
@@ -585,19 +591,19 @@ class ExtensibleAbilities {
try {
$value = call_user_func( $args['get_callback'], $subject );
} catch ( \Throwable $e ) {
- wc_get_logger()->error(
- sprintf( 'Ability field "%s.%s" of "%s" failed: %s', $namespace, $field, $resource_name, $e->getMessage() ),
- array( 'source' => 'extensible-abilities' )
- );
- continue;
+ return self::field_callback_failed_error( $args, $e );
}
- if ( null === $value || ! self::matches_schema( $args, $value ) ) {
+ if ( null === $value ) {
continue;
}
- $sanitized = rest_sanitize_value_from_schema( $value, $args['output_schema'], $field );
- if ( ! is_wp_error( $sanitized ) ) {
- $extensions[ $namespace ][ $field ] = $sanitized;
+ $valid = rest_validate_value_from_schema( $value, $args['output_schema'], $field );
+ if ( true === $valid ) {
+ $valid = rest_sanitize_value_from_schema( $value, $args['output_schema'], $field );
+ }
+ if ( is_wp_error( $valid ) ) {
+ return self::field_output_invalid_error( $args, $valid );
}
+ $extensions[ $namespace ][ $field ] = self::prepare_json_value( $valid, $args['output_schema'] );
}
}
if ( ! empty( $extensions ) ) {
@@ -708,40 +714,129 @@ class ExtensibleAbilities {
}
/**
- * Whether a value matches the output schema of its field. A value other than null that
- * does not match is logged and reported one time for each field.
+ * Mark an error from a write that saved before it failed. The message names
+ * the saved object, because MCP clients get the message and not the data.
+ * The data gets `saved` set to true and the `id` of the object.
*
- * @param array<string, mixed> $args Field arguments.
- * @param mixed $value Value that the get_callback returned.
- * @return bool
+ * @internal
+ *
+ * @param \WP_Error $error Error after the save.
+ * @param string $resource_name Resource, such as product.
+ * @param int $id ID of the saved object.
+ * @param array<string, mixed> $data More data to add.
+ * @return \WP_Error
*/
- private static function matches_schema( array $args, $value ): bool {
- $valid = rest_validate_value_from_schema( $value, $args['output_schema'], $args['field'] );
- if ( true === $valid ) {
- return true;
- }
+ public static function saved_write_error( \WP_Error $error, string $resource_name, int $id, array $data = array() ): \WP_Error {
+ $current = $error->get_error_data();
+ return new \WP_Error(
+ $error->get_error_code(),
+ sprintf(
+ /* translators: 1: Error message. 2: Resource, such as product. 3: Object ID. */
+ __( '%1$s The change is saved on the %2$s %3$d, so do not send it again.', 'woocommerce' ),
+ $error->get_error_message(),
+ $resource_name,
+ $id
+ ),
+ array_merge(
+ array( 'status' => 400 ),
+ is_array( $current ) ? $current : array(),
+ $data,
+ array(
+ 'saved' => true,
+ 'id' => $id,
+ )
+ )
+ );
+ }
- $key = $args['resource'] . '.' . $args['namespace'] . '.' . $args['field'];
- if ( null === $value || isset( self::$reported[ $key ] ) ) {
- return false;
- }
+ /**
+ * The error for a field value that does not match its schema. It is also
+ * reported with wc_doing_it_wrong(), so the extension developer sees it in the log.
+ *
+ * @param array<string, mixed> $args Field arguments.
+ * @param \WP_Error $error Error of the schema check.
+ * @return \WP_Error
+ */
+ private static function field_output_invalid_error( array $args, \WP_Error $error ): \WP_Error {
+ $message = sprintf(
+ /* translators: 1: Extension namespace. 2: Field name. 3: Resource, such as product. 4: What the field schema expected. */
+ __( 'The "%1$s" extension returned a value for the "%2$s" field of the %3$s that does not match the field schema: %4$s', 'woocommerce' ),
+ $args['namespace'],
+ $args['field'],
+ $args['resource'],
+ $error->get_error_message()
+ );
+ wc_doing_it_wrong( __CLASS__ . '::register_field', $message, '11.4.0' );
+ return new \WP_Error(
+ 'woocommerce_ability_field_output_invalid',
+ $message,
+ array(
+ 'status' => 500,
+ 'namespace' => $args['namespace'],
+ 'field' => $args['field'],
+ 'details' => $error->get_error_message(),
+ )
+ );
+ }
- self::$reported[ $key ] = true;
- wc_get_logger()->error(
- sprintf( 'Ability field "%s.%s" of "%s" was left out because its value does not match its schema: %s', $args['namespace'], $args['field'], $args['resource'], $valid->get_error_message() ),
- array( 'source' => 'extensible-abilities' )
+ /**
+ * The error for a get_callback that throws. The exception text goes to the
+ * log through wc_doing_it_wrong(), and stays out of the response.
+ *
+ * @param array<string, mixed> $args Field arguments.
+ * @param \Throwable $exception Exception that the get_callback threw.
+ * @return \WP_Error
+ */
+ private static function field_callback_failed_error( array $args, \Throwable $exception ): \WP_Error {
+ $message = sprintf(
+ /* translators: 1: Extension namespace. 2: Field name. 3: Resource, such as product. */
+ __( 'The "%1$s" extension failed while reading the "%2$s" field of the %3$s.', 'woocommerce' ),
+ $args['namespace'],
+ $args['field'],
+ $args['resource']
);
- wc_doing_it_wrong(
- __CLASS__ . '::register_field',
- sprintf(
- 'Ability field "%1$s.%2$s" of "%3$s" was left out because its value does not match its schema: %4$s',
- $args['namespace'],
- $args['field'],
- $args['resource'],
- $valid->get_error_message()
- ),
- '11.3.0'
+ wc_doing_it_wrong( __CLASS__ . '::register_field', $message . ' ' . $exception->getMessage(), '11.3.0' );
+ return new \WP_Error(
+ 'woocommerce_ability_extension_failed',
+ $message,
+ array(
+ 'status' => 500,
+ 'namespace' => $args['namespace'],
+ 'field' => $args['field'],
+ )
);
- return false;
+ }
+
+ /**
+ * Send an empty array for an `object` as an empty object, at any depth.
+ * rest_sanitize_value_from_schema() turns every object into an array, so a
+ * callback cannot send `{}` by itself. A part with no schema stays as it is.
+ *
+ * @param mixed $value Sanitized value.
+ * @param array<string, mixed> $schema Schema of the value.
+ * @return mixed The value for the JSON output.
+ */
+ private static function prepare_json_value( $value, array $schema ) {
+ $type = rest_get_best_type_for_value( $value, (array) ( $schema['type'] ?? array() ) );
+ if ( 'array' === $type ) {
+ $children = array_fill_keys( array_keys( $value ), $schema['items'] ?? null );
+ } elseif ( 'object' === $type ) {
+ if ( empty( $value ) ) {
+ return new \stdClass();
+ }
+ $children = array();
+ foreach ( array_keys( $value ) as $key ) {
+ $children[ $key ] = $schema['properties'][ $key ] ?? rest_find_matching_pattern_property_schema( (string) $key, $schema ) ?? ( $schema['additionalProperties'] ?? null );
+ }
+ } else {
+ return $value;
+ }
+
+ foreach ( $children as $key => $child_schema ) {
+ if ( is_array( $child_schema ) ) {
+ $value[ $key ] = self::prepare_json_value( $value[ $key ], $child_schema );
+ }
+ }
+ return $value;
}
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
index c23fc6c6e16..2e80fa8c8f7 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\Domain;
use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
defined( 'ABSPATH' ) || exit;
@@ -103,9 +104,14 @@ class OrderAddNote extends AbstractDomainAbility implements AbilityDefinition {
);
}
+ $formatted = self::format_saved_order_for_response( $order );
+ if ( is_wp_error( $formatted ) ) {
+ return ExtensibleAbilities::saved_write_error( $formatted, 'order', $order->get_id(), array( 'note_id' => (int) $note_id ) );
+ }
+
return array(
'note_id' => (int) $note_id,
- 'order' => self::format_saved_order_for_response( $order ),
+ 'order' => $formatted,
);
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
index 6e25a9eac62..de34d6a7b85 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -206,12 +206,15 @@ class OrderUpdateStatus extends ActionableAbility {
* The ability output for the saved order.
*
* @param \WC_Order $subject Saved order.
- * @return array
+ * @return array|\WP_Error
*/
- public function prepare_response( $subject ): array {
- return array(
- 'order' => self::format_saved_order_for_response( $subject ),
- );
+ public function prepare_response( $subject ) {
+ $order = self::format_saved_order_for_response( $subject );
+ if ( is_wp_error( $order ) ) {
+ return $order;
+ }
+
+ return array( 'order' => $order );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 28a9aa2e4f3..3892bb54b43 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -93,8 +93,13 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
return $order;
}
+ $orders = self::format_orders_for_response( array( $order ), $include_line_items );
+ if ( is_wp_error( $orders ) ) {
+ return $orders;
+ }
+
return array(
- 'orders' => array( self::format_order_for_response( $order, $include_line_items ) ),
+ 'orders' => $orders,
'total_pages' => 1,
'page' => 1,
'per_page' => 1,
@@ -182,13 +187,13 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
);
$pages = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $orders ) > 0 ? 1 : 0 );
+ $orders = self::format_orders_for_response( $orders, $include_line_items );
+ if ( is_wp_error( $orders ) ) {
+ return $orders;
+ }
+
return array(
- 'orders' => array_map(
- static function ( $order ) use ( $include_line_items ) {
- return self::format_order_for_response( $order, $include_line_items );
- },
- $orders
- ),
+ 'orders' => $orders,
'total_pages' => $pages,
'page' => $page,
'per_page' => $per_page,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 60bb2a1bc65..3a352336ff3 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -112,12 +112,15 @@ class ProductCreate extends ActionableAbility {
* The ability output for the saved product.
*
* @param \WC_Product $subject Saved product.
- * @return array
+ * @return array|\WP_Error
*/
- protected function prepare_response( $subject ): array {
- return array(
- 'product' => self::format_saved_product_for_response( $subject ),
- );
+ protected function prepare_response( $subject ) {
+ $product = self::format_saved_product_for_response( $subject );
+ if ( is_wp_error( $product ) ) {
+ return $product;
+ }
+
+ return array( 'product' => $product );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
index b734816355e..db9bdb88afc 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -129,8 +129,17 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
_prime_post_caches( $image_ids );
}
+ $formatted = array();
+ foreach ( $terms as $term ) {
+ $data = self::format_term( $term );
+ if ( is_wp_error( $data ) ) {
+ return $data;
+ }
+ $formatted[] = $data;
+ }
+
return array(
- 'terms' => array_map( array( __CLASS__, 'format_term' ), array_values( $terms ) ),
+ 'terms' => $formatted,
'total' => (int) $total,
'total_pages' => (int) ceil( (int) $total / $per_page ),
'page' => isset( $input['offset'] ) ? (int) floor( $args['offset'] / $per_page ) + 1 : $page,
@@ -142,9 +151,9 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
* Format a term with the fields of the WooCommerce term reads.
*
* @param \WP_Term $term Term to format.
- * @return array
+ * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
*/
- private static function format_term( \WP_Term $term ): array {
+ private static function format_term( \WP_Term $term ) {
$output = array(
'id' => $term->term_id,
'name' => html_entity_decode( $term->name, ENT_QUOTES | ENT_HTML5 ),
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index 649191ea868..d15e7ddfdea 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -161,12 +161,15 @@ class ProductUpdate extends ActionableAbility {
* The ability output for the saved product.
*
* @param \WC_Product $subject Saved product.
- * @return array
+ * @return array|\WP_Error
*/
- protected function prepare_response( $subject ): array {
- return array(
- 'product' => self::format_saved_product_for_response( $subject ),
- );
+ protected function prepare_response( $subject ) {
+ $product = self::format_saved_product_for_response( $subject );
+ if ( is_wp_error( $product ) ) {
+ return $product;
+ }
+
+ return array( 'product' => $product );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index 21d0587b568..d38e284805f 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -108,8 +108,13 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
);
}
+ $products = self::format_products_for_response( array( $product ) );
+ if ( is_wp_error( $products ) ) {
+ return $products;
+ }
+
return array(
- 'products' => array( self::format_product_for_response( $product ) ),
+ 'products' => $products,
'total_pages' => 1,
'page' => 1,
'per_page' => 1,
@@ -171,13 +176,13 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
_prime_post_caches( $image_ids );
}
+ $products = self::format_products_for_response( $products );
+ if ( is_wp_error( $products ) ) {
+ return $products;
+ }
+
return array(
- 'products' => array_map(
- static function ( $product ) {
- return self::format_product_for_response( $product );
- },
- $products
- ),
+ 'products' => $products,
'total_pages' => $pages,
'page' => $page,
'per_page' => $per_page,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
index 349eda893e1..669ae4d1c3f 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
@@ -33,6 +33,17 @@ trait DomainAbilityHelpers {
return $date->format( DATE_ATOM );
}
+ /**
+ * Get a price or total as a string, because the output schema declares Core
+ * amount fields as strings. The getters can return an int or a float.
+ *
+ * @param mixed $amount Value from a getter of the object.
+ * @return mixed
+ */
+ protected static function format_amount_for_response( $amount ) {
+ return is_int( $amount ) ? (string) $amount : wc_float_to_string( $amount );
+ }
+
/**
* Get a collection output schema.
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index cf17c3c421c..6097c081835 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -281,22 +281,41 @@ trait OrderAbilityTrait {
* Format an order after a save, retaining the saved object if reloading fails.
*
* @param \WC_Order $order Saved order object.
- * @return array
+ * @return array|\WP_Error
*/
- protected static function format_saved_order_for_response( \WC_Order $order ): array {
+ protected static function format_saved_order_for_response( \WC_Order $order ) {
$stored = wc_get_order( $order->get_id() );
return self::format_order_for_response( $stored instanceof \WC_Order ? $stored : $order, false );
}
+ /**
+ * Format orders for ability output. One order that fails returns its error.
+ *
+ * @param \WC_Order[] $orders Order objects.
+ * @param bool $include_line_items Whether to include line items.
+ * @return array|\WP_Error
+ */
+ protected static function format_orders_for_response( array $orders, bool $include_line_items ) {
+ $formatted = array();
+ foreach ( $orders as $order ) {
+ $data = self::format_order_for_response( $order, $include_line_items );
+ if ( is_wp_error( $data ) ) {
+ return $data;
+ }
+ $formatted[] = $data;
+ }
+ return $formatted;
+ }
+
/**
* Format an order for ability output.
*
* @param \WC_Order $order Order object.
* @param bool $include_line_items Whether to include line items.
- * @return array
+ * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
*/
- protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ): array {
+ protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ) {
$billing_email = $order->get_billing_email();
$data = array(
@@ -307,7 +326,7 @@ trait OrderAbilityTrait {
get_woocommerce_currency_symbol( $order->get_currency() ),
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
),
- 'total' => $order->get_total(),
+ 'total' => self::format_amount_for_response( $order->get_total() ),
'customer_id' => $order->get_customer_id(),
'payment_method' => $order->get_payment_method(),
'payment_method_title' => $order->get_payment_method_title(),
@@ -321,19 +340,23 @@ trait OrderAbilityTrait {
continue;
}
- $data['line_items'][] = ExtensibleAbilities::add_fields_to_object(
+ $line_item = ExtensibleAbilities::add_fields_to_object(
array(
'id' => $item->get_id(),
'name' => $item->get_name(),
'product_id' => $item->get_product_id(),
'variation_id' => $item->get_variation_id(),
'quantity' => $item->get_quantity(),
- 'subtotal' => $item->get_subtotal(),
- 'total' => $item->get_total(),
+ 'subtotal' => self::format_amount_for_response( $item->get_subtotal() ),
+ 'total' => self::format_amount_for_response( $item->get_total() ),
),
'order_item',
$item
);
+ if ( is_wp_error( $line_item ) ) {
+ return $line_item;
+ }
+ $data['line_items'][] = $line_item;
}
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 88a5d13e467..d27b9fe2768 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -745,21 +745,39 @@ trait ProductAbilityTrait {
* Format a product after a save, retaining the saved object if reloading fails.
*
* @param \WC_Product $product Saved product object.
- * @return array
+ * @return array|\WP_Error
*/
- protected static function format_saved_product_for_response( \WC_Product $product ): array {
+ protected static function format_saved_product_for_response( \WC_Product $product ) {
$stored = wc_get_product( $product->get_id() );
return self::format_product_for_response( $stored instanceof \WC_Product ? $stored : $product );
}
+ /**
+ * Format products for ability output. One product that fails returns its error.
+ *
+ * @param \WC_Product[] $products Product objects.
+ * @return array|\WP_Error
+ */
+ protected static function format_products_for_response( array $products ) {
+ $formatted = array();
+ foreach ( $products as $product ) {
+ $data = self::format_product_for_response( $product );
+ if ( is_wp_error( $data ) ) {
+ return $data;
+ }
+ $formatted[] = $data;
+ }
+ return $formatted;
+ }
+
/**
* Format a product for ability output.
*
* @param \WC_Product $product Product object.
- * @return array
+ * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
*/
- protected static function format_product_for_response( \WC_Product $product ): array {
+ protected static function format_product_for_response( \WC_Product $product ) {
$stock_quantity = $product->get_stock_quantity();
$low_stock_amount = $product->get_low_stock_amount();
$permalink = $product->get_permalink();
@@ -776,9 +794,9 @@ trait ProductAbilityTrait {
get_woocommerce_currency_symbol(),
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
),
- 'price' => $product->get_price(),
- 'regular_price' => $product->get_regular_price(),
- 'sale_price' => $product->get_sale_price(),
+ 'price' => self::format_amount_for_response( $product->get_price() ),
+ 'regular_price' => self::format_amount_for_response( $product->get_regular_price() ),
+ 'sale_price' => self::format_amount_for_response( $product->get_sale_price() ),
'on_sale' => (bool) $product->is_on_sale(),
'stock_status' => $product->get_stock_status(),
'manage_stock' => (bool) $product->get_manage_stock(),
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index e345a49e6f2..bfb29b013b0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -1211,6 +1211,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
)
);
ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'output_schema' => array( 'description' => 'x' ) ) ) );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'output_schema' => array( 'type' => array( 'string', 'null' ) ) ) ) );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'output_schema' => array( 'type' => 'null' ) ) ) );
ExtensibleAbilities::register_field( array_merge( $valid, array( 'namespace' => '0' ) ) );
ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => '123' ) ) );
$order = \WC_Helper_Order::create_order();
@@ -1720,38 +1722,131 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
}
/**
- * @testdox Should leave out a field that throws, returns null or fails sanitization, and cast a value to its schema type.
+ * @testdox Should send an empty array for an object as an empty object at any depth, and keep free-form parts as they are.
+ */
+ public function test_an_empty_array_for_an_object_is_an_empty_object(): void {
+ $output = $this->read_field_values(
+ array(
+ 'flat' => array( array( 'type' => 'object' ), array() ),
+ 'nested' => array(
+ array(
+ 'type' => 'object',
+ 'properties' => array( 'inner' => array( 'type' => 'object' ) ),
+ ),
+ array( 'inner' => array() ),
+ ),
+ 'in_list' => array(
+ array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'object' ),
+ ),
+ array( array() ),
+ ),
+ 'free' => array( array( 'type' => 'object' ), array( 'list' => array() ) ),
+ )
+ );
+
+ $this->assertSame(
+ '{"flat":{},"nested":{"inner":{}},"in_list":[{}],"free":{"list":[]}}',
+ wp_json_encode( $output )
+ );
+ }
+
+ /**
+ * @testdox Should keep an empty array for an array as an empty list.
+ */
+ public function test_an_empty_array_for_an_array_is_an_empty_list(): void {
+ $output = $this->read_field_values(
+ array(
+ 'flat' => array( array( 'type' => 'array' ), array() ),
+ 'of_objects' => array(
+ array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'object' ),
+ ),
+ array(),
+ ),
+ 'in_object' => array(
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'list' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ ),
+ ),
+ ),
+ array( 'list' => array() ),
+ ),
+ )
+ );
+
+ $this->assertSame( '{"flat":[],"of_objects":[],"in_object":{"list":[]}}', wp_json_encode( $output ) );
+ }
+
+ /**
+ * @testdox Should return product amounts as strings when the getters return an int or a float, and save the write.
+ */
+ public function test_product_amounts_are_returned_as_strings(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->add_test_filter( 'woocommerce_product_get_price', static fn() => 12.5 );
+ $this->add_test_filter( 'woocommerce_product_get_regular_price', static fn() => 15 );
+ $this->add_test_filter( 'woocommerce_product_get_sale_price', static fn() => 12.5 );
+
+ $query = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $product->get_id() ) );
+ $write = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Saved',
+ )
+ );
+
+ $this->assertNotWPError( $query );
+ $this->assertSame( '12.5', $query['products'][0]['price'] );
+ $this->assertSame( '15', $query['products'][0]['regular_price'] );
+ $this->assertSame( '12.5', $query['products'][0]['sale_price'] );
+ $this->assertNotWPError( $write );
+ $this->assertSame( '12.5', $write['product']['price'] );
+ $this->assertSame( 'Saved', get_post( $product->get_id() )->post_title );
+ }
+
+ /**
+ * @testdox Should return the order total and line item amounts as strings when the getters return an int or a float.
+ */
+ public function test_order_amounts_are_returned_as_strings(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->add_test_filter( 'woocommerce_order_get_total', static fn() => 40 );
+ $this->add_test_filter( 'woocommerce_order_item_get_subtotal', static fn() => 30 );
+ $this->add_test_filter( 'woocommerce_order_item_get_total', static fn() => 25.5 );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'include_line_items' => true,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( '40', $result['orders'][0]['total'] );
+ $this->assertSame( '30', $result['orders'][0]['line_items'][0]['subtotal'] );
+ $this->assertSame( '25.5', $result['orders'][0]['line_items'][0]['total'] );
+ }
+
+ /**
+ * @testdox Should cast a value to its schema type, and leave out a null.
*/
public function test_field_values_follow_their_schema(): void {
AbilitiesLoader::register_core_resources();
$this->register_test_fields();
$fields = array(
- 'broken' => array(
- 'output_schema' => array( 'type' => 'string' ),
- 'get_callback' => static function () {
- throw new \RuntimeException( 'Broken' );
- },
- ),
- 'count' => array(
+ 'count' => array(
'output_schema' => array( 'type' => 'integer' ),
'get_callback' => static fn() => '12',
),
- 'empty' => array(
+ 'empty' => array(
'output_schema' => array( 'type' => 'string' ),
'get_callback' => '__return_null',
),
- 'none' => array(
- 'output_schema' => array( 'type' => array( 'string', 'null' ) ),
- 'get_callback' => '__return_null',
- ),
- 'dupes' => array(
- 'output_schema' => array(
- 'type' => 'array',
- 'items' => array( 'type' => 'integer' ),
- 'uniqueItems' => true,
- ),
- 'get_callback' => static fn() => array( '12', 12 ),
- ),
);
foreach ( $fields as $field => $args ) {
ExtensibleAbilities::register_field(
@@ -1770,46 +1865,210 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
}
/**
- * @testdox Should leave out and report one time a value that does not match its schema, and keep the output valid.
+ * @testdox Should fail products-query and product-update with a named error when a product field value does not match its schema.
*/
- public function test_a_value_that_does_not_match_its_schema_is_left_out(): void {
+ public function test_a_product_field_value_that_does_not_match_its_schema_fails_the_call(): void {
$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
- add_action(
+ $this->register_invalid_field( 'product', array( 'type' => 'integer' ), 'many' );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen one' ) );
+ \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen two' ) );
+
+ $query = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'search' => 'Pen' ) );
+ $write = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Pencil',
+ )
+ );
+
+ $this->assert_field_output_invalid( $query, 'product' );
+ $this->assert_field_output_invalid( $write, 'product' );
+ $this->assertTrue( $write->get_error_data()['saved'] );
+ $this->assertSame( $product->get_id(), $write->get_error_data()['id'] );
+ $this->assertStringContainsString( 'The change is saved on the product ' . $product->get_id() . ',', $write->get_error_message() );
+ $this->assertSame( 'Pencil', wc_get_product( $product->get_id() )->get_name() );
+ }
+
+ /**
+ * @testdox Should fail orders-query with a named error when an order or line item field value does not match its schema.
+ * @dataProvider invalid_order_field_values
+ *
+ * @param string $resource_name Resource.
+ * @param array $schema Field schema.
+ * @param mixed $value Value that the get_callback returns.
+ */
+ public function test_an_order_field_value_that_does_not_match_its_schema_fails_the_call( string $resource_name, array $schema, $value ): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+ $this->register_invalid_field( $resource_name, $schema, $value );
+ $order = \WC_Helper_Order::create_order();
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'include_line_items' => true,
+ )
+ );
+
+ $this->assert_field_output_invalid( $result, $resource_name );
+ }
+
+ /**
+ * @testdox Should name the saved order and note in the error when order-add-note saves and an order field value then fails its schema.
+ */
+ public function test_an_order_note_that_saves_before_a_field_fails_names_the_saved_note(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+ $this->register_invalid_field( 'order', array( 'type' => 'integer' ), 'many' );
+ $order = \WC_Helper_Order::create_order();
+
+ $result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'note' => 'Packed.',
+ )
+ );
+
+ $this->assert_field_output_invalid( $result, 'order' );
+ $this->assertStringContainsString( 'The change is saved on the order ' . $order->get_id() . ',', $result->get_error_message() );
+ $this->assertTrue( $result->get_error_data()['saved'] );
+ $this->assertSame( $order->get_id(), $result->get_error_data()['id'] );
+ $note = wc_get_order_note( $result->get_error_data()['note_id'] );
+ $this->assertSame( $order->get_id(), (int) get_comment( $note->id )->comment_post_ID );
+ $this->assertSame( 'Packed.', $note->content );
+ }
+
+ /**
+ * @testdox Should name the saved order in the error when order-update-status saves and an order field value then fails its schema.
+ */
+ public function test_an_order_status_that_saves_before_a_field_fails_names_the_saved_order(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+ $this->register_invalid_field( 'order', array( 'type' => 'integer' ), 'many' );
+ $order = \WC_Helper_Order::create_order();
+
+ $result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ )
+ );
+
+ $this->assert_field_output_invalid( $result, 'order' );
+ $this->assertStringContainsString( 'The change is saved on the order ' . $order->get_id() . ',', $result->get_error_message() );
+ $this->assertTrue( $result->get_error_data()['saved'] );
+ $this->assertSame( $order->get_id(), $result->get_error_data()['id'] );
+ $this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
+ }
+
+ /**
+ * @testdox Should fail orders-query with a named error that keeps the exception text out when a get_callback throws.
+ */
+ public function test_a_get_callback_that_throws_fails_the_call(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+ $this->add_test_action(
'woocommerce_extensible_abilities_init',
static function () {
ExtensibleAbilities::register_field(
array(
- 'resource' => 'product',
- 'namespace' => 'test-ext',
- 'field' => 'count',
- 'output_schema' => array( 'type' => 'integer' ),
- 'get_callback' => static fn() => 'many',
+ 'resource' => 'order',
+ 'namespace' => 'invalid-ext',
+ 'field' => 'bad',
+ 'output_schema' => array( 'type' => 'string' ),
+ 'get_callback' => static function () {
+ throw new \RuntimeException( 'Secret failure' );
+ },
)
);
}
);
- $reported = array();
- add_action(
- 'doing_it_wrong_run',
- static function ( $function_name, $message ) use ( &$reported ) {
- $reported[] = $message;
- },
- 10,
- 2
+ $order = \WC_Helper_Order::create_order();
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'id' => $order->get_id() ) );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_ability_extension_failed', $result->get_error_code(), $result->get_error_message() );
+ $this->assertSame( 'The "invalid-ext" extension failed while reading the "bad" field of the order.', $result->get_error_message() );
+ $this->assertSame(
+ array(
+ 'status' => 500,
+ 'namespace' => 'invalid-ext',
+ 'field' => 'bad',
+ ),
+ $result->get_error_data()
);
- $first = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen one' ) );
- $first->update_meta_data( '_test_code', 'A1' );
- $first->save();
- \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen two' ) );
+ }
- $products = array_column( $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen' ) )['products'], null, 'id' );
+ /**
+ * Field values that do not match their schema.
+ *
+ * @return array<string, array{0: string, 1: array, 2: mixed}>
+ */
+ public function invalid_order_field_values(): array {
+ return array(
+ 'order field of the wrong type' => array( 'order', array( 'type' => 'integer' ), 'many' ),
+ 'nested object on a line item' => array(
+ 'order_item',
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'inner' => array(
+ 'type' => 'object',
+ 'properties' => array( 'count' => array( 'type' => 'integer' ) ),
+ ),
+ ),
+ ),
+ array( 'inner' => array( 'count' => 'many' ) ),
+ ),
+ 'list that fails sanitization' => array(
+ 'order',
+ array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'integer' ),
+ 'uniqueItems' => true,
+ ),
+ array( '12', 12 ),
+ ),
+ );
+ }
- $this->assertSame( array( 'code' => 'A1' ), $products[ $first->get_id() ]['extensions']['test-ext'] );
- $matches = array_filter(
- $reported,
- static fn( $message ) => false !== strpos( $message, '"test-ext.count" of "product"' )
+ /**
+ * Register the field invalid-ext.bad on a resource, with a get_callback that returns a fixed value.
+ *
+ * @param string $resource_name Resource.
+ * @param array $schema Field schema.
+ * @param mixed $value Value that the get_callback returns.
+ */
+ private function register_invalid_field( string $resource_name, array $schema, $value ): void {
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () use ( $resource_name, $schema, $value ) {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => $resource_name,
+ 'namespace' => 'invalid-ext',
+ 'field' => 'bad',
+ 'output_schema' => $schema,
+ 'get_callback' => static fn() => $value,
+ )
+ );
+ }
);
- $this->assertCount( 1, $matches );
+ }
+
+ /**
+ * Assert that a result is the error for the invalid-ext.bad field.
+ *
+ * @param mixed $result Ability result.
+ * @param string $resource_name Resource.
+ */
+ private function assert_field_output_invalid( $result, string $resource_name ): void {
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_ability_field_output_invalid', $result->get_error_code(), $result->get_error_message() );
+ $this->assertStringContainsString( '"invalid-ext" extension', $result->get_error_message() );
+ $this->assertStringContainsString( '"bad" field of the ' . $resource_name, $result->get_error_message() );
+ $data = $result->get_error_data();
+ $this->assertSame( 500, $data['status'] );
+ $this->assertSame( 'invalid-ext', $data['namespace'] );
+ $this->assertSame( 'bad', $data['field'] );
+ $this->assertNotEmpty( $data['details'] );
}
/**
@@ -3103,6 +3362,43 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertEmpty( ExtensibleAbilities::get_validators( 'order' ) );
}
+ /**
+ * Register fields on the order resource and read their values.
+ *
+ * @param array<string, array{0: array<string, mixed>, 1: mixed}> $fields Schema and value, keyed by field.
+ * @return array<string, mixed> The values under the namespace.
+ */
+ private function read_field_values( array $fields ): array {
+ AbilitiesLoader::register_core_resources();
+ foreach ( $fields as $field => list( $schema, $value ) ) {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'values',
+ 'field' => $field,
+ 'output_schema' => $schema,
+ 'get_callback' => static fn() => $value,
+ )
+ );
+ }
+
+ $output = ExtensibleAbilities::add_fields_to_object( array(), 'order', \WC_Helper_Order::create_order() );
+ return $output['extensions']['values'];
+ }
+
+ /**
+ * Add a filter and remove it in tearDown.
+ *
+ * @param string $hook Hook name.
+ * @param callable $callback Callback.
+ * @param int $priority Priority.
+ * @param int $accepted_args Number of arguments.
+ */
+ private function add_test_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void {
+ add_filter( $hook, $callback, $priority, $accepted_args );
+ $this->test_actions[] = array( $hook, $callback, $priority );
+ }
+
/**
* Add an action that tearDown removes.
*
@@ -3273,7 +3569,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
private function reset_registries(): void {
global $wp_actions;
- foreach ( array( 'fields', 'reported', 'validators', 'filters' ) as $property ) {
+ foreach ( array( 'fields', 'validators', 'filters' ) as $property ) {
$reflection = new \ReflectionProperty( ExtensibleAbilities::class, $property );
$reflection->setAccessible( true );
$reflection->setValue( null, array() );