Commit 713728a50ec for woocommerce
commit 713728a50ec76ad180d85521e9f6d9d47247feb0
Author: Darren Ethier <darren@roughsmootheng.in>
Date: Mon May 11 13:54:18 2026 -0400
Add canonical WooCommerce domain abilities (#64606)
Add canonical WooCommerce domain abilities for product and order management.
Introduce domain-backed Abilities API definitions for product query/create/update/delete and order query/status update/note creation, with strict WooCommerce-aware schemas, permission checks, extension registration hooks, reserved WooCommerce namespace handling, and REST/MCP projection metadata. Add coverage for registration, validation, permissions, edge cases, and execution behavior.
diff --git a/plugins/woocommerce/changelog/rsm-1335-domain-abilities b/plugins/woocommerce/changelog/rsm-1335-domain-abilities
new file mode 100644
index 00000000000..61d5946a8ed
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-1335-domain-abilities
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add canonical WooCommerce domain abilities for product and order management.
diff --git a/plugins/woocommerce/src/Abilities/AbilityDefinition.php b/plugins/woocommerce/src/Abilities/AbilityDefinition.php
new file mode 100644
index 00000000000..f7b3f9f9c9a
--- /dev/null
+++ b/plugins/woocommerce/src/Abilities/AbilityDefinition.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Ability definition interface file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Abilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Defines a WooCommerce ability registration class.
+ *
+ * @since 10.9.0
+ */
+interface AbilityDefinition {
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string;
+
+ /**
+ * Get the arguments used to register the ability with the WordPress Abilities API.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array;
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php
index 495493fdbc0..95342880d29 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php
@@ -33,6 +33,8 @@ class AbilitiesCategories {
/**
* Register WooCommerce ability categories.
+ *
+ * @since 10.9.0
*/
public static function register_categories(): void {
// Only register if the function exists.
@@ -40,12 +42,22 @@ class AbilitiesCategories {
return;
}
+ if ( ! function_exists( 'wp_has_ability_category' ) || ! wp_has_ability_category( 'woocommerce' ) ) {
+ wp_register_ability_category(
+ 'woocommerce',
+ array(
+ 'label' => __( 'WooCommerce', 'woocommerce' ),
+ 'description' => __( 'Canonical store management abilities backed by domain APIs.', 'woocommerce' ),
+ )
+ );
+ }
+
if ( ! function_exists( 'wp_has_ability_category' ) || ! wp_has_ability_category( 'woocommerce-rest' ) ) {
wp_register_ability_category(
'woocommerce-rest',
array(
'label' => __( 'WooCommerce REST API', 'woocommerce' ),
- 'description' => __( 'REST API operations for WooCommerce resources including products, orders, and other store data.', 'woocommerce' ),
+ 'description' => __( 'REST API operations for store resources including products, orders, and other store data.', 'woocommerce' ),
)
);
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
new file mode 100644
index 00000000000..ad6b96c8209
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -0,0 +1,223 @@
+<?php
+/**
+ * Abilities loader class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Hooks WooCommerce ability definitions into the WordPress Abilities API.
+ */
+class AbilitiesLoader {
+
+ /**
+ * Whether the loader hooks have been registered.
+ *
+ * @var bool
+ */
+ private static bool $initialized = false;
+
+ /**
+ * Canonical WooCommerce domain ability definition classes.
+ *
+ * @var array<int, class-string>
+ */
+ private const CORE_ABILITY_DEFINITION_CLASSES = array(
+ OrdersQuery::class,
+ OrderAddNote::class,
+ OrderUpdateStatus::class,
+ ProductsQuery::class,
+ ProductCreate::class,
+ ProductDelete::class,
+ ProductUpdate::class,
+ );
+
+ /**
+ * Log source for ability registration notices.
+ *
+ * @var string
+ */
+ private const LOG_SOURCE = 'woocommerce-abilities';
+
+ /**
+ * Core ability instances registered by this loader in the current request.
+ *
+ * @var array<string, object>
+ */
+ private static array $registered_core_abilities = array();
+
+ /**
+ * Initialize ability registration hooks.
+ *
+ * @internal
+ *
+ * @since 10.9.0
+ */
+ final public static function init(): void {
+ if ( self::$initialized ) {
+ return;
+ }
+
+ /*
+ * Register abilities when Abilities API is ready.
+ * Support both old (pre-6.9) and new (6.9+) action names.
+ */
+ AbilitiesCategories::init();
+ add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
+ add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
+
+ AbilitiesRestBridge::init();
+
+ self::$initialized = true;
+ }
+
+ /**
+ * Register all configured ability definitions.
+ *
+ * @since 10.9.0
+ */
+ public static function register_abilities(): void {
+ if ( ! function_exists( 'wp_register_ability' ) ) {
+ return;
+ }
+
+ foreach ( self::get_ability_definition_classes() as $class_name ) {
+ if ( ! is_string( $class_name ) || ! class_exists( $class_name ) ) {
+ continue;
+ }
+
+ if ( ! is_a( $class_name, AbilityDefinition::class, true ) ) {
+ continue;
+ }
+
+ $ability_name = $class_name::get_name();
+
+ if ( '' === $ability_name ) {
+ continue;
+ }
+
+ $is_core_ability = self::is_core_ability_definition_class( $class_name );
+
+ if ( self::is_reserved_woocommerce_ability_name( $ability_name ) && ! $is_core_ability ) {
+ continue;
+ }
+
+ if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_name ) ) {
+ $existing_ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
+
+ if (
+ $is_core_ability
+ && isset( self::$registered_core_abilities[ $ability_name ] )
+ && $existing_ability === self::$registered_core_abilities[ $ability_name ]
+ ) {
+ continue;
+ }
+
+ if ( ! $is_core_ability || ! function_exists( 'wp_unregister_ability' ) ) {
+ continue;
+ }
+
+ // Drop stale instance tracking before replacing a shadowed registration.
+ unset( self::$registered_core_abilities[ $ability_name ] );
+ wp_unregister_ability( $ability_name );
+ self::log_replaced_reserved_ability( $ability_name, $class_name );
+ }
+
+ $registered_ability = wp_register_ability( $ability_name, $class_name::get_registration_args() );
+
+ if ( $is_core_ability && null !== $registered_ability ) {
+ self::$registered_core_abilities[ $ability_name ] = $registered_ability;
+ }
+ }
+ }
+
+ /**
+ * Log when WooCommerce replaces a pre-existing registration in its reserved namespace.
+ *
+ * @param string $ability_name Ability name.
+ * @param class-string $class_name Ability definition class name.
+ */
+ private static function log_replaced_reserved_ability( string $ability_name, string $class_name ): void {
+ if ( ! function_exists( 'wc_get_logger' ) ) {
+ return;
+ }
+
+ wc_get_logger()->warning(
+ 'WooCommerce unregistered a previously registered ability before registering its canonical definition.',
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'ability_name' => $ability_name,
+ 'definition_class' => $class_name,
+ 'reserved_prefix' => 'woocommerce/',
+ )
+ );
+ }
+
+ /**
+ * Check whether an ability definition class is provided by WooCommerce core.
+ *
+ * @param class-string $class_name Ability definition class name.
+ * @return bool
+ */
+ private static function is_core_ability_definition_class( string $class_name ): bool {
+ return in_array( $class_name, self::CORE_ABILITY_DEFINITION_CLASSES, true );
+ }
+
+ /**
+ * Check whether an ability name uses WooCommerce's reserved namespace.
+ *
+ * @param string $ability_name Ability name.
+ * @return bool
+ */
+ private static function is_reserved_woocommerce_ability_name( string $ability_name ): bool {
+ return 0 === strpos( $ability_name, 'woocommerce/' );
+ }
+
+ /**
+ * Get all ability definition classes that should be loaded.
+ *
+ * @return array<int, class-string>
+ */
+ private static function get_ability_definition_classes(): array {
+ /**
+ * Filter WooCommerce ability definition classes.
+ *
+ * Extensions can append autoloadable classes that implement
+ * {@see AbilityDefinition}. The loader will call get_name() and
+ * get_registration_args() on each definition class and register the ability on the
+ * Abilities API init hook. Returning a subset will not unregister core abilities;
+ * core classes are always retained.
+ *
+ * @since 10.9.0
+ *
+ * @param array<int, class-string> $classes Ability definition class names.
+ */
+ $classes = apply_filters( 'woocommerce_ability_definition_classes', self::CORE_ABILITY_DEFINITION_CLASSES );
+
+ if ( ! is_array( $classes ) ) {
+ $classes = array();
+ }
+
+ return array_values(
+ array_unique(
+ array_filter(
+ array_merge( self::CORE_ABILITY_DEFINITION_CLASSES, $classes ),
+ 'is_string'
+ )
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php
index ede6d19a450..18626c512e8 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php
@@ -17,19 +17,15 @@ defined( 'ABSPATH' ) || exit;
*/
class AbilitiesRegistry {
- /**
- * Initialize the registry.
- */
- public function __construct() {
- $this->init_abilities();
- }
-
/**
* Initialize all WooCommerce abilities.
+ *
+ * @since 10.9.0
+ *
+ * @internal
*/
- private function init_abilities(): void {
- AbilitiesCategories::init();
- AbilitiesRestBridge::init();
+ final public function init(): void {
+ AbilitiesLoader::init();
}
/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
index f7bc674de89..d90b2a77668 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
@@ -47,7 +47,7 @@ class AbilitiesRestBridge {
'id' => 'woocommerce/products-create',
'operation' => 'create',
'label' => __( 'Create Product', 'woocommerce' ),
- 'description' => __( 'Create a new product in WooCommerce with name, price, description, and other product attributes.', 'woocommerce' ),
+ 'description' => __( 'Create a new product with name, price, description, and other product attributes.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/products-update',
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php b/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php
new file mode 100644
index 00000000000..fe8af063dc6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Domain ability base class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared helpers for WooCommerce domain ability definitions.
+ */
+abstract class AbstractDomainAbility {
+
+ /**
+ * Get a collection output schema.
+ *
+ * @param string $collection_key Collection property key.
+ * @param array $item_schema JSON schema describing a single item in the collection.
+ * @return array
+ */
+ protected static function get_collection_output_schema( string $collection_key, array $item_schema ): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ $collection_key => array(
+ 'type' => 'array',
+ 'description' => sprintf(
+ /* translators: %s: Collection key, such as products or orders. */
+ __( 'Returned %s for the current page.', 'woocommerce' ),
+ $collection_key
+ ),
+ 'items' => $item_schema,
+ ),
+ 'total_pages' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Total number of result pages available for the current query.', 'woocommerce' ),
+ ),
+ 'page' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Current result page.', 'woocommerce' ),
+ ),
+ 'per_page' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Maximum number of items requested per page.', 'woocommerce' ),
+ ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get an entity output schema.
+ *
+ * @param string $entity_key Entity property key.
+ * @param array $item_schema JSON schema describing the entity.
+ * @return array
+ */
+ protected static function get_entity_output_schema( string $entity_key, array $item_schema ): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ $entity_key => $item_schema,
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get a delete output schema.
+ *
+ * @return array
+ */
+ protected static function get_delete_output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'deleted' => array( 'type' => 'boolean' ),
+ 'id' => array( 'type' => 'integer' ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get an ID value from ability input.
+ *
+ * @param mixed $input Ability input.
+ * @return int
+ */
+ protected static function get_id_from_input( $input ): int {
+ return is_array( $input ) && ! empty( $input['id'] ) ? (int) $input['id'] : 0;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
new file mode 100644
index 00000000000..caf469eb270
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Order add note ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce order add note ability.
+ */
+class OrderAddNote extends AbstractDomainAbility implements AbilityDefinition {
+
+ use OrderAbilityTrait;
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/order-add-note';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Add order note', 'woocommerce' ),
+ 'description' => __(
+ 'Add a note to an order.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_order_note_output_schema(),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_edit_order' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Add an order note.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 10.9.0
+ */
+ public static function execute( array $input ) {
+ $order = self::get_order_from_input( $input );
+
+ if ( is_wp_error( $order ) ) {
+ return $order;
+ }
+
+ $note = isset( $input['note'] ) ? trim( wp_kses_post( (string) $input['note'] ) ) : '';
+
+ if ( '' === $note ) {
+ return new \WP_Error(
+ 'woocommerce_order_note_required',
+ __( 'Order note is required.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $note_id = $order->add_order_note(
+ $note,
+ ( (bool) ( $input['customer_note'] ?? false ) ) ? 1 : 0,
+ get_current_user_id() > 0
+ );
+
+ if ( $note_id <= 0 ) {
+ return new \WP_Error(
+ 'woocommerce_order_note_create_failed',
+ __( 'Failed to add order note.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return array(
+ 'note_id' => (int) $note_id,
+ 'order' => self::format_order_for_response( $order, false ),
+ );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'note' => array(
+ 'type' => 'string',
+ 'description' => __( 'Order note content. Safe HTML is allowed.', 'woocommerce' ),
+ 'minLength' => 1,
+ 'pattern' => '\S',
+ ),
+ 'customer_note' => array(
+ 'type' => 'boolean',
+ 'description' => __(
+ 'Whether the note is visible to the customer. Defaults to false for a private/admin note.',
+ 'woocommerce'
+ ),
+ 'default' => false,
+ ),
+ ),
+ 'required' => array( 'id', 'note' ),
+ 'additionalProperties' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
new file mode 100644
index 00000000000..688c084941f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Order update status ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce order update status ability.
+ */
+class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefinition {
+
+ use OrderAbilityTrait;
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/order-update-status';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Update order status', 'woocommerce' ),
+ 'description' => __(
+ 'Update an order status.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_entity_output_schema( 'order', self::get_order_output_schema() ),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_edit_order' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Update an order status.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 10.9.0
+ */
+ public static function execute( array $input ) {
+ $order = self::get_order_from_input( $input );
+
+ if ( is_wp_error( $order ) ) {
+ return $order;
+ }
+
+ if ( empty( $input['status'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_order_status_required',
+ __( 'Order status is required.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $status = OrderUtil::remove_status_prefix( sanitize_key( $input['status'] ) );
+
+ if ( ! in_array( $status, self::get_allowed_order_status_slugs(), true ) ) {
+ return new \WP_Error(
+ 'woocommerce_order_status_invalid',
+ __( 'Order status is invalid.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( $status === $order->get_status() ) {
+ return new \WP_Error(
+ 'woocommerce_order_status_unchanged',
+ __(
+ 'Order already has this status. Use the woocommerce/order-add-note ability to add a note without changing status.',
+ 'woocommerce'
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $updated = $order->update_status(
+ $status,
+ isset( $input['note'] ) ? wp_kses_post( $input['note'] ) : '',
+ true
+ );
+
+ if ( ! $updated ) {
+ return new \WP_Error(
+ 'woocommerce_order_status_update_failed',
+ __( 'Failed to update order status.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return array(
+ 'order' => self::format_order_for_response( $order, false ),
+ );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'description' => __( 'Order status slug without the wc- prefix.', 'woocommerce' ),
+ 'enum' => self::get_allowed_order_status_slugs(),
+ ),
+ 'note' => array(
+ 'type' => 'string',
+ 'description' => __( 'Optional status change note. Safe HTML is allowed. Use the woocommerce/order-add-note ability for notes without a status change.', 'woocommerce' ),
+ ),
+ ),
+ 'required' => array( 'id', 'status' ),
+ 'additionalProperties' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
new file mode 100644
index 00000000000..e0ede9e7f9d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -0,0 +1,356 @@
+<?php
+/**
+ * Orders query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce orders query ability.
+ */
+class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+ use OrderAbilityTrait;
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/orders-query';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Query orders', 'woocommerce' ),
+ 'description' => __(
+ 'Find orders by ID or common order filters.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_collection_output_schema( 'orders', self::get_order_output_schema() ),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_query_orders' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Query orders.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 10.9.0
+ */
+ public static function execute( array $input ) {
+ $include_line_items = (bool) ( $input['include_line_items'] ?? false );
+
+ if ( ! empty( $input['id'] ) ) {
+ $order = self::get_order_from_input( $input );
+
+ if ( is_wp_error( $order ) ) {
+ return $order;
+ }
+
+ return array(
+ 'orders' => array( self::format_order_for_response( $order, $include_line_items ) ),
+ 'total_pages' => 1,
+ 'page' => 1,
+ 'per_page' => 1,
+ );
+ }
+
+ $page = (int) ( $input['page'] ?? 1 );
+ $per_page = (int) ( $input['per_page'] ?? 10 );
+ $args = array(
+ 'limit' => $per_page,
+ 'page' => $page,
+ 'paginate' => true,
+ 'return' => 'objects',
+ 'type' => 'shop_order',
+ );
+
+ foreach ( array( 'status', 'billing_email', 'order' ) as $field ) {
+ if ( ! empty( $input[ $field ] ) && is_scalar( $input[ $field ] ) ) {
+ $args[ $field ] = wc_clean( (string) $input[ $field ] );
+ }
+ }
+
+ if ( empty( $args['status'] ) ) {
+ $args['status'] = self::get_allowed_order_status_slugs();
+ }
+
+ if ( ! empty( $input['orderby'] ) && is_scalar( $input['orderby'] ) ) {
+ $orderby = sanitize_text_field( (string) $input['orderby'] );
+ $args['orderby'] = self::prepare_orderby_arg( $orderby );
+ }
+
+ foreach ( array( 'customer_id', 'parent' ) as $field ) {
+ if ( isset( $input[ $field ] ) ) {
+ $args[ $field ] = (int) $input[ $field ];
+ }
+ }
+
+ if ( ! empty( $input['exclude'] ) && is_array( $input['exclude'] ) ) {
+ $args['exclude'] = array_map( 'intval', $input['exclude'] );
+ }
+
+ foreach ( array( 'date_after', 'date_before' ) as $field ) {
+ if ( ! empty( $input[ $field ] ) ) {
+ $args[ $field ] = wc_clean( $input[ $field ] );
+ }
+ }
+
+ $modified_date_query = self::build_modified_date_query_arg( $input );
+ if ( null !== $modified_date_query ) {
+ $args['date_query'][] = $modified_date_query;
+ }
+
+ $results = wc_get_orders( $args );
+ $orders = is_object( $results ) && isset( $results->orders ) ? $results->orders : array();
+ $orders = array_values(
+ array_filter(
+ $orders,
+ static function ( $order ): bool {
+ return $order instanceof \WC_Order;
+ }
+ )
+ );
+ $pages = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $orders ) > 0 ? 1 : 0 );
+
+ return array(
+ 'orders' => array_map(
+ static function ( $order ) use ( $include_line_items ) {
+ return self::format_order_for_response( $order, $include_line_items );
+ },
+ $orders
+ ),
+ 'total_pages' => $pages,
+ 'page' => $page,
+ 'per_page' => $per_page,
+ );
+ }
+
+ /**
+ * Check order read access.
+ *
+ * @param mixed $input Ability input.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public static function can_query_orders( $input = array() ): bool {
+ $order_id = self::get_id_from_input( $input );
+
+ return wc_rest_check_post_permissions( 'shop_order', 'read', $order_id );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'description' => __( 'Filter by order status slug without the wc- prefix.', 'woocommerce' ),
+ 'enum' => self::get_allowed_order_status_slugs(),
+ ),
+ 'customer_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Filter by customer ID. Use 0 to filter guest orders.', 'woocommerce' ),
+ 'minimum' => 0,
+ ),
+ 'billing_email' => array(
+ 'type' => 'string',
+ 'format' => 'email',
+ ),
+ 'parent' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Filter by parent order ID.', 'woocommerce' ),
+ 'minimum' => 1,
+ ),
+ 'exclude' => array(
+ 'type' => 'array',
+ 'description' => __( 'Order IDs to exclude from the results.', 'woocommerce' ),
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
+ 'date_after' => array(
+ 'type' => 'string',
+ 'description' => __( 'Filter orders created after this date/time.', 'woocommerce' ),
+ 'format' => 'date-time',
+ ),
+ 'date_before' => array(
+ 'type' => 'string',
+ 'description' => __( 'Filter orders created before this date/time.', 'woocommerce' ),
+ 'format' => 'date-time',
+ ),
+ 'modified_after' => array(
+ 'type' => 'string',
+ 'description' => __( 'Filter orders modified after this date/time.', 'woocommerce' ),
+ 'format' => 'date-time',
+ ),
+ 'modified_before' => array(
+ 'type' => 'string',
+ 'description' => __( 'Filter orders modified before this date/time.', 'woocommerce' ),
+ 'format' => 'date-time',
+ ),
+ 'orderby' => array(
+ 'type' => 'string',
+ 'enum' => array( 'id', 'date', 'date_modified', 'total' ),
+ ),
+ 'order' => array(
+ 'type' => 'string',
+ 'enum' => array( 'asc', 'desc' ),
+ ),
+ 'include_line_items' => array(
+ 'type' => 'boolean',
+ 'description' => __(
+ 'Whether to include order line items in each returned order. Defaults to false.',
+ 'woocommerce'
+ ),
+ 'default' => false,
+ ),
+ 'page' => array(
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ ),
+ 'per_page' => array(
+ 'type' => 'integer',
+ 'default' => 10,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ ),
+ ),
+ 'additionalProperties' => false,
+ 'default' => array(),
+ );
+ }
+
+ /**
+ * Build a modified-date query arg from modified_after/modified_before input.
+ *
+ * @param array $input Ability input.
+ * @return array|null
+ */
+ private static function build_modified_date_query_arg( array $input ): ?array {
+ $after = isset( $input['modified_after'] ) && is_string( $input['modified_after'] ) ? sanitize_text_field( $input['modified_after'] ) : '';
+ $before = isset( $input['modified_before'] ) && is_string( $input['modified_before'] ) ? sanitize_text_field( $input['modified_before'] ) : '';
+
+ if ( '' === $after && '' === $before ) {
+ return null;
+ }
+
+ $after_timestamp = '' !== $after ? self::prepare_date_time_for_query( $after ) : null;
+ $before_timestamp = '' !== $before ? self::prepare_date_time_for_query( $before ) : null;
+
+ if (
+ ( '' !== $after && null === $after_timestamp )
+ || ( '' !== $before && null === $before_timestamp )
+ ) {
+ return null;
+ }
+
+ $date_query = array(
+ 'column' => 'post_modified_gmt',
+ 'inclusive' => false,
+ );
+
+ if ( null !== $after_timestamp ) {
+ $date_query['after'] = self::format_timestamp_for_date_query( $after_timestamp );
+ }
+
+ if ( null !== $before_timestamp ) {
+ $date_query['before'] = self::format_timestamp_for_date_query( $before_timestamp );
+ }
+
+ return $date_query;
+ }
+
+ /**
+ * Prepare a date-time string as a timestamp for second-precision order queries.
+ *
+ * @param string $date_time Date-time string.
+ * @return int|null
+ */
+ private static function prepare_date_time_for_query( string $date_time ): ?int {
+ try {
+ return wc_string_to_datetime( $date_time )->getTimestamp();
+ } catch ( \Exception $exception ) {
+ return null;
+ }
+ }
+
+ /**
+ * Format a timestamp for a GMT date query.
+ *
+ * @param int $timestamp Timestamp.
+ * @return string
+ */
+ private static function format_timestamp_for_date_query( int $timestamp ): string {
+ return gmdate( 'Y-m-d H:i:s', $timestamp );
+ }
+
+ /**
+ * Prepare orderby values for wc_get_orders across HPOS and legacy storage.
+ *
+ * Values not present in the map are already accepted by wc_get_orders for
+ * both storage engines and pass through unchanged.
+ *
+ * @param string $orderby Input orderby value.
+ * @return string
+ */
+ private static function prepare_orderby_arg( string $orderby ): string {
+ $orderby_map = array(
+ 'id' => 'ID',
+ );
+
+ if ( 'date_modified' === $orderby ) {
+ return OrderUtil::custom_orders_table_usage_is_enabled() ? 'date_modified' : 'post_modified';
+ }
+
+ return $orderby_map[ $orderby ] ?? $orderby;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
new file mode 100644
index 00000000000..9a79df1083d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Product create ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce product create ability.
+ */
+class ProductCreate extends AbstractDomainAbility implements AbilityDefinition {
+
+ use ProductAbilityTrait;
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/product-create';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Create product', 'woocommerce' ),
+ 'description' => __(
+ 'Create a product using supported catalog fields.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_create_product' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Create a product.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 10.9.0
+ */
+ public static function execute( array $input ) {
+ $product_config = self::get_product_config_for_alias( $input['product_type_alias'] ?? 'physical' );
+
+ if ( is_wp_error( $product_config ) ) {
+ return $product_config;
+ }
+
+ $product = wc_get_product_object( $product_config['wc_type'] );
+
+ if ( ! $product ) {
+ return new \WP_Error(
+ 'woocommerce_invalid_product_type',
+ __( 'Invalid product type.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ try {
+ self::apply_product_type_config( $product, $product_config );
+
+ $validation_error = self::set_product_props_from_input( $product, $input, $product_config );
+ if ( is_wp_error( $validation_error ) ) {
+ return $validation_error;
+ }
+ } catch ( \WC_Data_Exception $exception ) {
+ return self::get_product_data_exception_error( $exception );
+ }
+
+ $save_error = self::save_product( $product, 'woocommerce_product_create_failed' );
+ if ( is_wp_error( $save_error ) ) {
+ return $save_error;
+ }
+
+ return array(
+ 'product' => self::format_product_for_response( $product ),
+ );
+ }
+
+ /**
+ * Check product creation access.
+ *
+ * @param mixed $input Ability input.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public static function can_create_product( $input = array() ): bool {
+ // Cap is not object-scoped for create.
+ unset( $input );
+
+ return wc_rest_check_post_permissions( 'product', 'create' );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return self::get_product_create_input_schema();
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
new file mode 100644
index 00000000000..1d674a673ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
@@ -0,0 +1,172 @@
+<?php
+/**
+ * Product delete ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce product delete ability.
+ */
+class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
+
+ use ProductAbilityTrait;
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/product-delete';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Delete product', 'woocommerce' ),
+ 'description' => __(
+ 'Delete, trash, or restore a product.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_delete_output_schema(),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_delete_product' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => true,
+ 'destructive' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Delete a product.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 10.9.0
+ */
+ public static function execute( array $input ) {
+ $product = self::get_product_from_input( $input );
+
+ if ( is_wp_error( $product ) ) {
+ return $product;
+ }
+
+ if ( $product->is_type( ProductType::VARIATION ) ) {
+ return new \WP_Error(
+ 'woocommerce_product_type_unsupported',
+ __( 'Product type is not supported by this ability.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $product_id = $product->get_id();
+ $force = (bool) ( $input['force'] ?? false );
+
+ /**
+ * Filter whether a product supports trashing in WooCommerce domain abilities.
+ *
+ * @param bool $supports_trash Whether the product supports trashing.
+ * @param \WC_Product $product The product being considered for trashing.
+ *
+ * @since 10.9.0
+ */
+ $supports_trash = apply_filters( 'woocommerce_product_object_trashable', EMPTY_TRASH_DAYS > 0, $product );
+
+ if ( ! $force && ! $supports_trash ) {
+ return new \WP_Error(
+ 'woocommerce_trash_not_supported',
+ __( 'Trash is disabled on this site. Pass force: true to permanently delete.', 'woocommerce' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ $deleted = $product->delete( $force );
+
+ if (
+ ! $deleted
+ || ( $force && null !== get_post( $product_id ) )
+ || ( ! $force && 'trash' !== get_post_status( $product_id ) )
+ ) {
+ return new \WP_Error(
+ 'woocommerce_product_delete_failed',
+ __( 'Failed to delete product.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return array(
+ 'deleted' => (bool) $deleted,
+ 'id' => $product_id,
+ );
+ }
+
+ /**
+ * Check product deletion access.
+ *
+ * @param mixed $input Ability input.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public static function can_delete_product( $input = array() ): bool {
+ $product_id = self::get_id_from_input( $input );
+
+ return $product_id > 0 && wc_rest_check_post_permissions( 'product', 'delete', $product_id );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'force' => array(
+ 'type' => 'boolean',
+ 'description' => __(
+ 'Permanently delete the product. Defaults to false, which moves the product to trash.',
+ 'woocommerce'
+ ),
+ 'default' => false,
+ ),
+ ),
+ 'required' => array( 'id' ),
+ 'additionalProperties' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
new file mode 100644
index 00000000000..a56fa660cd7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * Product update ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce product update ability.
+ */
+class ProductUpdate extends AbstractDomainAbility implements AbilityDefinition {
+
+ use ProductAbilityTrait;
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/product-update';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Update product', 'woocommerce' ),
+ 'description' => __(
+ 'Update an existing product using supported catalog fields.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_update_product' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Update a product.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 10.9.0
+ */
+ public static function execute( array $input ) {
+ $product = self::get_product_from_input( $input );
+
+ if ( is_wp_error( $product ) ) {
+ return $product;
+ }
+
+ if ( empty( array_diff( array_keys( $input ), array( 'id' ) ) ) ) {
+ return new \WP_Error(
+ 'woocommerce_product_update_no_fields',
+ __( 'At least one product field is required to update a product.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $product_config = self::get_product_config_for_product( $product );
+
+ if ( is_wp_error( $product_config ) ) {
+ return $product_config;
+ }
+
+ if (
+ isset( $input['status'] )
+ && in_array(
+ sanitize_key( $input['status'] ),
+ array( ProductStatus::PUBLISH, ProductStatus::FUTURE, ProductStatus::PRIVATE ),
+ true
+ )
+ && sanitize_key( $input['status'] ) !== $product->get_status()
+ && ! self::current_user_can_publish_products()
+ ) {
+ return new \WP_Error(
+ 'woocommerce_product_publish_forbidden',
+ __( 'You are not allowed to publish products.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( isset( $input['product_type_alias'] ) ) {
+ $product_config = self::get_product_config_for_alias( $input['product_type_alias'] );
+
+ if ( is_wp_error( $product_config ) ) {
+ return $product_config;
+ }
+
+ $product = wc_get_product_object( $product_config['wc_type'], $product->get_id() );
+
+ if ( ! $product ) {
+ return new \WP_Error(
+ 'woocommerce_invalid_product_type',
+ __( 'Invalid product type.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ try {
+ if ( isset( $input['product_type_alias'] ) ) {
+ self::apply_product_type_config( $product, $product_config );
+ }
+
+ $validation_error = self::set_product_props_from_input( $product, $input, $product_config );
+ if ( is_wp_error( $validation_error ) ) {
+ return $validation_error;
+ }
+ } catch ( \WC_Data_Exception $exception ) {
+ return self::get_product_data_exception_error( $exception );
+ }
+
+ $save_error = self::save_product( $product, 'woocommerce_product_update_failed' );
+ if ( is_wp_error( $save_error ) ) {
+ return $save_error;
+ }
+
+ return array(
+ 'product' => self::format_product_for_response( $product ),
+ );
+ }
+
+ /**
+ * Check product update access.
+ *
+ * @param mixed $input Ability input.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public static function can_update_product( $input = array() ): bool {
+ $product_id = self::get_id_from_input( $input );
+
+ return $product_id > 0 && wc_rest_check_post_permissions( 'product', 'edit', $product_id );
+ }
+
+ /**
+ * Check whether the current user can publish products.
+ *
+ * @return bool
+ */
+ private static function current_user_can_publish_products(): bool {
+ // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers the publish_products capability.
+ return current_user_can( 'publish_products' );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return self::get_product_update_input_schema();
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
new file mode 100644
index 00000000000..3de97939e45
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -0,0 +1,211 @@
+<?php
+/**
+ * Products query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce products query ability.
+ */
+class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+ use ProductAbilityTrait;
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/products-query';
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Query products', 'woocommerce' ),
+ 'description' => __(
+ 'Find products by ID or common catalog filters.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_collection_output_schema( 'products', self::get_product_output_schema() ),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_query_products' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Query products.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 10.9.0
+ */
+ public static function execute( array $input ) {
+ if ( ! empty( $input['id'] ) ) {
+ $product = self::get_product_from_input( $input );
+
+ if ( is_wp_error( $product ) ) {
+ return $product;
+ }
+
+ if ( $product->is_type( ProductType::VARIATION ) ) {
+ return new \WP_Error(
+ 'woocommerce_product_type_unsupported',
+ __( 'Product type is not supported by this ability.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return array(
+ 'products' => array( self::format_product_for_response( $product ) ),
+ 'total_pages' => 1,
+ 'page' => 1,
+ 'per_page' => 1,
+ );
+ }
+
+ $page = (int) ( $input['page'] ?? 1 );
+ $per_page = (int) ( $input['per_page'] ?? 10 );
+ $args = array(
+ 'limit' => $per_page,
+ 'page' => $page,
+ 'paginate' => true,
+ 'return' => 'objects',
+ );
+
+ foreach ( array( 'status', 'sku', 'stock_status' ) as $field ) {
+ if ( ! empty( $input[ $field ] ) ) {
+ $args[ $field ] = wc_clean( $input[ $field ] );
+ }
+ }
+
+ if ( ! empty( $input['product_type_alias'] ) && is_scalar( $input['product_type_alias'] ) ) {
+ $product_type_alias = sanitize_text_field( (string) $input['product_type_alias'] );
+ $type_args = self::get_product_query_args_for_alias( $product_type_alias );
+
+ if ( is_wp_error( $type_args ) ) {
+ return $type_args;
+ }
+
+ $args = array_merge( $args, $type_args );
+ }
+
+ if ( ! empty( $input['search'] ) ) {
+ $args['s'] = wc_clean( $input['search'] );
+ }
+
+ $results = wc_get_products( $args );
+ $products = is_object( $results ) && isset( $results->products ) ? $results->products : array();
+ $pages = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $products ) > 0 ? 1 : 0 );
+
+ return array(
+ 'products' => array_map(
+ static function ( $product ) {
+ return self::format_product_for_response( $product );
+ },
+ $products
+ ),
+ 'total_pages' => $pages,
+ 'page' => $page,
+ 'per_page' => $per_page,
+ );
+ }
+
+ /**
+ * Check product read access.
+ *
+ * @param mixed $input Ability input.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public static function can_query_products( $input = array() ): bool {
+ $product_id = self::get_id_from_input( $input );
+
+ return wc_rest_check_post_permissions( 'product', 'read', $product_id );
+ }
+
+ /**
+ * Get the ability input schema.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'search' => array( 'type' => 'string' ),
+ 'sku' => array(
+ 'type' => 'string',
+ 'description' => __( 'Limit results to products with SKUs that partially match this string. Use * to match products with any non-empty SKU.', 'woocommerce' ),
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => self::get_product_query_status_slugs(),
+ ),
+ 'product_type_alias' => array(
+ 'type' => 'string',
+ 'description' => __(
+ 'Filter by supported agent-facing product type alias. physical maps to simple shippable, non-downloadable products; virtual maps to simple non-shipping, non-downloadable products; digital maps to simple virtual/downloadable products; affiliate maps to the external product type; grouped maps to grouped.',
+ 'woocommerce'
+ ),
+ 'enum' => self::get_supported_product_type_aliases(),
+ ),
+ 'stock_status' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( wc_get_product_stock_status_options() ),
+ ),
+ 'page' => array(
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ ),
+ 'per_page' => array(
+ 'type' => 'integer',
+ 'default' => 10,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ ),
+ ),
+ 'additionalProperties' => false,
+ 'default' => array(),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
new file mode 100644
index 00000000000..4cad9374209
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -0,0 +1,237 @@
+<?php
+/**
+ * Order ability trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared order helpers for WooCommerce domain ability definitions.
+ */
+trait OrderAbilityTrait {
+
+ /**
+ * Allowed order status slugs (without the `wc-` prefix).
+ *
+ * @return array<int, string>
+ */
+ protected static function get_allowed_order_status_slugs(): array {
+ return array_values(
+ array_diff(
+ array_map(
+ array( OrderUtil::class, 'remove_status_prefix' ),
+ array_keys( wc_get_order_statuses() )
+ ),
+ array( OrderStatus::CHECKOUT_DRAFT )
+ )
+ );
+ }
+
+ /**
+ * Possible order status slugs (without the `wc-` prefix) for ability output.
+ *
+ * @return array<int, string>
+ */
+ protected static function get_order_output_status_slugs(): array {
+ return array_values(
+ array_unique(
+ array_map(
+ array( OrderUtil::class, 'remove_status_prefix' ),
+ array_merge( OrderStatus::get_all(), array_keys( wc_get_order_statuses() ) )
+ )
+ )
+ );
+ }
+
+ /**
+ * Get an order note output schema.
+ *
+ * @return array
+ */
+ protected static function get_order_note_output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'note_id' => array( 'type' => 'integer' ),
+ 'order' => self::get_order_output_schema(),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get the schema for a single order in a response.
+ *
+ * @return array
+ */
+ protected static function get_order_output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => self::get_order_output_status_slugs(),
+ ),
+ 'currency' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( get_woocommerce_currencies() ),
+ ),
+ 'currency_symbol' => array( 'type' => 'string' ),
+ 'total' => array( 'type' => 'string' ),
+ 'customer_id' => array( 'type' => 'integer' ),
+ 'billing_email' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'email',
+ ),
+ 'payment_method' => array( 'type' => 'string' ),
+ 'payment_method_title' => array( 'type' => 'string' ),
+ 'date_created' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_created_gmt' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_modified' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_modified_gmt' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'line_items' => array(
+ 'type' => 'array',
+ 'description' => __( 'Order line items. Only present when include_line_items is true.', 'woocommerce' ),
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'name' => array( 'type' => 'string' ),
+ 'product_id' => array( 'type' => 'integer' ),
+ 'variation_id' => array( 'type' => 'integer' ),
+ 'quantity' => array( 'type' => 'integer' ),
+ 'subtotal' => array( 'type' => 'string' ),
+ 'total' => array( 'type' => 'string' ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get an order from ability input.
+ *
+ * @param array $input Ability input.
+ * @return \WC_Order|\WP_Error
+ */
+ protected static function get_order_from_input( array $input ) {
+ if ( empty( $input['id'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_order_id_required',
+ __( 'Order ID is required.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $order_id = (int) $input['id'];
+
+ if ( $order_id < 1 ) {
+ return new \WP_Error(
+ 'woocommerce_order_id_required',
+ __( 'Order ID is required.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $order = wc_get_order( $order_id );
+
+ if ( ! $order instanceof \WC_Order ) {
+ return new \WP_Error(
+ 'woocommerce_order_not_found',
+ __( 'Order not found.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return $order;
+ }
+
+ /**
+ * Check order edit access for an ability input payload.
+ *
+ * @param mixed $input Ability input.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public static function can_edit_order( $input = array() ): bool {
+ $order_id = self::get_id_from_input( $input );
+
+ return $order_id > 0 && wc_rest_check_post_permissions( 'shop_order', 'edit', $order_id );
+ }
+
+ /**
+ * Format an order for ability output.
+ *
+ * @param \WC_Order $order Order object.
+ * @param bool $include_line_items Whether to include line items.
+ * @return array
+ */
+ protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ): array {
+ $billing_email = $order->get_billing_email();
+
+ $data = array(
+ 'id' => $order->get_id(),
+ 'status' => $order->get_status(),
+ 'currency' => $order->get_currency(),
+ 'currency_symbol' => html_entity_decode(
+ get_woocommerce_currency_symbol( $order->get_currency() ),
+ ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
+ ),
+ 'total' => $order->get_total(),
+ 'customer_id' => $order->get_customer_id(),
+ 'billing_email' => '' === $billing_email ? null : $billing_email,
+ 'payment_method' => $order->get_payment_method(),
+ 'payment_method_title' => $order->get_payment_method_title(),
+ 'date_created' => wc_rest_prepare_date_response( $order->get_date_created(), false ),
+ 'date_created_gmt' => wc_rest_prepare_date_response( $order->get_date_created() ),
+ 'date_modified' => wc_rest_prepare_date_response( $order->get_date_modified(), false ),
+ 'date_modified_gmt' => wc_rest_prepare_date_response( $order->get_date_modified() ),
+ );
+
+ if ( $include_line_items ) {
+ $data['line_items'] = array();
+
+ foreach ( $order->get_items() as $item ) {
+ if ( ! $item instanceof \WC_Order_Item_Product ) {
+ continue;
+ }
+
+ $data['line_items'][] = 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(),
+ );
+ }
+ }
+
+ return $data;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
new file mode 100644
index 00000000000..56338cfa5fc
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -0,0 +1,919 @@
+<?php
+/**
+ * Product ability trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductType;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared product helpers for WooCommerce domain ability definitions.
+ */
+trait ProductAbilityTrait {
+
+ /**
+ * Get the product create input schema.
+ *
+ * @return array
+ */
+ protected static function get_product_create_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'oneOf' => self::get_product_alias_input_schema_branches( array( 'name' ), false ),
+ );
+ }
+
+ /**
+ * Get the product update input schema.
+ *
+ * Updates without a product type alias can mutate fields shared by all
+ * supported product aliases. Type-specific fields require an explicit alias.
+ *
+ * @return array
+ */
+ protected static function get_product_update_input_schema(): array {
+ $branches = self::get_product_alias_input_schema_branches( array( 'id' ), true );
+ $mutation_schemas = self::get_product_mutation_field_schemas();
+ $common_properties = array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ );
+
+ foreach ( self::get_common_product_mutation_fields() as $field ) {
+ $common_properties[ $field ] = $mutation_schemas[ $field ];
+ }
+
+ array_unshift(
+ $branches,
+ array(
+ 'type' => 'object',
+ 'properties' => $common_properties,
+ 'required' => array( 'id' ),
+ 'additionalProperties' => false,
+ )
+ );
+
+ return array(
+ 'type' => 'object',
+ 'oneOf' => $branches,
+ );
+ }
+
+ /**
+ * Get alias-specific product input schema branches.
+ *
+ * @param array<int, string> $base_required Required fields for every branch.
+ * @param bool $require_alias Whether every branch must include product_type_alias.
+ * @return array<int, array>
+ */
+ private static function get_product_alias_input_schema_branches( array $base_required, bool $require_alias ): array {
+ $branches = array();
+
+ foreach ( self::get_product_type_alias_configs() as $product_type_alias => $product_config ) {
+ $required = $base_required;
+
+ if ( $require_alias || 'physical' !== $product_type_alias ) {
+ $required[] = 'product_type_alias';
+ }
+
+ $properties = array();
+
+ if ( in_array( 'id', $base_required, true ) ) {
+ $properties['id'] = array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ );
+ }
+
+ $properties['product_type_alias'] = self::get_product_type_alias_schema( $product_type_alias );
+
+ if ( 'physical' === $product_type_alias && ! $require_alias ) {
+ $properties['product_type_alias']['default'] = 'physical';
+ }
+
+ $properties = array_merge(
+ $properties,
+ self::get_product_mutation_field_schemas_for_fields( $product_config['fields'] )
+ );
+
+ $branches[] = array(
+ 'type' => 'object',
+ 'properties' => $properties,
+ 'required' => array_values( array_unique( $required ) ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ return $branches;
+ }
+
+ /**
+ * Get the product type alias schema.
+ *
+ * @param string|null $product_type_alias Product type alias to restrict to, or null for all aliases.
+ * @return array
+ */
+ private static function get_product_type_alias_schema( ?string $product_type_alias = null ): array {
+ return array(
+ 'type' => 'string',
+ 'description' => __(
+ 'Supported agent-facing product type alias. physical maps to a simple shippable, non-downloadable product; virtual maps to a simple non-shipping, non-downloadable product; digital maps to a simple virtual/downloadable product; affiliate maps to the external product type; grouped maps to grouped.',
+ 'woocommerce'
+ ),
+ 'enum' => null === $product_type_alias ? self::get_supported_product_type_aliases() : array( $product_type_alias ),
+ );
+ }
+
+ /**
+ * Get product mutation field schemas keyed by input field.
+ *
+ * @return array<string, array>
+ */
+ private static function get_product_mutation_field_schemas(): array {
+ return array(
+ 'name' => array( 'type' => 'string' ),
+ 'sku' => array( 'type' => 'string' ),
+ 'regular_price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol or thousand separators.', 'woocommerce' ),
+ 'pattern' => self::get_product_price_input_pattern(),
+ ),
+ 'sale_price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol or thousand separators.', 'woocommerce' ),
+ 'pattern' => self::get_product_price_input_pattern(),
+ ),
+ 'description' => array(
+ 'type' => 'string',
+ 'description' => __( 'Product description content. Safe HTML is allowed.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => 'string',
+ 'description' => __( 'Short product description content. Safe HTML is allowed.', 'woocommerce' ),
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => self::get_product_mutation_status_slugs(),
+ ),
+ 'manage_stock' => array( 'type' => 'boolean' ),
+ 'stock_quantity' => array(
+ 'type' => self::get_product_stock_quantity_schema_type(),
+ 'description' => __( 'Available stock quantity when product-level stock management is used.', 'woocommerce' ),
+ ),
+ 'stock_status' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( wc_get_product_stock_status_options() ),
+ ),
+ 'external_url' => array(
+ 'type' => 'string',
+ 'description' => __( 'External destination URL for affiliate products.', 'woocommerce' ),
+ 'format' => 'uri',
+ ),
+ 'button_text' => array(
+ 'type' => 'string',
+ 'description' => __( 'Button text for affiliate products.', 'woocommerce' ),
+ ),
+ 'grouped_products' => array(
+ 'type' => 'array',
+ 'description' => __( 'Product IDs to include as children of a grouped product.', 'woocommerce' ),
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Get product mutation field schemas for a set of fields.
+ *
+ * @param array<int, string> $fields Field names.
+ * @return array<string, array>
+ */
+ private static function get_product_mutation_field_schemas_for_fields( array $fields ): array {
+ $schemas = self::get_product_mutation_field_schemas();
+ $result = array();
+
+ foreach ( $schemas as $field => $schema ) {
+ if ( in_array( $field, $fields, true ) ) {
+ $result[ $field ] = $schema;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Supported agent-facing product type aliases.
+ *
+ * @return array<int, string>
+ */
+ protected static function get_supported_product_type_aliases(): array {
+ return array_keys( self::get_product_type_alias_configs() );
+ }
+
+ /**
+ * Get product configuration by agent-facing product type alias.
+ *
+ * @param string $product_type_alias Agent-facing product type alias.
+ * @return array|\WP_Error
+ */
+ protected static function get_product_config_for_alias( string $product_type_alias ) {
+ $product_type_alias = sanitize_key( $product_type_alias );
+ $configs = self::get_product_type_alias_configs();
+
+ if ( ! isset( $configs[ $product_type_alias ] ) ) {
+ return new \WP_Error(
+ 'woocommerce_product_type_unsupported',
+ __( 'Product type is not supported by this ability.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $configs[ $product_type_alias ];
+ }
+
+ /**
+ * Get product query arguments for an agent-facing product type alias.
+ *
+ * @param string $product_type_alias Agent-facing product type alias.
+ * @return array|\WP_Error
+ */
+ protected static function get_product_query_args_for_alias( string $product_type_alias ) {
+ $product_config = self::get_product_config_for_alias( $product_type_alias );
+
+ if ( is_wp_error( $product_config ) ) {
+ return $product_config;
+ }
+
+ $query_args = array(
+ 'type' => $product_config['wc_type'],
+ );
+
+ if ( isset( $product_config['query_props'] ) ) {
+ foreach ( $product_config['query_props'] as $field => $value ) {
+ $query_args[ $field ] = (bool) $value;
+ }
+ }
+
+ return $query_args;
+ }
+
+ /**
+ * Get product configuration for an existing product.
+ *
+ * @param \WC_Product $product Product object.
+ * @return array|\WP_Error
+ */
+ protected static function get_product_config_for_product( \WC_Product $product ) {
+ $product_type_alias = self::get_product_type_alias_for_product( $product );
+
+ if ( is_wp_error( $product_type_alias ) ) {
+ return $product_type_alias;
+ }
+
+ return self::get_product_config_for_alias( $product_type_alias );
+ }
+
+ /**
+ * Get the supported agent-facing product type alias for a product.
+ *
+ * @param \WC_Product $product Product object.
+ * @return string|\WP_Error
+ */
+ protected static function get_product_type_alias_for_product( \WC_Product $product ) {
+ if ( $product->is_type( ProductType::SIMPLE ) ) {
+ if ( $product->get_virtual() && $product->get_downloadable() ) {
+ return 'digital';
+ }
+
+ if ( $product->get_virtual() ) {
+ return 'virtual';
+ }
+
+ return 'physical';
+ }
+
+ if ( $product->is_type( ProductType::EXTERNAL ) ) {
+ return 'affiliate';
+ }
+
+ if ( $product->is_type( ProductType::GROUPED ) ) {
+ return 'grouped';
+ }
+
+ return new \WP_Error(
+ 'woocommerce_product_type_unsupported',
+ __( 'Product type is not supported by this ability.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /**
+ * Get fields shared by all supported product mutation aliases.
+ *
+ * @return array<int, string>
+ */
+ private static function get_common_product_mutation_fields(): array {
+ return array( 'name', 'sku', 'description', 'short_description', 'status' );
+ }
+
+ /**
+ * Get product type alias configuration.
+ *
+ * The keys are agent-facing product type aliases. Each config maps the alias to a
+ * WooCommerce product class plus the fields that can be applied to it.
+ *
+ * @return array<string, array{wc_type: string, fields: array<int, string>, product_props: array<string, mixed>, query_props?: array<string, mixed>}>
+ */
+ private static function get_product_type_alias_configs(): array {
+ $common_fields = self::get_common_product_mutation_fields();
+ $simple_fields = array_merge(
+ $common_fields,
+ array( 'regular_price', 'sale_price', 'manage_stock', 'stock_quantity', 'stock_status' )
+ );
+
+ return array(
+ 'physical' => array(
+ 'wc_type' => ProductType::SIMPLE,
+ 'fields' => $simple_fields,
+ 'product_props' => array(
+ 'virtual' => false,
+ 'downloadable' => false,
+ ),
+ 'query_props' => array(
+ 'virtual' => false,
+ 'downloadable' => false,
+ ),
+ ),
+ 'virtual' => array(
+ 'wc_type' => ProductType::SIMPLE,
+ 'fields' => $simple_fields,
+ 'product_props' => array(
+ 'virtual' => true,
+ 'downloadable' => false,
+ ),
+ 'query_props' => array(
+ 'virtual' => true,
+ 'downloadable' => false,
+ ),
+ ),
+ 'digital' => array(
+ 'wc_type' => ProductType::SIMPLE,
+ 'fields' => $simple_fields,
+ 'product_props' => array(
+ 'virtual' => true,
+ 'downloadable' => true,
+ ),
+ 'query_props' => array(
+ 'virtual' => true,
+ 'downloadable' => true,
+ ),
+ ),
+ 'affiliate' => array(
+ 'wc_type' => ProductType::EXTERNAL,
+ 'fields' => array_merge(
+ $common_fields,
+ array( 'regular_price', 'sale_price', 'external_url', 'button_text' )
+ ),
+ 'product_props' => array(
+ 'virtual' => false,
+ 'downloadable' => false,
+ 'manage_stock' => false,
+ 'stock_quantity' => '',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ ),
+ ),
+ 'grouped' => array(
+ 'wc_type' => ProductType::GROUPED,
+ 'fields' => array_merge( $common_fields, array( 'grouped_products' ) ),
+ 'product_props' => array(
+ 'manage_stock' => false,
+ 'stock_quantity' => '',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Allowed product post-status slugs for mutation abilities.
+ *
+ * Mirrors the REST products controller mutation enum.
+ *
+ * @return array<int, string>
+ */
+ protected static function get_product_mutation_status_slugs(): array {
+ return array_merge(
+ array_keys( get_post_statuses() ),
+ array( ProductStatus::FUTURE )
+ );
+ }
+
+ /**
+ * Allowed product post-status slugs for product output.
+ *
+ * @return array<int, string>
+ */
+ protected static function get_product_output_status_slugs(): array {
+ return array_values(
+ array_unique(
+ array_merge(
+ self::get_product_mutation_status_slugs(),
+ array( ProductStatus::AUTO_DRAFT, ProductStatus::TRASH )
+ )
+ )
+ );
+ }
+
+ /**
+ * Allowed product post-status slugs for query abilities.
+ *
+ * @return array<int, string>
+ */
+ protected static function get_product_query_status_slugs(): array {
+ return array_merge(
+ array( ProductStatus::FUTURE, ProductStatus::TRASH ),
+ array_keys( get_post_statuses() )
+ );
+ }
+
+ /**
+ * Get a product from ability input.
+ *
+ * @param array $input Ability input.
+ * @return \WC_Product|\WP_Error
+ */
+ protected static function get_product_from_input( array $input ) {
+ if ( empty( $input['id'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_product_id_required',
+ __( 'Product ID is required.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $product_id = (int) $input['id'];
+
+ if ( $product_id < 1 ) {
+ return new \WP_Error(
+ 'woocommerce_product_id_required',
+ __( 'Product ID is required.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $product = wc_get_product( $product_id );
+
+ if ( ! $product ) {
+ return new \WP_Error(
+ 'woocommerce_product_not_found',
+ __( 'Product not found.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return $product;
+ }
+
+ /**
+ * Set supported product properties from ability input.
+ *
+ * @param \WC_Product $product Product object.
+ * @param array $input Ability input.
+ * @param array $product_config Product type configuration.
+ * @return null|\WP_Error
+ */
+ protected static function set_product_props_from_input( \WC_Product $product, array $input, array $product_config ) {
+ $validation_error = self::validate_product_fields_for_config( $input, $product_config );
+ if ( is_wp_error( $validation_error ) ) {
+ return $validation_error;
+ }
+
+ $setters = array(
+ 'name' => 'set_name',
+ 'sku' => 'set_sku',
+ 'regular_price' => 'set_regular_price',
+ 'sale_price' => 'set_sale_price',
+ 'description' => 'set_description',
+ 'short_description' => 'set_short_description',
+ 'status' => 'set_status',
+ 'manage_stock' => 'set_manage_stock',
+ 'stock_quantity' => 'set_stock_quantity',
+ 'stock_status' => 'set_stock_status',
+ 'external_url' => 'set_product_url',
+ 'button_text' => 'set_button_text',
+ 'grouped_products' => 'set_children',
+ );
+
+ foreach ( $setters as $field => $setter ) {
+ if (
+ ! in_array( $field, $product_config['fields'], true )
+ || ! array_key_exists( $field, $input )
+ || ! is_callable( array( $product, $setter ) )
+ ) {
+ continue;
+ }
+
+ $prepared_value = self::prepare_product_field_value( $field, $input[ $field ] );
+ if ( is_wp_error( $prepared_value ) ) {
+ return $prepared_value;
+ }
+
+ $product->{$setter}( $prepared_value );
+ }
+
+ return null;
+ }
+
+ /**
+ * Apply product type configuration to a product object.
+ *
+ * @param \WC_Product $product Product object.
+ * @param array $product_config Product type configuration.
+ */
+ protected static function apply_product_type_config( \WC_Product $product, array $product_config ): void {
+ $prop_setters = array(
+ 'virtual' => 'set_virtual',
+ 'downloadable' => 'set_downloadable',
+ 'manage_stock' => 'set_manage_stock',
+ 'stock_quantity' => 'set_stock_quantity',
+ 'stock_status' => 'set_stock_status',
+ );
+
+ foreach ( $product_config['product_props'] as $prop => $value ) {
+ $setter = $prop_setters[ $prop ] ?? '';
+
+ if ( '' !== $setter && is_callable( array( $product, $setter ) ) ) {
+ $product->{$setter}( $value );
+ }
+ }
+ }
+
+ /**
+ * Validate that input fields are supported for the configured product type.
+ *
+ * @param array $input Ability input.
+ * @param array $product_config Product type configuration.
+ * @return null|\WP_Error
+ */
+ private static function validate_product_fields_for_config( array $input, array $product_config ) {
+ $shared_fields = array( 'id', 'product_type_alias' );
+ $supported_fields = array_merge( $shared_fields, $product_config['fields'] );
+ $unsupported_field_keys = array_diff( array_keys( $input ), $supported_fields );
+
+ if ( empty( $unsupported_field_keys ) ) {
+ return null;
+ }
+
+ return new \WP_Error(
+ 'woocommerce_product_field_unsupported',
+ sprintf(
+ /* translators: %s is a comma-separated list of unsupported product fields. */
+ __( 'These fields are not supported for the selected product type: %s.', 'woocommerce' ),
+ implode( ', ', $unsupported_field_keys )
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /**
+ * Prepare a product field value before passing it to the product setter.
+ *
+ * @param string $field Field name.
+ * @param mixed $value Field value.
+ * @return mixed|\WP_Error
+ */
+ private static function prepare_product_field_value( string $field, $value ) {
+ if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) && is_string( $value ) ) {
+ return wp_filter_post_kses( $value );
+ }
+
+ if ( 'status' === $field && is_string( $value ) ) {
+ return sanitize_key( $value );
+ }
+
+ if ( 'external_url' === $field && is_string( $value ) ) {
+ return esc_url_raw( $value );
+ }
+
+ if ( in_array( $field, array( 'regular_price', 'sale_price' ), true ) ) {
+ return self::prepare_product_price_value( $value );
+ }
+
+ if ( 'grouped_products' === $field ) {
+ if ( ! is_array( $value ) ) {
+ return new \WP_Error(
+ 'woocommerce_product_grouped_products_invalid',
+ __( 'Grouped product IDs must be positive integers.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $grouped_product_ids = array();
+
+ foreach ( $value as $product_id ) {
+ if ( ! rest_is_integer( $product_id ) || (int) $product_id < 1 ) {
+ return new \WP_Error(
+ 'woocommerce_product_grouped_products_invalid',
+ __( 'Grouped product IDs must be positive integers.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $grouped_product_ids[] = (int) $product_id;
+ }
+
+ return $grouped_product_ids;
+ }
+
+ if ( is_string( $value ) ) {
+ return wc_clean( $value );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Prepare a product price before passing it to the product setter.
+ *
+ * @param mixed $value Price value.
+ * @return string|\WP_Error
+ */
+ private static function prepare_product_price_value( $value ) {
+ if ( ! is_scalar( $value ) || is_bool( $value ) ) {
+ return self::get_invalid_product_price_error();
+ }
+
+ $value = trim( (string) $value );
+
+ if ( 1 !== preg_match( '/' . self::get_product_price_input_pattern() . '/', $value ) ) {
+ return self::get_invalid_product_price_error();
+ }
+
+ return wc_format_decimal( $value );
+ }
+
+ /**
+ * Get a schema-compatible pattern for product price inputs.
+ *
+ * @return string
+ */
+ private static function get_product_price_input_pattern(): string {
+ $decimal_separators = array_map(
+ static function ( string $separator ): string {
+ return preg_quote( $separator, '/' );
+ },
+ self::get_product_price_decimal_separators()
+ );
+ $decimal_separator_pattern = implode( '', $decimal_separators );
+
+ return '^(?:-?(?:[0-9]+(?:[' . $decimal_separator_pattern . '][0-9]+)?|[' . $decimal_separator_pattern . '][0-9]+)|)$';
+ }
+
+ /**
+ * Get decimal separators accepted by WooCommerce decimal formatting.
+ *
+ * @return array<int, string>
+ */
+ private static function get_product_price_decimal_separators(): array {
+ $locale = localeconv();
+
+ return array_values(
+ array_unique(
+ array_filter(
+ array(
+ wc_get_price_decimal_separator(),
+ $locale['decimal_point'] ?? '',
+ $locale['mon_decimal_point'] ?? '',
+ ),
+ static function ( $separator ): bool {
+ return is_string( $separator ) && '' !== $separator;
+ }
+ )
+ )
+ );
+ }
+
+ /**
+ * Get an invalid product price error.
+ *
+ * @return \WP_Error
+ */
+ private static function get_invalid_product_price_error(): \WP_Error {
+ return new \WP_Error(
+ 'woocommerce_product_price_invalid',
+ __( 'Product prices must be decimal strings without currency symbols or thousand separators.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /**
+ * Convert a WooCommerce data exception to a WordPress error.
+ *
+ * @param \WC_Data_Exception $exception Exception.
+ * @return \WP_Error
+ */
+ protected static function get_product_data_exception_error( \WC_Data_Exception $exception ): \WP_Error {
+ return new \WP_Error(
+ $exception->getErrorCode(),
+ $exception->getMessage(),
+ $exception->getErrorData()
+ );
+ }
+
+ /**
+ * Save a product and return a failure if WooCommerce did not persist it.
+ *
+ * @param \WC_Product $product Product object.
+ * @param string $failure_code Error code to use when save returns no ID.
+ * @return null|\WP_Error
+ */
+ protected static function save_product( \WC_Product $product, string $failure_code ) {
+ try {
+ $product_id = $product->save();
+ } catch ( \WC_Data_Exception $exception ) {
+ return self::get_product_data_exception_error( $exception );
+ } catch ( \Exception $exception ) {
+ if ( function_exists( 'wc_get_logger' ) ) {
+ wc_get_logger()->error(
+ 'WooCommerce domain ability failed to save product.',
+ array(
+ 'source' => 'woocommerce-abilities',
+ 'failure_code' => $failure_code,
+ 'product_id' => $product->get_id(),
+ 'exception' => get_class( $exception ),
+ 'error_message' => $exception->getMessage(),
+ )
+ );
+ }
+
+ return new \WP_Error(
+ $failure_code,
+ __( 'Failed to save product.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ if ( $product_id <= 0 || $product->get_id() <= 0 ) {
+ return new \WP_Error(
+ $failure_code,
+ __( 'Failed to save product.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Format a product for ability output.
+ *
+ * @param \WC_Product $product Product object.
+ * @return array
+ */
+ protected static function format_product_for_response( \WC_Product $product ): array {
+ $stock_quantity = $product->get_stock_quantity();
+ $permalink = $product->get_permalink();
+
+ return array(
+ 'id' => $product->get_id(),
+ 'name' => $product->get_name(),
+ 'slug' => $product->get_slug(),
+ 'permalink' => false === $permalink ? null : $permalink,
+ 'type' => $product->get_type(),
+ 'status' => $product->get_status(),
+ 'sku' => $product->get_sku(),
+ 'currency' => get_woocommerce_currency(),
+ 'currency_symbol' => html_entity_decode(
+ 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(),
+ 'stock_status' => $product->get_stock_status(),
+ 'stock_quantity' => null === $stock_quantity ? null : wc_stock_amount( $stock_quantity ),
+ 'manage_stock' => (bool) $product->get_manage_stock(),
+ 'virtual' => (bool) $product->get_virtual(),
+ 'downloadable' => (bool) $product->get_downloadable(),
+ 'external_url' => $product instanceof \WC_Product_External ? $product->get_product_url() : null,
+ 'button_text' => $product instanceof \WC_Product_External ? $product->get_button_text() : null,
+ 'grouped_products' => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
+ 'date_created' => wc_rest_prepare_date_response( $product->get_date_created(), false ),
+ 'date_created_gmt' => wc_rest_prepare_date_response( $product->get_date_created() ),
+ 'date_modified' => wc_rest_prepare_date_response( $product->get_date_modified(), false ),
+ 'date_modified_gmt' => wc_rest_prepare_date_response( $product->get_date_modified() ),
+ );
+ }
+
+ /**
+ * Get the schema for a single product in a response.
+ *
+ * @return array
+ */
+ protected static function get_product_output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'name' => array( 'type' => 'string' ),
+ 'slug' => array( 'type' => 'string' ),
+ 'permalink' => array(
+ 'type' => array( 'string', 'null' ),
+ 'description' => __( 'Product permalink, or null when no public permalink is available.', 'woocommerce' ),
+ 'format' => 'uri',
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ 'description' => __( 'Internal product type slug, such as simple, external, grouped, or variable.', 'woocommerce' ),
+ 'enum' => array_keys( wc_get_product_types() ),
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => self::get_product_output_status_slugs(),
+ ),
+ 'sku' => array( 'type' => 'string' ),
+ 'currency' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( get_woocommerce_currencies() ),
+ ),
+ 'currency_symbol' => array( 'type' => 'string' ),
+ 'price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+ ),
+ 'sale_price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+ ),
+ 'stock_status' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( wc_get_product_stock_status_options() ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => array( self::get_product_stock_quantity_schema_type(), 'null' ),
+ 'description' => __( 'Current stock quantity, or null when no stock quantity is set.', 'woocommerce' ),
+ ),
+ 'manage_stock' => array( 'type' => 'boolean' ),
+ 'virtual' => array( 'type' => 'boolean' ),
+ 'downloadable' => array( 'type' => 'boolean' ),
+ 'external_url' => array(
+ 'type' => array( 'string', 'null' ),
+ 'description' => __( 'External product URL for external products.', 'woocommerce' ),
+ 'format' => 'uri',
+ ),
+ 'button_text' => array(
+ 'type' => array( 'string', 'null' ),
+ 'description' => __( 'Button text for external products.', 'woocommerce' ),
+ ),
+ 'grouped_products' => array(
+ 'type' => 'array',
+ 'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
+ 'items' => array( 'type' => 'integer' ),
+ ),
+ 'date_created' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_created_gmt' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_modified' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_modified_gmt' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Get the schema type for product stock quantities.
+ *
+ * WooCommerce stock quantities can support fractional values when the
+ * stock amount filter is configured to return non-integer amounts.
+ *
+ * @return string
+ */
+ protected static function get_product_stock_quantity_schema_type(): string {
+ return wc_is_stock_amount_integer() ? 'integer' : 'number';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
new file mode 100644
index 00000000000..3daeac3fecb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -0,0 +1,2442 @@
+<?php
+/**
+ * AbilitiesLoaderTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesCategories;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+/**
+ * Tests for the canonical WooCommerce domain abilities and their loader.
+ */
+class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
+
+ use HPOSToggleTrait;
+
+ private const CANONICAL_ABILITY_IDS = array(
+ 'woocommerce/products-query',
+ 'woocommerce/product-create',
+ 'woocommerce/product-update',
+ 'woocommerce/product-delete',
+ 'woocommerce/orders-query',
+ 'woocommerce/order-update-status',
+ 'woocommerce/order-add-note',
+ );
+
+ /**
+ * Ability IDs registered by these tests.
+ *
+ * @var array
+ */
+ private $registered_ability_ids = self::CANONICAL_ABILITY_IDS;
+
+ /**
+ * Category IDs registered by these tests.
+ *
+ * @var array
+ */
+ private $registered_category_ids = array();
+
+ /**
+ * Product IDs created by these tests.
+ *
+ * @var array
+ */
+ private $created_product_ids = array();
+
+ /**
+ * Order IDs created by these tests.
+ *
+ * @var array
+ */
+ private $created_order_ids = array();
+
+ /**
+ * Original action counts captured for restoration in tearDown.
+ *
+ * @var array<string, int|null>
+ */
+ private $original_action_counts = array();
+
+ /**
+ * Original HPOS/COT state captured before tests that toggle storage.
+ *
+ * @var bool|null
+ */
+ private $original_cot_state = null;
+
+ /**
+ * Whether HPOS/COT tables were set up by the current test.
+ *
+ * @var bool
+ */
+ private $cot_setup_for_test = false;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ global $wp_actions;
+
+ parent::setUp();
+
+ foreach ( array( 'init', 'wp_abilities_api_init', 'wp_abilities_api_categories_init' ) as $action ) {
+ $this->original_action_counts[ $action ] = $wp_actions[ $action ] ?? null;
+ }
+
+ if ( ! function_exists( 'wp_register_ability' ) ) {
+ $abilities_bootstrap = WC_ABSPATH . 'vendor/wordpress/abilities-api/includes/bootstrap.php';
+ if ( file_exists( $abilities_bootstrap ) ) {
+ require_once $abilities_bootstrap;
+ }
+ }
+
+ // WordPress 6.9+ requires init to have fired before the Abilities API registry can be initialized.
+ $wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ wp_set_current_user(
+ $this->factory->user->create( array( 'role' => 'administrator' ) )
+ );
+
+ $this->register_woocommerce_category();
+ $this->register_domain_abilities();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ global $wp_actions;
+
+ foreach ( $this->created_order_ids as $order_id ) {
+ $order = wc_get_order( $order_id );
+
+ if ( $order ) {
+ $order->delete( true );
+ }
+ }
+
+ foreach ( $this->created_product_ids as $product_id ) {
+ $product = wc_get_product( $product_id );
+
+ if ( $product ) {
+ $product->delete( true );
+ }
+ }
+
+ foreach ( $this->registered_ability_ids as $ability_id ) {
+ if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_id ) ) {
+ wp_unregister_ability( $ability_id );
+ }
+ }
+
+ foreach ( $this->registered_category_ids as $category_id ) {
+ if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( $category_id ) ) {
+ wp_unregister_ability_category( $category_id );
+ }
+ }
+
+ foreach ( $this->original_action_counts as $action => $original_count ) {
+ if ( null !== $original_count ) {
+ $wp_actions[ $action ] = $original_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ } elseif ( isset( $wp_actions[ $action ] ) ) {
+ unset( $wp_actions[ $action ] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ }
+
+ if ( null !== $this->original_cot_state ) {
+ if ( $this->cot_setup_for_test ) {
+ $this->clean_up_cot_setup();
+ }
+
+ $this->toggle_cot_feature_and_usage( $this->original_cot_state );
+ remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ }
+
+ wp_set_current_user( 0 );
+
+ parent::tearDown();
+ }
+
+ /*
+ * ---------------------------------------------------------------------
+ * Registration / Loader
+ * ---------------------------------------------------------------------
+ */
+
+ /**
+ * @testdox Should register every canonical ability with WooCommerce metadata.
+ */
+ public function test_canonical_abilities_register_with_woocommerce_metadata(): void {
+ foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+ $ability = wp_get_ability( $ability_id );
+
+ $this->assertNotNull( $ability, "{$ability_id} should be registered." );
+ $this->assertSame( 'woocommerce', $ability->get_category(), "{$ability_id} should belong to the woocommerce category." );
+
+ $meta = $ability->get_meta();
+ $this->assertTrue( $meta['show_in_rest'] ?? false, "{$ability_id} should be exposed in REST." );
+ $this->assertTrue( $meta['mcp']['public'] ?? false, "{$ability_id} should be flagged as MCP-public." );
+ $this->assertSame( 'tool', $meta['mcp']['type'] ?? '', "{$ability_id} should be an MCP tool." );
+ $this->assertArrayHasKey( 'readonly', $meta['annotations'] );
+ $this->assertArrayHasKey( 'destructive', $meta['annotations'] );
+ $this->assertArrayHasKey( 'idempotent', $meta['annotations'] );
+ $this->assertArrayNotHasKey( 'expose_in_deprecated_woocommerce_mcp', $meta );
+ }
+ }
+
+ /**
+ * @testdox Should mark write abilities as destructive and queries as readonly/idempotent.
+ */
+ public function test_canonical_ability_annotations_match_intent(): void {
+ $expectations = array(
+ 'woocommerce/products-query' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ 'woocommerce/product-create' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ 'woocommerce/product-update' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => true,
+ ),
+ 'woocommerce/product-delete' => array(
+ 'readonly' => false,
+ 'idempotent' => true,
+ 'destructive' => true,
+ ),
+ 'woocommerce/orders-query' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ 'woocommerce/order-update-status' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => true,
+ ),
+ 'woocommerce/order-add-note' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ );
+
+ foreach ( $expectations as $ability_id => $annotations ) {
+ $meta = wp_get_ability( $ability_id )->get_meta();
+
+ foreach ( $annotations as $key => $value ) {
+ $this->assertSame(
+ $value,
+ $meta['annotations'][ $key ] ?? null,
+ "{$ability_id} should have annotations.{$key}=" . ( $value ? 'true' : 'false' ) . '.'
+ );
+ }
+ }
+ }
+
+ /**
+ * @testdox Should advertise product statuses that product responses can return.
+ */
+ public function test_product_output_schema_allows_response_statuses(): void {
+ $output_schema = wp_get_ability( 'woocommerce/product-create' )->get_output_schema();
+ $status_enum = $output_schema['properties']['product']['properties']['status']['enum'] ?? array();
+
+ $this->assertContains( 'auto-draft', $status_enum );
+ $this->assertContains( 'trash', $status_enum );
+ $this->assertContains( 'publish', $status_enum );
+ }
+
+ /**
+ * @testdox Should describe product output primitives using WooCommerce registries.
+ */
+ public function test_product_output_schema_uses_woocommerce_primitive_constraints(): void {
+ $output_schema = wp_get_ability( 'woocommerce/product-create' )->get_output_schema();
+ $product = $output_schema['properties']['product']['properties'] ?? array();
+
+ $this->assertSame( 'uri', $product['permalink']['format'] ?? null );
+ $this->assertSame( array( 'string', 'null' ), $product['permalink']['type'] ?? null );
+ $this->assertContains( get_woocommerce_currency(), $product['currency']['enum'] ?? array() );
+ $this->assertSame(
+ array( wc_is_stock_amount_integer() ? 'integer' : 'number', 'null' ),
+ $product['stock_quantity']['type'] ?? null
+ );
+ }
+
+ /**
+ * @testdox Should describe collection pagination metadata in query outputs.
+ */
+ public function test_collection_output_schemas_describe_pagination_metadata(): void {
+ $schemas = array(
+ 'woocommerce/products-query' => 'products',
+ 'woocommerce/orders-query' => 'orders',
+ );
+
+ foreach ( $schemas as $ability_id => $collection_key ) {
+ $properties = wp_get_ability( $ability_id )->get_output_schema()['properties'] ?? array();
+
+ $this->assertArrayHasKey( $collection_key, $properties );
+ $this->assertArrayHasKey( 'total_pages', $properties );
+ $this->assertArrayHasKey( 'page', $properties );
+ $this->assertArrayHasKey( 'per_page', $properties );
+ $this->assertArrayNotHasKey( 'total', $properties );
+ $this->assertNotEmpty( $properties[ $collection_key ]['description'] ?? '' );
+ $this->assertNotEmpty( $properties['total_pages']['description'] ?? '' );
+ $this->assertNotEmpty( $properties['page']['description'] ?? '' );
+ $this->assertNotEmpty( $properties['per_page']['description'] ?? '' );
+ }
+ }
+
+ /**
+ * @testdox Should expose agent-friendly product type alias inputs for product operations.
+ */
+ public function test_product_schema_uses_agent_friendly_product_types(): void {
+ $query_schema = wp_get_ability( 'woocommerce/products-query' )->get_input_schema();
+ $create_schema = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+ $update_schema = wp_get_ability( 'woocommerce/product-update' )->get_input_schema();
+
+ $expected_aliases = array( 'physical', 'virtual', 'digital', 'affiliate', 'grouped' );
+
+ $this->assertSame( $expected_aliases, $query_schema['properties']['product_type_alias']['enum'] ?? array() );
+ $this->assertSame( $expected_aliases, $this->get_product_type_aliases_from_schema_branches( $create_schema ) );
+ $this->assertSame( $expected_aliases, $this->get_product_type_aliases_from_schema_branches( $update_schema ) );
+ $this->assertSame( 'physical', $this->get_product_type_alias_schema_branch( $create_schema, 'physical' )['properties']['product_type_alias']['default'] ?? null );
+ $this->assertArrayNotHasKey( 'default', $this->get_product_type_alias_schema_branch( $update_schema, 'physical' )['properties']['product_type_alias'] ?? array() );
+ $this->assertNotContains( 'variable', $query_schema['properties']['product_type_alias']['enum'] ?? array() );
+ $this->assertNotContains( 'variable', $this->get_product_type_aliases_from_schema_branches( $create_schema ) );
+ $this->assertArrayNotHasKey(
+ 'product_type_alias',
+ wp_get_ability( 'woocommerce/product-create' )->get_output_schema()['properties']['product']['properties'] ?? array()
+ );
+ $this->assertArrayNotHasKey( 'default', $create_schema );
+ $this->assertArrayNotHasKey( 'default', $update_schema );
+ }
+
+ /**
+ * @testdox Should expose only alias-relevant product fields in mutation schema branches.
+ */
+ public function test_product_mutation_schema_branches_only_include_alias_relevant_fields(): void {
+ $create_schema = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+ $update_schema = wp_get_ability( 'woocommerce/product-update' )->get_input_schema();
+
+ $physical_properties = $this->get_product_type_alias_schema_branch( $create_schema, 'physical' )['properties'];
+ $affiliate_properties = $this->get_product_type_alias_schema_branch( $create_schema, 'affiliate' )['properties'];
+ $grouped_properties = $this->get_product_type_alias_schema_branch( $create_schema, 'grouped' )['properties'];
+ $common_properties = $update_schema['oneOf'][0]['properties'];
+
+ $this->assertArrayNotHasKey( 'external_url', $physical_properties );
+ $this->assertArrayNotHasKey( 'button_text', $physical_properties );
+ $this->assertArrayNotHasKey( 'grouped_products', $physical_properties );
+
+ $this->assertArrayNotHasKey( 'manage_stock', $affiliate_properties );
+ $this->assertArrayNotHasKey( 'stock_quantity', $affiliate_properties );
+ $this->assertArrayNotHasKey( 'grouped_products', $affiliate_properties );
+
+ $this->assertArrayNotHasKey( 'regular_price', $grouped_properties );
+ $this->assertArrayNotHasKey( 'sale_price', $grouped_properties );
+ $this->assertArrayNotHasKey( 'stock_status', $grouped_properties );
+
+ $this->assertArrayNotHasKey( 'product_type_alias', $common_properties );
+ $this->assertArrayNotHasKey( 'regular_price', $common_properties );
+ $this->assertArrayNotHasKey( 'external_url', $common_properties );
+ $this->assertArrayNotHasKey( 'grouped_products', $common_properties );
+ }
+
+ /**
+ * @testdox Should describe order output primitives using WooCommerce registries.
+ */
+ public function test_order_output_schema_uses_woocommerce_primitive_constraints(): void {
+ $output_schema = wp_get_ability( 'woocommerce/orders-query' )->get_output_schema();
+ $order = $output_schema['properties']['orders']['items']['properties'] ?? array();
+
+ $this->assertContains( 'auto-draft', $order['status']['enum'] ?? array() );
+ $this->assertContains( 'trash', $order['status']['enum'] ?? array() );
+ $this->assertContains( 'checkout-draft', $order['status']['enum'] ?? array() );
+ $this->assertContains( get_woocommerce_currency(), $order['currency']['enum'] ?? array() );
+ $this->assertSame( array( 'string', 'null' ), $order['billing_email']['type'] ?? null );
+ $this->assertSame( 'email', $order['billing_email']['format'] ?? null );
+ $this->assertNotEmpty( $order['line_items']['description'] ?? '' );
+ $this->assertSame( 'array', $order['line_items']['type'] ?? null );
+ $this->assertSame( 'object', $order['line_items']['items']['type'] ?? null );
+ $this->assertArrayHasKey( 'quantity', $order['line_items']['items']['properties'] ?? array() );
+ }
+
+ /**
+ * @testdox Should exclude checkout-draft from order status inputs.
+ */
+ public function test_order_status_input_schemas_exclude_checkout_draft(): void {
+ $query_schema = wp_get_ability( 'woocommerce/orders-query' )->get_input_schema();
+ $update_schema = wp_get_ability( 'woocommerce/order-update-status' )->get_input_schema();
+
+ $this->assertNotContains( 'checkout-draft', $query_schema['properties']['status']['enum'] ?? array() );
+ $this->assertNotContains( 'checkout-draft', $update_schema['properties']['status']['enum'] ?? array() );
+ }
+
+ /**
+ * @testdox Should register extension ability classes appended via the loader filter.
+ */
+ public function test_loader_filter_accepts_valid_extension_classes(): void {
+ add_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_test_extension_ability_definition_class' ) );
+ $this->register_domain_abilities();
+ remove_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_test_extension_ability_definition_class' ) );
+
+ $this->registered_ability_ids[] = TestExtensionAbilityDefinition::ABILITY_ID;
+ $ability = wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID );
+
+ $this->assertNotNull( $ability, 'Extension ability should be registered.' );
+ $this->assertSame( 'woocommerce', $ability->get_category() );
+
+ $result = $ability->execute();
+
+ $this->assertNotWPError( $result );
+ $this->assertTrue( $result['ok'] );
+ }
+
+ /**
+ * @testdox Should skip extension ability classes that use the reserved WooCommerce namespace.
+ */
+ public function test_loader_filter_skips_extension_classes_using_reserved_woocommerce_namespace(): void {
+ add_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_reserved_woocommerce_ability_definition_class' ) );
+ $this->register_domain_abilities();
+ remove_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_reserved_woocommerce_ability_definition_class' ) );
+
+ $ability = wp_get_ability( TestReservedWooAbilityDefinition::ABILITY_ID );
+
+ $this->assert_products_query_ability_is_canonical( $ability );
+ }
+
+ /**
+ * @testdox Should preserve core WooCommerce definitions when the reserved namespace was registered first.
+ */
+ public function test_loader_preserves_core_definition_when_reserved_namespace_ability_exists(): void {
+ $this->unregister_domain_abilities();
+
+ $shadow_callback = static function (): void {
+ wp_register_ability(
+ TestReservedWooAbilityDefinition::ABILITY_ID,
+ TestReservedWooAbilityDefinition::get_registration_args()
+ );
+ };
+ $logger = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+ $logger_filter = static function () use ( $logger ) {
+ return $logger;
+ };
+
+ $logger
+ ->expects( $this->once() )
+ ->method( 'warning' )
+ ->with(
+ $this->stringContains( 'previously registered ability' ),
+ $this->callback(
+ static function ( $context ): bool {
+ return is_array( $context )
+ && 'woocommerce-abilities' === ( $context['source'] ?? null )
+ && TestReservedWooAbilityDefinition::ABILITY_ID === ( $context['ability_name'] ?? null )
+ && ProductsQuery::class === ( $context['definition_class'] ?? null )
+ && 'woocommerce/' === ( $context['reserved_prefix'] ?? null );
+ }
+ )
+ );
+
+ add_filter( 'woocommerce_logging_class', $logger_filter );
+ add_action( 'wp_abilities_api_init', $shadow_callback, 5 );
+ add_action( 'wp_abilities_api_init', array( AbilitiesLoader::class, 'register_abilities' ), 10 );
+
+ try {
+ do_action( 'wp_abilities_api_init' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Test bootstrap for Abilities API registration.
+ } finally {
+ remove_filter( 'woocommerce_logging_class', $logger_filter );
+ remove_action( 'wp_abilities_api_init', $shadow_callback, 5 );
+ remove_action( 'wp_abilities_api_init', array( AbilitiesLoader::class, 'register_abilities' ), 10 );
+ }
+
+ $ability = wp_get_ability( TestReservedWooAbilityDefinition::ABILITY_ID );
+
+ $this->assert_products_query_ability_is_canonical( $ability );
+ }
+
+ /**
+ * @testdox Should not replace canonical abilities when registration is called again in the same request.
+ */
+ public function test_register_abilities_is_idempotent_for_existing_canonical_abilities(): void {
+ $logger = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+ $logger_filter = static function () use ( $logger ) {
+ return $logger;
+ };
+
+ $logger
+ ->expects( $this->never() )
+ ->method( 'warning' );
+
+ add_filter( 'woocommerce_logging_class', $logger_filter );
+
+ try {
+ $this->register_domain_abilities();
+ } finally {
+ remove_filter( 'woocommerce_logging_class', $logger_filter );
+ }
+
+ foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+ $this->assertNotNull( wp_get_ability( $ability_id ), "{$ability_id} should remain registered." );
+ }
+ }
+
+ /**
+ * @testdox Should ignore filter entries that are not strings or AbilityDefinition implementations.
+ */
+ public function test_loader_filter_skips_invalid_entries(): void {
+ $callback = static function ( array $classes ): array {
+ $classes[] = '\\Some\\Class\\That\\Does\\Not\\Exist';
+ // Real class but not an AbilityDefinition.
+ $classes[] = \WC_Order::class;
+ // Wrong type.
+ $classes[] = 42;
+
+ return $classes;
+ };
+
+ add_filter( 'woocommerce_ability_definition_classes', $callback );
+ $this->register_domain_abilities();
+ remove_filter( 'woocommerce_ability_definition_classes', $callback );
+
+ // Canonical abilities should still register; nothing extra.
+ foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+ $this->assertNotNull( wp_get_ability( $ability_id ), "{$ability_id} should remain registered." );
+ }
+ }
+
+ /**
+ * @testdox Should keep canonical abilities when the loader filter returns an invalid value.
+ */
+ public function test_loader_filter_cannot_remove_canonical_abilities_with_invalid_return(): void {
+ $this->unregister_domain_abilities();
+
+ $callback = static function () {
+ return 'not-a-class-list';
+ };
+
+ add_filter( 'woocommerce_ability_definition_classes', $callback );
+ $this->register_domain_abilities();
+ remove_filter( 'woocommerce_ability_definition_classes', $callback );
+
+ foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+ $this->assertNotNull( wp_get_ability( $ability_id ), "{$ability_id} should remain registered." );
+ }
+ }
+
+ /**
+ * @testdox Should register WooCommerce ability categories idempotently.
+ */
+ public function test_ability_category_registration_is_idempotent(): void {
+ if ( ! function_exists( 'wp_register_ability_category' ) || ! function_exists( 'wp_has_ability_category' ) ) {
+ $this->markTestSkipped( 'Abilities API category registry is not available.' );
+ }
+
+ AbilitiesCategories::register_categories();
+ AbilitiesCategories::register_categories();
+
+ $this->assertTrue( wp_has_ability_category( 'woocommerce' ) );
+ $this->assertTrue( wp_has_ability_category( 'woocommerce-rest' ) );
+ $this->registered_category_ids[] = 'woocommerce-rest';
+ }
+
+ /*
+ * ---------------------------------------------------------------------
+ * Permission negative paths
+ * ---------------------------------------------------------------------
+ */
+
+ /**
+ * @testdox Should reject every canonical ability when no user is authenticated.
+ *
+ * @dataProvider provider_canonical_abilities_with_minimal_input
+ *
+ * @param string $ability_id Ability ID.
+ * @param array $input Minimal valid-shape input.
+ */
+ public function test_abilities_reject_unauthenticated_user( string $ability_id, array $input ): void {
+ wp_set_current_user( 0 );
+
+ $result = wp_get_ability( $ability_id )->execute( $input );
+
+ $this->assertWPError( $result, "{$ability_id} should reject unauthenticated users." );
+ $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject every canonical ability for a subscriber with no shop caps.
+ *
+ * @dataProvider provider_canonical_abilities_with_minimal_input
+ *
+ * @param string $ability_id Ability ID.
+ * @param array $input Minimal valid-shape input.
+ */
+ public function test_abilities_reject_subscriber_without_caps( string $ability_id, array $input ): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $result = wp_get_ability( $ability_id )->execute( $input );
+
+ $this->assertWPError( $result, "{$ability_id} should reject subscribers without shop caps." );
+ $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+ }
+
+ /**
+ * Provides ability IDs and minimal valid-shape input for cap negative-path tests.
+ *
+ * @return array<string, array{0: string, 1: array}>
+ */
+ public function provider_canonical_abilities_with_minimal_input(): array {
+ // Valid-shape input only — permission check should fire before execute.
+ return array(
+ 'products-query' => array(
+ 'woocommerce/products-query',
+ array(
+ 'id' => 1,
+ ),
+ ),
+ 'product-create' => array(
+ 'woocommerce/product-create',
+ array(
+ 'name' => 'Forbidden',
+ ),
+ ),
+ 'product-update' => array(
+ 'woocommerce/product-update',
+ array(
+ 'id' => 1,
+ 'name' => 'Forbidden',
+ ),
+ ),
+ 'product-delete' => array(
+ 'woocommerce/product-delete',
+ array(
+ 'id' => 1,
+ ),
+ ),
+ 'orders-query' => array(
+ 'woocommerce/orders-query',
+ array(
+ 'id' => 1,
+ ),
+ ),
+ 'order-update-status' => array(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => 1,
+ 'status' => 'processing',
+ ),
+ ),
+ 'order-add-note' => array(
+ 'woocommerce/order-add-note',
+ array(
+ 'id' => 1,
+ 'note' => 'denied',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox Should require object edit and delete permissions for product mutations.
+ */
+ public function test_product_mutations_require_object_permissions(): void {
+ $product_to_update = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Owned by another user' ) );
+ $product_to_delete = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Also owned by another user' ) );
+ $this->created_product_ids[] = $product_to_update->get_id();
+ $this->created_product_ids[] = $product_to_delete->get_id();
+ $limited_product_manager_id = $this->create_user_with_caps(
+ array(
+ 'edit_products',
+ 'edit_published_products',
+ 'delete_products',
+ 'delete_published_products',
+ )
+ );
+ wp_set_current_user( $limited_product_manager_id );
+
+ $cases = array(
+ 'woocommerce/product-update' => array(
+ 'id' => $product_to_update->get_id(),
+ 'name' => 'Unauthorized update',
+ ),
+ 'woocommerce/product-delete' => array(
+ 'id' => $product_to_delete->get_id(),
+ ),
+ );
+
+ foreach ( $cases as $ability_id => $input ) {
+ $result = wp_get_ability( $ability_id )->execute( $input );
+
+ $this->assertWPError( $result, "{$ability_id} should reject users without object-level access." );
+ $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+ }
+ }
+
+ /**
+ * @testdox Should require object edit permissions for order mutations.
+ */
+ public function test_order_mutations_require_object_edit_permissions(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+ $limited_order_manager_id = $this->create_user_with_caps(
+ array(
+ 'edit_shop_orders',
+ 'edit_published_shop_orders',
+ )
+ );
+ wp_set_current_user( $limited_order_manager_id );
+
+ $cases = array(
+ 'woocommerce/order-update-status' => array(
+ 'id' => $order->get_id(),
+ 'status' => 'processing',
+ ),
+ 'woocommerce/order-add-note' => array(
+ 'id' => $order->get_id(),
+ 'note' => 'Unauthorized note',
+ ),
+ );
+
+ foreach ( $cases as $ability_id => $input ) {
+ $result = wp_get_ability( $ability_id )->execute( $input );
+
+ $this->assertWPError( $result, "{$ability_id} should reject users without object-level access." );
+ $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+ }
+ }
+
+ /**
+ * @testdox Should require publish_products to transition an existing draft to statuses that publish capability protects.
+ *
+ * @dataProvider provider_product_statuses_requiring_publish_cap
+ *
+ * @param string $status Product status.
+ */
+ public function test_product_update_publish_transition_requires_publish_cap( string $status ): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+ $this->created_product_ids[] = $product->get_id();
+
+ $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ $user = get_user_by( 'id', $user_id );
+ $user->add_cap( 'edit_products' );
+ $user->add_cap( 'edit_others_products' );
+ $user->add_cap( 'edit_published_products' );
+ wp_set_current_user( $user_id );
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'status' => $status,
+ )
+ );
+
+ $this->assertWPError( $result, "A user without publish_products should not be able to set status {$status}." );
+ $this->assertSame( 'woocommerce_product_publish_forbidden', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should allow non-status updates to published products without publish_products.
+ */
+ public function test_product_update_allows_published_product_edits_without_publish_cap_when_status_is_unchanged(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'status' => 'publish' ) );
+ $this->created_product_ids[] = $product->get_id();
+
+ $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ $user = get_user_by( 'id', $user_id );
+ $user->add_cap( 'edit_products' );
+ $user->add_cap( 'edit_others_products' );
+ $user->add_cap( 'edit_published_products' );
+ wp_set_current_user( $user_id );
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Updated Published Product',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'Updated Published Product', $result['product']['name'] );
+ $this->assertSame( 'publish', $result['product']['status'] );
+ }
+
+ /**
+ * Product statuses that require publish_products for a transition.
+ *
+ * @return array<string, array{0: string}>
+ */
+ public function provider_product_statuses_requiring_publish_cap(): array {
+ return array(
+ 'publish' => array( 'publish' ),
+ 'future' => array( 'future' ),
+ 'private' => array( 'private' ),
+ );
+ }
+
+ /*
+ * ---------------------------------------------------------------------
+ * Input validation
+ * ---------------------------------------------------------------------
+ */
+
+ /**
+ * @testdox Should reject order status updates with an unknown status slug.
+ */
+ public function test_order_update_status_rejects_invalid_status_slug(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'totally-bogus',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject product creation with a status not in the allowed enum.
+ */
+ public function test_product_create_rejects_unknown_status(): void {
+ $result = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Bad Status Product',
+ 'status' => 'not-a-real-status',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject auto-draft as a product mutation status.
+ */
+ public function test_product_create_rejects_auto_draft_status(): void {
+ $result = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Auto Draft Product',
+ 'status' => 'auto-draft',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject extra unknown fields on product create input.
+ */
+ public function test_product_create_rejects_unknown_input_field(): void {
+ $result = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Mass Assigned',
+ 'invoice_url' => 'https://attacker.example.com',
+ 'admin_notes' => 'tampering',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject orders-query orderby values outside the allowed enum.
+ */
+ public function test_orders_query_rejects_unknown_orderby(): void {
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array( 'orderby' => 'malicious_field' )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject uppercase order directions.
+ */
+ public function test_orders_query_rejects_uppercase_order_direction(): void {
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array( 'order' => 'ASC' )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject non-numeric product prices during schema validation.
+ *
+ * @dataProvider provider_invalid_product_price_inputs
+ *
+ * @param string $price Invalid price input.
+ */
+ public function test_product_create_rejects_non_numeric_prices( string $price ): void {
+ $result = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Invalid Price Product',
+ 'regular_price' => $price,
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * Provides invalid product price inputs.
+ *
+ * @return array<string, array{0: string}>
+ */
+ public function provider_invalid_product_price_inputs(): array {
+ return array(
+ 'text' => array( 'free' ),
+ 'currency-symbol' => array( '$10.00' ),
+ 'thousand-separator' => array( '1,234.56' ),
+ );
+ }
+
+ /**
+ * @testdox Should accept the configured WooCommerce decimal separator for product prices.
+ */
+ public function test_product_create_accepts_configured_decimal_separator_for_prices(): void {
+ $original_decimal_separator = get_option( 'woocommerce_price_decimal_sep' );
+ $created = null;
+
+ $this->unregister_domain_abilities();
+ update_option( 'woocommerce_price_decimal_sep', ',' );
+
+ try {
+ $this->register_domain_abilities();
+
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Localized Price Product',
+ 'regular_price' => '10,99',
+ )
+ );
+ } finally {
+ $this->unregister_domain_abilities();
+ update_option( 'woocommerce_price_decimal_sep', $original_decimal_separator );
+ $this->register_domain_abilities();
+ }
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $this->assertSame( '10.99', $created['product']['regular_price'] );
+ }
+
+ /**
+ * @testdox Should reject whitespace-only order notes during schema validation.
+ */
+ public function test_order_add_note_schema_rejects_whitespace_only_note(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'note' => ' ',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject negative entity IDs during schema validation.
+ *
+ * @dataProvider provider_negative_entity_id_inputs
+ *
+ * @param string $ability_id Ability ID.
+ * @param array $input Ability input.
+ */
+ public function test_entity_id_schemas_reject_negative_ids( string $ability_id, array $input ): void {
+ $result = wp_get_ability( $ability_id )->execute( $input );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * Provides negative ID inputs for entity abilities.
+ *
+ * @return array<string, array{0: string, 1: array}>
+ */
+ public function provider_negative_entity_id_inputs(): array {
+ return array(
+ 'products-query' => array(
+ 'woocommerce/products-query',
+ array( 'id' => -123 ),
+ ),
+ 'product-update' => array(
+ 'woocommerce/product-update',
+ array(
+ 'id' => -123,
+ 'name' => 'Invalid',
+ ),
+ ),
+ 'product-delete' => array(
+ 'woocommerce/product-delete',
+ array( 'id' => -123 ),
+ ),
+ 'orders-query' => array(
+ 'woocommerce/orders-query',
+ array( 'id' => -123 ),
+ ),
+ 'order-update-status' => array(
+ 'woocommerce/order-update-status',
+ array(
+ 'id' => -123,
+ 'status' => 'processing',
+ ),
+ ),
+ 'order-add-note' => array(
+ 'woocommerce/order-add-note',
+ array(
+ 'id' => -123,
+ 'note' => 'Invalid',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox Should reject negative order query filter IDs during schema validation.
+ *
+ * @dataProvider provider_negative_order_query_filter_id_inputs
+ *
+ * @param array $input Ability input.
+ */
+ public function test_orders_query_filter_id_schemas_reject_negative_ids( array $input ): void {
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute( $input );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+ }
+
+ /**
+ * Provides negative order query filter ID inputs.
+ *
+ * @return array<string, array{0: array}>
+ */
+ public function provider_negative_order_query_filter_id_inputs(): array {
+ return array(
+ 'customer-id' => array(
+ array( 'customer_id' => -123 ),
+ ),
+ 'parent' => array(
+ array( 'parent' => -123 ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox Should allow customer_id=0 to filter guest orders.
+ */
+ public function test_orders_query_allows_customer_id_zero_for_guest_orders(): void {
+ $email = wp_unique_id( 'abilities-guest-order-' ) . '@example.com';
+ $order = \WC_Helper_Order::create_order();
+ $order->set_customer_id( 0 );
+ $order->set_billing_email( $email );
+ $order->save();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'customer_id' => 0,
+ 'billing_email' => $email,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( array( $order->get_id() ), array_column( $result['orders'], 'id' ) );
+ }
+
+ /**
+ * Provides order storage engine states.
+ *
+ * @return array<string, array{0: bool}>
+ */
+ public function provider_order_storage_engines(): array {
+ return array(
+ 'legacy' => array( false ),
+ 'hpos' => array( true ),
+ );
+ }
+
+ /*
+ * ---------------------------------------------------------------------
+ * Execution behaviors
+ * ---------------------------------------------------------------------
+ */
+
+ /**
+ * @testdox Should not force products-query to published products by default.
+ */
+ public function test_products_query_does_not_force_publish_status_by_default(): void {
+ $published = \WC_Helper_Product::create_simple_product(
+ true,
+ array( 'name' => 'Public Item' )
+ );
+ $this->created_product_ids[] = $published->get_id();
+
+ $draft = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Draft Item',
+ 'status' => 'draft',
+ )
+ );
+ $this->created_product_ids[] = $draft->get_id();
+
+ $result = wp_get_ability( 'woocommerce/products-query' )->execute( array() );
+
+ $this->assertNotWPError( $result );
+ $ids = array_column( $result['products'], 'id' );
+ $this->assertContains( $published->get_id(), $ids );
+ $this->assertContains( $draft->get_id(), $ids );
+ }
+
+ /**
+ * @testdox Should return drafts when explicitly filtered by status=draft.
+ */
+ public function test_products_query_returns_drafts_when_explicitly_requested(): void {
+ $draft = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Hidden Draft',
+ 'status' => 'draft',
+ 'sku' => 'hidden-draft-sku',
+ )
+ );
+ $this->created_product_ids[] = $draft->get_id();
+
+ $result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'status' => 'draft',
+ 'sku' => 'hidden-draft-sku',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 1, $result['total_pages'] );
+ $this->assertSame( $draft->get_id(), $result['products'][0]['id'] );
+ }
+
+ /**
+ * @testdox Should include currency context in product responses.
+ */
+ public function test_product_response_includes_currency_context(): void {
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Priced Product',
+ 'regular_price' => '12.34',
+ )
+ );
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+
+ $this->assertSame( get_woocommerce_currency(), $created['product']['currency'] );
+ $this->assertNotEmpty( $created['product']['currency_symbol'] );
+ $this->assertSame( '12.34', $created['product']['regular_price'] );
+ }
+
+ /**
+ * @testdox Should map digital products to simple virtual downloadable products.
+ */
+ public function test_product_create_maps_digital_product_type_to_simple_downloadable_product(): void {
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Digital Product',
+ 'product_type_alias' => 'digital',
+ 'regular_price' => '19.99',
+ )
+ );
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $this->assertSame( 'simple', $created['product']['type'] );
+ $this->assertTrue( $created['product']['virtual'] );
+ $this->assertTrue( $created['product']['downloadable'] );
+ }
+
+ /**
+ * @testdox Should map virtual products to simple virtual non-downloadable products.
+ */
+ public function test_product_create_maps_virtual_product_type_alias_to_simple_virtual_product(): void {
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Virtual Product',
+ 'product_type_alias' => 'virtual',
+ 'regular_price' => '29.99',
+ )
+ );
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $this->assertSame( 'simple', $created['product']['type'] );
+ $this->assertTrue( $created['product']['virtual'] );
+ $this->assertFalse( $created['product']['downloadable'] );
+ }
+
+ /**
+ * @testdox Should query simple products by agent-friendly physical, virtual, and digital product type aliases.
+ */
+ public function test_products_query_maps_agent_friendly_product_types_to_product_fields(): void {
+ $physical_sku = wp_unique_id( 'abilities-physical-' );
+ $virtual_sku = wp_unique_id( 'abilities-virtual-' );
+ $digital_sku = wp_unique_id( 'abilities-digital-' );
+ $hybrid_sku = wp_unique_id( 'abilities-downloadable-shippable-' );
+
+ $physical = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Physical Query Product',
+ 'product_type_alias' => 'physical',
+ 'sku' => $physical_sku,
+ )
+ );
+
+ $this->assertNotWPError( $physical );
+ $this->created_product_ids[] = $physical['product']['id'];
+
+ $virtual = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Virtual Query Product',
+ 'product_type_alias' => 'virtual',
+ 'sku' => $virtual_sku,
+ )
+ );
+
+ $this->assertNotWPError( $virtual );
+ $this->created_product_ids[] = $virtual['product']['id'];
+
+ $digital = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Digital Query Product',
+ 'product_type_alias' => 'digital',
+ 'sku' => $digital_sku,
+ )
+ );
+
+ $this->assertNotWPError( $digital );
+ $this->created_product_ids[] = $digital['product']['id'];
+
+ $hybrid = \WC_Helper_Product::create_simple_product();
+ $hybrid->set_sku( $hybrid_sku );
+ $hybrid->set_virtual( false );
+ $hybrid->set_downloadable( true );
+ $hybrid->save();
+ $this->created_product_ids[] = $hybrid->get_id();
+
+ $physical_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'product_type_alias' => 'physical',
+ 'sku' => $physical_sku,
+ )
+ );
+
+ $this->assertNotWPError( $physical_result );
+ $this->assertSame( 1, $physical_result['total_pages'] );
+ $this->assertSame( $physical['product']['id'], $physical_result['products'][0]['id'] );
+
+ $virtual_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'product_type_alias' => 'virtual',
+ 'sku' => $virtual_sku,
+ )
+ );
+
+ $this->assertNotWPError( $virtual_result );
+ $this->assertSame( 1, $virtual_result['total_pages'] );
+ $this->assertSame( $virtual['product']['id'], $virtual_result['products'][0]['id'] );
+
+ $digital_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'product_type_alias' => 'digital',
+ 'sku' => $digital_sku,
+ )
+ );
+
+ $this->assertNotWPError( $digital_result );
+ $this->assertSame( 1, $digital_result['total_pages'] );
+ $this->assertSame( $digital['product']['id'], $digital_result['products'][0]['id'] );
+
+ $mismatched_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'product_type_alias' => 'physical',
+ 'sku' => $digital_sku,
+ )
+ );
+
+ $this->assertNotWPError( $mismatched_result );
+ $this->assertSame( 0, $mismatched_result['total_pages'] );
+
+ $unfiltered_hybrid_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'sku' => $hybrid_sku,
+ )
+ );
+
+ $this->assertNotWPError( $unfiltered_hybrid_result );
+ $this->assertSame( $hybrid->get_id(), $unfiltered_hybrid_result['products'][0]['id'] );
+
+ $physical_hybrid_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'product_type_alias' => 'physical',
+ 'sku' => $hybrid_sku,
+ )
+ );
+
+ $this->assertNotWPError( $physical_hybrid_result );
+ $this->assertSame( 0, $physical_hybrid_result['total_pages'] );
+ }
+
+ /**
+ * @testdox Should map affiliate products to external products.
+ */
+ public function test_product_create_maps_affiliate_product_type_to_external_product(): void {
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Affiliate Product',
+ 'product_type_alias' => 'affiliate',
+ 'regular_price' => '49.99',
+ 'external_url' => 'https://example.com/buy',
+ 'button_text' => 'Buy elsewhere',
+ )
+ );
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $this->assertSame( 'external', $created['product']['type'] );
+ $this->assertSame( 'https://example.com/buy', $created['product']['external_url'] );
+ $this->assertSame( 'Buy elsewhere', $created['product']['button_text'] );
+ }
+
+ /**
+ * @testdox Should map grouped products to grouped WooCommerce products with children.
+ */
+ public function test_product_create_maps_grouped_product_type_to_grouped_product(): void {
+ $child = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $child->get_id();
+
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Grouped Product',
+ 'product_type_alias' => 'grouped',
+ 'grouped_products' => array( $child->get_id() ),
+ )
+ );
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $this->assertSame( 'grouped', $created['product']['type'] );
+ $this->assertSame( array( $child->get_id() ), $created['product']['grouped_products'] );
+ }
+
+ /**
+ * @testdox Should support the WooCommerce SKU wildcard for products with any non-empty SKU.
+ */
+ public function test_products_query_supports_sku_wildcard_for_products_with_any_sku(): void {
+ $sku_product = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'SKU Product',
+ 'sku' => wp_unique_id( 'abilities-any-sku-' ),
+ )
+ );
+
+ $this->assertNotWPError( $sku_product );
+ $this->created_product_ids[] = $sku_product['product']['id'];
+
+ $no_sku_product = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'No SKU Product',
+ )
+ );
+
+ $this->assertNotWPError( $no_sku_product );
+ $this->created_product_ids[] = $no_sku_product['product']['id'];
+
+ $result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'sku' => '*',
+ 'per_page' => 100,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+
+ $ids = array_column( $result['products'], 'id' );
+ $this->assertContains( $sku_product['product']['id'], $ids );
+ $this->assertNotContains( $no_sku_product['product']['id'], $ids );
+ }
+
+ /**
+ * @testdox Should reject invalid grouped product IDs before setting children.
+ */
+ public function test_product_create_rejects_invalid_grouped_product_ids(): void {
+ $result = ProductCreate::execute(
+ array(
+ 'name' => 'Invalid Grouped Product',
+ 'product_type_alias' => 'grouped',
+ 'grouped_products' => array( -12 ),
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_grouped_products_invalid', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject fields that are unsupported by the selected product type alias.
+ */
+ public function test_product_create_rejects_fields_not_supported_by_product_type_alias(): void {
+ $result = ProductCreate::execute(
+ array(
+ 'name' => 'Invalid Grouped Product',
+ 'product_type_alias' => 'grouped',
+ 'regular_price' => '19.99',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_field_unsupported', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return an error when product creation fails to persist.
+ */
+ public function test_product_create_returns_error_when_save_fails(): void {
+ add_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+ try {
+ $result = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Failed Product',
+ )
+ );
+ } finally {
+ remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+ }
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_create_failed', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should support fractional product stock quantities when WooCommerce is configured for them.
+ */
+ public function test_product_stock_quantity_schema_allows_fractional_stock_amounts(): void {
+ $this->unregister_domain_abilities();
+ remove_filter( 'woocommerce_stock_amount', 'intval' );
+ add_filter( 'woocommerce_stock_amount', array( $this, 'preserve_fractional_stock_amount' ) );
+
+ try {
+ $this->register_domain_abilities();
+
+ $input_schema = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+ $output_schema = wp_get_ability( 'woocommerce/product-create' )->get_output_schema();
+
+ $this->assertSame( 'number', $this->get_product_type_alias_schema_branch( $input_schema, 'physical' )['properties']['stock_quantity']['type'] ?? null );
+ $this->assertSame(
+ array( 'number', 'null' ),
+ $output_schema['properties']['product']['properties']['stock_quantity']['type'] ?? null
+ );
+
+ $created = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Fractional Stock Product',
+ 'manage_stock' => true,
+ 'stock_quantity' => 1.5,
+ )
+ );
+
+ $this->assertNotWPError( $created );
+ $this->created_product_ids[] = $created['product']['id'];
+ $this->assertSame( 1.5, $created['product']['stock_quantity'] );
+ } finally {
+ remove_filter( 'woocommerce_stock_amount', array( $this, 'preserve_fractional_stock_amount' ) );
+ add_filter( 'woocommerce_stock_amount', 'intval' );
+ }
+ }
+
+ /**
+ * @testdox Should update product properties and reflect changes in the response.
+ */
+ public function test_product_update_changes_props_and_returns_updated_response(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'product_type_alias' => 'physical',
+ 'name' => 'Updated Name',
+ 'regular_price' => '99.00',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'Updated Name', $result['product']['name'] );
+ $this->assertSame( '99.00', $result['product']['regular_price'] );
+ }
+
+ /**
+ * @testdox Should return a structured error when product updates throw during save.
+ */
+ public function test_product_update_returns_error_when_save_throws_exception(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+ $throw_on_update = static function (): void {
+ throw new \Exception( 'Generic product save failure.' );
+ };
+ $logger = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+ $logger_filter = static function () use ( $logger ) {
+ return $logger;
+ };
+
+ $logger
+ ->expects( $this->once() )
+ ->method( 'error' )
+ ->with(
+ $this->stringContains( 'failed to save product' ),
+ $this->callback(
+ static function ( $context ) use ( $product ): bool {
+ return is_array( $context )
+ && 'woocommerce-abilities' === ( $context['source'] ?? null )
+ && 'woocommerce_product_update_failed' === ( $context['failure_code'] ?? null )
+ && $product->get_id() === ( $context['product_id'] ?? null )
+ && \Exception::class === ( $context['exception'] ?? null )
+ && 'Generic product save failure.' === ( $context['error_message'] ?? null );
+ }
+ )
+ );
+
+ add_filter( 'woocommerce_logging_class', $logger_filter );
+ add_action( 'woocommerce_update_product', $throw_on_update );
+
+ try {
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Updated Name',
+ )
+ );
+ } finally {
+ remove_action( 'woocommerce_update_product', $throw_on_update );
+ remove_filter( 'woocommerce_logging_class', $logger_filter );
+ }
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_update_failed', $result->get_error_code() );
+ $this->assertSame( 'Failed to save product.', $result->get_error_message() );
+ $this->assertSame( 500, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * @testdox Should apply product type configuration when updating a product type alias.
+ */
+ public function test_product_update_applies_product_type_configuration(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'product_type_alias' => 'digital',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'simple', $result['product']['type'] );
+ $this->assertTrue( $result['product']['virtual'] );
+ $this->assertTrue( $result['product']['downloadable'] );
+ }
+
+ /**
+ * @testdox Should reject product updates that include no changed fields.
+ */
+ public function test_product_update_requires_at_least_one_mutation_field(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_update_no_fields', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should hard-delete a product when force=true.
+ */
+ public function test_product_delete_force_true_hard_deletes(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'force' => true,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertTrue( $result['deleted'] );
+ $this->assertSame( $product->get_id(), $result['id'] );
+ $this->assertNull( get_post( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Should reject product variation IDs when deleting products.
+ */
+ public function test_product_delete_rejects_variation_ids(): void {
+ $variation_id = $this->create_variation_product_id_for_test();
+
+ $result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+ array(
+ 'id' => $variation_id,
+ 'force' => true,
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
+ $this->assertNotNull( wc_get_product( $variation_id ) );
+ }
+
+ /**
+ * @testdox Should trash a product by default.
+ */
+ public function test_product_delete_defaults_to_trash(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertTrue( $result['deleted'] );
+ $this->assertSame( 'trash', get_post_status( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Should reject soft deletes when product trashing is not supported.
+ */
+ public function test_product_delete_rejects_soft_delete_when_trash_not_supported(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ add_filter( 'woocommerce_product_object_trashable', '__return_false' );
+
+ try {
+ $result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ )
+ );
+ } finally {
+ remove_filter( 'woocommerce_product_object_trashable', '__return_false' );
+ }
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_trash_not_supported', $result->get_error_code() );
+ $this->assertNotSame( 'trash', get_post_status( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Should return an error when product deletion is blocked.
+ */
+ public function test_product_delete_returns_error_when_delete_is_blocked(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $callback = static function () {
+ return false;
+ };
+
+ add_filter( 'woocommerce_pre_delete_product', $callback );
+
+ try {
+ $result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ )
+ );
+ } finally {
+ remove_filter( 'woocommerce_pre_delete_product', $callback );
+ }
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_delete_failed', $result->get_error_code() );
+ $this->assertNotSame( 'trash', get_post_status( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Should return a not-found error when querying an unknown product ID.
+ */
+ public function test_products_query_returns_not_found_for_unknown_id(): void {
+ $result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => 999999 ) );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject product mutation unknown IDs without leaking existence.
+ *
+ * @dataProvider provider_product_mutation_unknown_id_inputs
+ *
+ * @param string $ability_id Ability ID.
+ * @param array $input Ability input.
+ */
+ public function test_product_mutations_reject_unknown_ids_without_leaking_existence( string $ability_id, array $input ): void {
+ $result = wp_get_ability( $ability_id )->execute( $input );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+ }
+
+ /**
+ * Provides product mutation inputs with unknown IDs.
+ *
+ * @return array<string, array{0: string, 1: array}>
+ */
+ public function provider_product_mutation_unknown_id_inputs(): array {
+ return array(
+ 'product-update' => array(
+ 'woocommerce/product-update',
+ array(
+ 'id' => 999999,
+ 'name' => 'Unknown Product',
+ ),
+ ),
+ 'product-delete' => array(
+ 'woocommerce/product-delete',
+ array(
+ 'id' => 999999,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox Should reject product variation IDs when querying products by ID.
+ */
+ public function test_products_query_rejects_variation_ids(): void {
+ $variation_id = $this->create_variation_product_id_for_test();
+
+ $result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $variation_id ) );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return products without public permalinks without output validation errors.
+ */
+ public function test_products_query_handles_products_without_public_permalink(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_status( 'auto-draft' );
+ $product->save();
+ $this->created_product_ids[] = $product->get_id();
+
+ $filter_permalink = static function ( $permalink, $post ) use ( $product ) {
+ return $post instanceof \WP_Post && $post->ID === $product->get_id() ? false : $permalink;
+ };
+
+ add_filter( 'post_type_link', $filter_permalink, 20, 2 );
+
+ try {
+ $result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ )
+ );
+ } finally {
+ remove_filter( 'post_type_link', $filter_permalink, 20 );
+ }
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'auto-draft', $result['products'][0]['status'] );
+ $this->assertNull( $result['products'][0]['permalink'] );
+ }
+
+ /**
+ * @testdox Should return a not-found error when orders-query receives an unknown order ID.
+ *
+ * @dataProvider provider_order_storage_engines
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+ */
+ public function test_orders_query_returns_not_found_for_unknown_id( bool $hpos_enabled ): void {
+ $this->set_order_storage_for_test( $hpos_enabled );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'id' => 999999 ) );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_order_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should query orders by billing email and include line items when requested.
+ */
+ public function test_orders_query_filters_by_billing_email_with_line_items(): void {
+ $order = \WC_Helper_Order::create_order();
+ $order->set_billing_email( 'domain-order-query@example.com' );
+ $order->save();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => 'domain-order-query@example.com',
+ 'include_line_items' => true,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 1, $result['total_pages'] );
+ $this->assertSame( $order->get_id(), $result['orders'][0]['id'] );
+ $this->assertNotEmpty( $result['orders'][0]['line_items'] );
+ $this->assertNotEmpty( $result['orders'][0]['currency_symbol'] );
+ }
+
+ /**
+ * @testdox Should exclude checkout-draft orders from default order queries.
+ *
+ * @dataProvider provider_order_storage_engines
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+ */
+ public function test_orders_query_excludes_checkout_draft_orders_by_default( bool $hpos_enabled ): void {
+ $this->set_order_storage_for_test( $hpos_enabled );
+
+ $email = wp_unique_id( 'abilities-checkout-draft-' ) . '@example.com';
+
+ $processing_order = \WC_Helper_Order::create_order();
+ $processing_order->set_billing_email( $email );
+ $processing_order->set_status( 'processing' );
+ $processing_order->save();
+ $this->created_order_ids[] = $processing_order->get_id();
+
+ $checkout_draft = \WC_Helper_Order::create_order();
+ $checkout_draft->set_billing_email( $email );
+ $checkout_draft->set_status( 'checkout-draft' );
+ $checkout_draft->save();
+ $this->created_order_ids[] = $checkout_draft->get_id();
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'per_page' => 10,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $ids = array_column( $result['orders'], 'id' );
+ $this->assertContains( $processing_order->get_id(), $ids );
+ $this->assertNotContains( $checkout_draft->get_id(), $ids );
+ }
+
+ /**
+ * @testdox Should sort orders by ID across order storage engines.
+ *
+ * @dataProvider provider_order_storage_engines
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+ */
+ public function test_orders_query_sorts_by_id_across_order_storage_engines( bool $hpos_enabled ): void {
+ $this->set_order_storage_for_test( $hpos_enabled );
+
+ $email = wp_unique_id( 'abilities-order-id-sort-' ) . '@example.com';
+ $first = $this->create_order_for_query_sorting( $email, '2025-01-01T12:00:00', '2025-01-01T12:00:00' );
+ $second = $this->create_order_for_query_sorting( $email, '2020-01-01T12:00:00', '2020-01-01T12:00:00' );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'orderby' => 'id',
+ 'order' => 'asc',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame(
+ array( $first->get_id(), $second->get_id() ),
+ array_column( $result['orders'], 'id' )
+ );
+ }
+
+ /**
+ * @testdox Should sort orders by modified date across order storage engines.
+ *
+ * @dataProvider provider_order_storage_engines
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+ */
+ public function test_orders_query_sorts_by_modified_date_across_order_storage_engines( bool $hpos_enabled ): void {
+ $this->set_order_storage_for_test( $hpos_enabled );
+
+ $email = wp_unique_id( 'abilities-order-modified-sort-' ) . '@example.com';
+ $first = $this->create_order_for_query_sorting( $email, '2020-01-01T12:00:00', '2025-01-01T12:00:00' );
+ $second = $this->create_order_for_query_sorting( $email, '2025-01-01T12:00:00', '2020-01-01T12:00:00' );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'orderby' => 'date_modified',
+ 'order' => 'asc',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame(
+ array( $second->get_id(), $first->get_id() ),
+ array_column( $result['orders'], 'id' )
+ );
+ }
+
+ /**
+ * @testdox Should return null for absent billing email values.
+ */
+ public function test_orders_query_returns_null_for_absent_billing_email(): void {
+ $order = wc_create_order();
+ $this->assertNotWPError( $order );
+ $order->save();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertNull( $result['orders'][0]['billing_email'] );
+ }
+
+ /**
+ * @testdox Should narrow orders-query results by date range.
+ */
+ public function test_orders_query_filters_by_date_range(): void {
+ $old_order = \WC_Helper_Order::create_order();
+ $old_order->set_date_created( '2020-01-15T00:00:00' );
+ $old_order->set_billing_email( 'date-range@example.com' );
+ $old_order->save();
+ $this->created_order_ids[] = $old_order->get_id();
+
+ $new_order = \WC_Helper_Order::create_order();
+ $new_order->set_date_created( '2025-01-15T00:00:00' );
+ $new_order->set_billing_email( 'date-range@example.com' );
+ $new_order->save();
+ $this->created_order_ids[] = $new_order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => 'date-range@example.com',
+ 'date_after' => '2024-01-01T00:00:00',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $ids = array_column( $result['orders'], 'id' );
+ $this->assertContains( $new_order->get_id(), $ids );
+ $this->assertNotContains( $old_order->get_id(), $ids );
+ }
+
+ /**
+ * @testdox Should filter modified_before with time precision across order storage engines.
+ *
+ * @dataProvider provider_order_storage_engines
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+ */
+ public function test_orders_query_filters_modified_before_with_time_precision( bool $hpos_enabled ): void {
+ $this->set_order_storage_for_test( $hpos_enabled );
+
+ $email = wp_unique_id( 'abilities-order-modified-before-' ) . '@example.com';
+ $early = $this->create_order_for_query_sorting( $email, '2025-01-15T09:00:00', '2025-01-15T10:00:00' );
+ $late = $this->create_order_for_query_sorting( $email, '2025-01-15T09:00:00', '2025-01-15T12:00:00' );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'modified_before' => '2025-01-15T11:00:00',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $ids = array_column( $result['orders'], 'id' );
+ $this->assertContains( $early->get_id(), $ids );
+ $this->assertNotContains( $late->get_id(), $ids );
+ }
+
+ /**
+ * @testdox Should change order status and surface failure when status update fails.
+ */
+ public function test_order_update_status_changes_status(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'processing',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'processing', $result['order']['status'] );
+ $this->assertSame( 'processing', wc_get_order( $order->get_id() )->get_status() );
+ }
+
+ /**
+ * @testdox Should reject unchanged order status updates without adding notes.
+ */
+ public function test_order_update_status_rejects_unchanged_status_without_adding_notes(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+ $note_count_before = count(
+ wc_get_order_notes(
+ array(
+ 'order_id' => $order->get_id(),
+ )
+ )
+ );
+
+ $result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'status' => $order->get_status(),
+ 'note' => 'This should be added through Add order note.',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_order_status_unchanged', $result->get_error_code() );
+ $this->assertStringContainsString( 'woocommerce/order-add-note', $result->get_error_message() );
+ $this->assertSame(
+ $note_count_before,
+ count(
+ wc_get_order_notes(
+ array(
+ 'order_id' => $order->get_id(),
+ )
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox Should treat order status updates as manual status changes.
+ */
+ public function test_order_update_status_uses_manual_status_update(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+ $manual_update_observed = false;
+
+ $callback = static function ( $order_id, $status ) use ( $order, &$manual_update_observed ) {
+ if ( $order->get_id() === $order_id && 'processing' === $status ) {
+ $manual_update_observed = true;
+ }
+ };
+
+ add_action( 'woocommerce_order_edit_status', $callback, 10, 2 );
+
+ try {
+ $result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'processing',
+ )
+ );
+ } finally {
+ remove_action( 'woocommerce_order_edit_status', $callback, 10 );
+ }
+
+ $this->assertNotWPError( $result );
+ $this->assertTrue( $manual_update_observed );
+ }
+
+ /**
+ * @testdox Should reject status updates against unknown order IDs without leaking existence.
+ */
+ public function test_order_update_status_rejects_unknown_id(): void {
+ $result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+ array(
+ 'id' => 999999,
+ 'status' => 'processing',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should attribute order notes to the acting user.
+ */
+ public function test_order_add_note_attributes_to_acting_user(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+
+ $user_id = $this->factory->user->create(
+ array(
+ 'role' => 'shop_manager',
+ 'display_name' => 'Audit Trail Admin',
+ )
+ );
+ wp_set_current_user( $user_id );
+
+ $result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'note' => 'Tracked by acting user.',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+
+ $comment = get_comment( $result['note_id'] );
+ $this->assertNotNull( $comment );
+ $this->assertSame( 'Audit Trail Admin', $comment->comment_author );
+ }
+
+ /**
+ * @testdox Should preserve safe HTML in order notes.
+ */
+ public function test_order_add_note_preserves_safe_html(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'note' => 'Packed with <strong>care</strong><script>alert("x")</script>.',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+
+ $comment = get_comment( $result['note_id'] );
+ $this->assertStringContainsString( '<strong>care</strong>', $comment->comment_content );
+ $this->assertStringNotContainsString( '<script>', $comment->comment_content );
+ }
+
+ /**
+ * @testdox Should reject whitespace-only order notes during execution.
+ */
+ public function test_order_add_note_execute_rejects_whitespace_only_note(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = OrderAddNote::execute(
+ array(
+ 'id' => $order->get_id(),
+ 'note' => ' ',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_order_note_required', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should mark notes as customer-facing when customer_note=true.
+ */
+ public function test_order_add_note_persists_customer_note_flag(): void {
+ $order = \WC_Helper_Order::create_order();
+ $this->created_order_ids[] = $order->get_id();
+
+ $result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+ array(
+ 'id' => $order->get_id(),
+ 'note' => 'Visible to customer.',
+ 'customer_note' => true,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( '1', get_comment_meta( $result['note_id'], 'is_customer_note', true ) );
+ }
+
+ /**
+ * Register the WooCommerce ability category for this test.
+ */
+ private function register_woocommerce_category(): void {
+ if ( ! function_exists( 'wp_register_ability_category' ) || ! function_exists( 'wp_has_ability_category' ) ) {
+ return;
+ }
+
+ if ( wp_has_ability_category( 'woocommerce' ) ) {
+ return;
+ }
+
+ $callback = static function () {
+ wp_register_ability_category(
+ 'woocommerce',
+ array(
+ 'label' => 'WooCommerce',
+ 'description' => 'Canonical store management abilities.',
+ )
+ );
+ };
+
+ add_action( 'wp_abilities_api_categories_init', $callback );
+ do_action( 'wp_abilities_api_categories_init' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Test bootstrap for Abilities API registration.
+ remove_action( 'wp_abilities_api_categories_init', $callback );
+
+ $this->registered_category_ids[] = 'woocommerce';
+ }
+
+ /**
+ * Register canonical domain abilities for this test.
+ */
+ private function register_domain_abilities(): void {
+ $callback = array( AbilitiesLoader::class, 'register_abilities' );
+
+ add_action( 'wp_abilities_api_init', $callback );
+ do_action( 'wp_abilities_api_init' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Test bootstrap for Abilities API registration.
+ remove_action( 'wp_abilities_api_init', $callback );
+ }
+
+ /**
+ * Unregister canonical domain abilities for this test.
+ */
+ private function unregister_domain_abilities(): void {
+ foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+ if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_id ) ) {
+ wp_unregister_ability( $ability_id );
+ }
+ }
+ }
+
+ /**
+ * Assert that a registered ability matches the canonical products-query definition.
+ *
+ * @param \WP_Ability|null $ability Ability instance.
+ */
+ private function assert_products_query_ability_is_canonical( $ability ): void {
+ $this->assertNotNull( $ability, 'Canonical products-query ability should be registered.' );
+
+ $registration_args = ProductsQuery::get_registration_args();
+
+ $this->assertSame( ProductsQuery::get_name(), $ability->get_name() );
+ $this->assertSame( $registration_args['category'], $ability->get_category() );
+ $this->assertSame( $registration_args['input_schema'], $ability->get_input_schema() );
+ $this->assertSame( $registration_args['output_schema'], $ability->get_output_schema() );
+ }
+
+ /**
+ * Get product type aliases from a branched product input schema.
+ *
+ * @param array $schema Input schema.
+ * @return array<int, string>
+ */
+ private function get_product_type_aliases_from_schema_branches( array $schema ): array {
+ $aliases = array();
+
+ foreach ( $schema['oneOf'] ?? array() as $branch ) {
+ $alias = $branch['properties']['product_type_alias']['enum'][0] ?? null;
+
+ if ( is_string( $alias ) ) {
+ $aliases[] = $alias;
+ }
+ }
+
+ return array_values( array_unique( $aliases ) );
+ }
+
+ /**
+ * Get a product input schema branch for a product type alias.
+ *
+ * @param array $schema Input schema.
+ * @param string $product_type_alias Product type alias.
+ * @return array
+ */
+ private function get_product_type_alias_schema_branch( array $schema, string $product_type_alias ): array {
+ foreach ( $schema['oneOf'] ?? array() as $branch ) {
+ if ( in_array( $product_type_alias, $branch['properties']['product_type_alias']['enum'] ?? array(), true ) ) {
+ return $branch;
+ }
+ }
+
+ $this->fail( "Missing product type alias schema branch: {$product_type_alias}" );
+ return array();
+ }
+
+ /**
+ * Create a user with specific capabilities.
+ *
+ * @param array<int, string> $capabilities Capabilities to grant.
+ * @return int
+ */
+ private function create_user_with_caps( array $capabilities ): int {
+ $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ $user = get_user_by( 'id', $user_id );
+
+ if ( $user instanceof \WP_User ) {
+ foreach ( $capabilities as $capability ) {
+ $user->add_cap( $capability );
+ }
+ }
+
+ return $user_id;
+ }
+
+ /**
+ * Add the test extension ability definition class.
+ *
+ * @param array $classes Ability definition class names.
+ * @return array
+ */
+ public function add_test_extension_ability_definition_class( array $classes ): array {
+ $classes[] = TestExtensionAbilityDefinition::class;
+
+ return $classes;
+ }
+
+ /**
+ * Add the test reserved WooCommerce ability definition class.
+ *
+ * @param array $classes Ability definition class names.
+ * @return array
+ */
+ public function add_reserved_woocommerce_ability_definition_class( array $classes ): array {
+ $classes[] = TestReservedWooAbilityDefinition::class;
+
+ return $classes;
+ }
+
+ /**
+ * Preserve fractional stock amounts for filtered stock quantity tests.
+ *
+ * @param mixed $amount Stock amount.
+ * @return float
+ */
+ public function preserve_fractional_stock_amount( $amount ): float {
+ return (float) $amount;
+ }
+
+ /**
+ * Create a product variation and track it for cleanup.
+ *
+ * @return int Variation product ID.
+ */
+ private function create_variation_product_id_for_test(): int {
+ $parent = \WC_Helper_Product::create_variation_product();
+ $variation_id = $parent->get_children()[0];
+
+ $this->created_product_ids[] = $variation_id;
+ $this->created_product_ids[] = $parent->get_id();
+
+ return $variation_id;
+ }
+
+ /**
+ * Set order storage mode for a test.
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled.
+ */
+ private function set_order_storage_for_test( bool $hpos_enabled ): void {
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+
+ if ( null === $this->original_cot_state ) {
+ $this->original_cot_state = OrderUtil::custom_orders_table_usage_is_enabled();
+ }
+
+ if ( $hpos_enabled && ! $this->cot_setup_for_test ) {
+ $this->setup_cot();
+ $this->cot_setup_for_test = true;
+ return;
+ }
+
+ $this->toggle_cot_feature_and_usage( $hpos_enabled );
+ }
+
+ /**
+ * Create an order with controlled dates for query tests.
+ *
+ * @param string $billing_email Billing email.
+ * @param string $date_created Created date.
+ * @param string $date_modified Modified date.
+ * @return \WC_Order
+ */
+ private function create_order_for_query_sorting( string $billing_email, string $date_created, string $date_modified ): \WC_Order {
+ $order = \WC_Helper_Order::create_order();
+ $order->set_billing_email( $billing_email );
+ $order->set_date_created( $date_created );
+ $order->set_date_modified( $date_modified );
+ $order->save();
+
+ $this->created_order_ids[] = $order->get_id();
+ $this->set_order_modified_date_for_query_test( $order, $date_modified );
+
+ return $order;
+ }
+
+ /**
+ * Set an order modified date directly in the authoritative storage table.
+ *
+ * @param \WC_Order $order Order object.
+ * @param string $date_modified Modified date.
+ */
+ private function set_order_modified_date_for_query_test( \WC_Order $order, string $date_modified ): void {
+ global $wpdb;
+
+ $timestamp = wc_string_to_datetime( $date_modified )->getTimestamp();
+ $gmt_date = gmdate( 'Y-m-d H:i:s', $timestamp );
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $wpdb->update(
+ OrdersTableDataStore::get_orders_table_name(),
+ array(
+ 'date_updated_gmt' => $gmt_date,
+ ),
+ array( 'id' => $order->get_id() )
+ );
+
+ return;
+ }
+
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_modified_gmt' => $gmt_date,
+ 'post_modified' => get_date_from_gmt( $gmt_date ),
+ ),
+ array( 'ID' => $order->get_id() )
+ );
+ clean_post_cache( $order->get_id() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
new file mode 100644
index 00000000000..b3a19084c5b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Test extension ability definition class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+/**
+ * Test extension ability definition.
+ */
+class TestExtensionAbilityDefinition implements AbilityDefinition {
+
+ public const ABILITY_ID = 'test-extension/test-extension-ability';
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ */
+ public static function get_name(): string {
+ return self::ABILITY_ID;
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => 'Test extension ability',
+ 'description' => 'Test extension ability registered through the ability loader.',
+ 'category' => 'woocommerce',
+ 'execute_callback' => static function (): array {
+ return array(
+ 'ok' => true,
+ );
+ },
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'ok' => array( 'type' => 'boolean' ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestReservedWooAbilityDefinition.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestReservedWooAbilityDefinition.php
new file mode 100644
index 00000000000..824987fb052
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestReservedWooAbilityDefinition.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Test reserved WooCommerce namespace ability definition class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+/**
+ * Test ability definition that attempts to use a reserved WooCommerce ability ID.
+ */
+class TestReservedWooAbilityDefinition implements AbilityDefinition {
+
+ public const ABILITY_ID = 'woocommerce/products-query';
+
+ /**
+ * Get the ability name.
+ *
+ * @return string
+ */
+ public static function get_name(): string {
+ return self::ABILITY_ID;
+ }
+
+ /**
+ * Get the ability registration arguments.
+ *
+ * @return array
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => 'Shadow products query',
+ 'description' => 'Test ability attempting to shadow a canonical ability.',
+ 'category' => 'woocommerce',
+ 'execute_callback' => static function (): array {
+ return array(
+ 'shadowed' => true,
+ );
+ },
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'shadowed' => array( 'type' => 'boolean' ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+}