Commit fbf50b2b80b for woocommerce
commit fbf50b2b80b3ec520137a6e81e26c72fb63ced13
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Mon Nov 2 16:19:04 2026 -0300
Accept registered product types in the product abilities (#70050)
* Spike: accept registered product types in the product abilities
Replace product_type_alias with the product type registry, and add
virtual and downloadable as fields of their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use one flat input schema for product-update
The update schema lists id, type and the fields of every supported
product type. A client no longer sends type to change a per-type field
such as regular_price. After load() resolves the product, the update
refuses a field that the product type does not support, with
woocommerce_product_field_unsupported. product-create keeps one oneOf
branch for each product type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Let the owner of a product type choose its Core fields
The product class still decides the default list of Core fields, and the
new woocommerce_ability_product_type_fields filter lets the owner of a
type add or remove one, such as a price for a bundle.
Also from the review of the spike:
- Leave a type without a class out of the create and update schemas
instead of listing it and refusing it at run time.
- Read the config of a loaded product from its class, not from its type.
- Sanitize the virtual and downloadable query filters with
rest_sanitize_boolean(), so "false" is false.
- Drop the two config checks in ProductUpdate::load() that change()
repeats.
- Say in the create schema that a product without a type is simple.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* List the fields of each product type in one flat input schema
product-create had one oneOf branch per product type. A wrong field
gave the message "input[type] is not simple", and every branch carried
a stub for the extensions property. product-create now has the same
flat schema as product-update. The description of type lists the fields
that each type takes, and the run-time check names the type, the fields
it refused, and the fields the type takes.
The woocommerce_ability_product_type_fields filter also carries the
writable extension fields of the product resource, as namespace.field.
The owner of an extension field removes it from the types it does not
apply to, so the schema and the error name the right fields for each
type. register_field() stays resource-neutral. The filter no longer
receives the class name, because the owner of a type knows its class.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the field list out of the type description
The description of type listed the fields of every product type. That
is a long sentence an agent has to parse, and it repeats what the
run-time error already says. The schema now lists every field once,
and the error names the fields the type takes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Let a field name the object types it applies to
An extension field that applies to some types of a resource had no
structured way to say so. The product filter carried extension fields
as namespace.field strings, which was product-specific and loose.
ExtensibleAbilities::register_field() takes object_types: the types,
as get_type() of the object returns them, that the field applies to.
Core leaves the field out of other objects and refuses a write to them
with woocommerce_ability_field_object_type, for every resource. The
woocommerce_ability_product_type_fields filter goes back to Core
fields only.
Also: document why a type whose class is WC_Product_Simple itself is
refused, and cast the boolean query filters with wc_string_to_bool so
phpstan passes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the object_types diagnostic untranslated
Developer diagnostics passed to wc_doing_it_wrong() stay in English, as
the lint rule requires.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Shorten the changelog entry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Refuse variations in product-update and restricted fields on unknown types
A product-update call with a variation ID could edit the variation with the
simple product fields, and with a type it could write a simple product type
on the variation post. product-update now refuses variations with the same
woocommerce_product_type_unsupported error as products-query and
product-delete, and the product class config refuses variation classes.
A field with object_types no longer applies to an object whose type is
unknown, and get_type() is only called when it is callable and the field is
restricted.
Addresses the CodeRabbit review on #70050.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename the changelog entry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Refuse a type change on a variable product
product-update replaced the loaded product with one of the requested
type before checking that the current type is supported, so a variable
product could be saved as simple and lose its variations. The current
type is checked first, as CodeRabbit pointed out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Apply forced product properties only when the type changes
A repeated `type` cleared the stock of a stocked grouped product. The
forced properties now apply only when `type` differs from the stored type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tell agents which fields each product type takes
The description of `type` lists the Core fields of each type, and an extension
field with `object_types` says which types it applies to. The field list was
removed in b669c71c74 because it looked long and repeated the error. A test with
6 models and 8 tasks showed that it helps: 87% of calls were right on the first
try with this guidance and 74% without it, with no wrong saves. Refusals stay
the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say on each Core field which product types take it
A field that not every type takes now says "Applies to: ..." in its description.
This is the shape that won a final test with 6 models and 10 clear tasks, set
against the `type` field list alone: 96% of calls were right on the first try
with both, and 92% with the list alone, with no wrong saves. The rule for
replacing the list alone was fixed before the run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove checks that repeat the input schema in the product abilities
The input schema already limits the product type, so the abilities no
longer check it again after validation. The product type fields filter
result and get_type() of a subject are used as returned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the object_types diagnostic message in English
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/custom-product-types b/plugins/woocommerce/changelog/custom-product-types
new file mode 100644
index 00000000000..5b357ac9676
--- /dev/null
+++ b/plugins/woocommerce/changelog/custom-product-types
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Product abilities: accept the product types that extensions register, let extension fields apply to some product types only, and say in the input schema which fields each type takes.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 0baeed3f339..97461fb49a9 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -166,7 +166,7 @@ abstract class ActionableAbility extends \WP_Ability {
* is, to delete the value.
*
* @param array<string, mixed> $values Values keyed by namespace, then field.
- * @return array|\WP_Error A list of update_callback and value pairs, or a WP_Error when a value is rejected.
+ * @return array|\WP_Error A list of field arguments and value pairs, or a WP_Error when a value is rejected.
*/
private function prepare_fields( array $values ) {
$registered = ExtensibleAbilities::get_fields( $this->get_resource() );
@@ -193,7 +193,7 @@ abstract class ActionableAbility extends \WP_Ability {
return self::with_data( $value, array( 'field' => $param ) );
}
}
- $updates[ 'extensions.' . $namespace . '.' . $field ] = array( $args['update_callback'], $value );
+ $updates[ 'extensions.' . $namespace . '.' . $field ] = array( $args, $value );
}
}
return $updates;
@@ -205,12 +205,26 @@ abstract class ActionableAbility extends \WP_Ability {
* the given errors, as add_refusal() describes.
*
* @param \WC_Data $subject Object to change.
- * @param array $updates Pairs of update_callback and value from prepare_fields().
- * @param \WP_Error $refused Collects the refusals.
+ * @param array $updates Pairs of field arguments and value from prepare_fields().
+ * @param \WP_Error $refused Collects the refusals, including a field that does not apply to the object.
*/
private function apply_fields( $subject, array $updates, \WP_Error $refused ): void {
- foreach ( $updates as $param => list( $callback, $value ) ) {
- $rejected = self::run_callback( $callback, array( $subject, $value ), array( 'field' => $param ) );
+ foreach ( $updates as $param => list( $args, $value ) ) {
+ if ( ! ExtensibleAbilities::field_applies_to( $args, $subject ) ) {
+ $rejected = new \WP_Error(
+ 'woocommerce_ability_field_object_type',
+ sprintf(
+ /* translators: 1: extension field, 2: object type, 3: comma-separated list of object types. */
+ __( 'The %1$s field does not apply to the %2$s type. It applies to %3$s.', 'woocommerce' ),
+ $param,
+ ExtensibleAbilities::get_object_type( $subject ),
+ implode( ', ', $args['object_types'] )
+ ),
+ array( 'status' => 400 )
+ );
+ } else {
+ $rejected = self::run_callback( $args['update_callback'], array( $subject, $value ), array( 'field' => $param ) );
+ }
if ( null !== $rejected ) {
self::add_refusal( $rejected, explode( '.', $param )[1], $refused );
}
@@ -290,8 +304,8 @@ abstract class ActionableAbility extends \WP_Ability {
/**
* Add the `extensions` property one time, at the top level of the input
- * schema. It lists the fields that have an update_callback, and allows null
- * for each one. Each `oneOf`, `anyOf` and `allOf` branch only accepts the
+ * schema. It lists the fields that have an update_callback, allows null for
+ * each one, and says which object types a field with `object_types` applies to. Each `oneOf`, `anyOf` and `allOf` branch only accepts the
* key, so a branch with `additionalProperties` set to false does not reject
* it, and the schema does not grow with the number of branches.
*
@@ -325,6 +339,11 @@ abstract class ActionableAbility extends \WP_Ability {
if ( isset( $field_schema['enum'] ) && ! in_array( null, $field_schema['enum'], true ) ) {
$field_schema['enum'][] = null;
}
+ if ( ! empty( $args['object_types'] ) ) {
+ /* translators: %s: Comma-separated list of object types. */
+ $applies_to = sprintf( __( 'Applies to: %s.', 'woocommerce' ), implode( ', ', $args['object_types'] ) );
+ $field_schema['description'] = trim( ( $field_schema['description'] ?? '' ) . ' ' . $applies_to );
+ }
return $field_schema;
},
$writable
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 9584b815dcb..ae7b72189a7 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -127,6 +127,14 @@ class ExtensibleAbilities {
* @type array $schema JSON schema of the value. It needs a `type`. Its `title` is the label clients show.
* It cannot set `required`, because a value that fails its schema is
* left out. For a field that is always present, allow null and return null.
+ * For a field that applies only to some objects, set `object_types`, or do not
+ * allow null and return null for the other objects. The field is then left
+ * out of them, with no error.
+ * @type string[] $object_types Optional. The object types the field applies to, as `get_type()` of the
+ * object returns them, such as `simple` and `subscription` for a product or
+ * `line_item` for an order item. The object must have a `get_type()` method.
+ * The abilities leave the field out of the other objects and refuse a write
+ * to them. Omit it for every type.
* @type callable $get_callback Receives the object and returns the value. A value that the schema
* does not allow is left out, so return null when the object has no
* value. A schema that allows null, such as `array( 'integer', 'null' )`,
@@ -190,6 +198,10 @@ class ExtensibleAbilities {
wc_doing_it_wrong( __METHOD__, 'The "update_callback" argument must be callable.', '11.3.0' );
return;
}
+ if ( isset( $args['object_types'] ) && ( ! is_array( $args['object_types'] ) || empty( $args['object_types'] ) || array_filter( $args['object_types'], static fn( $type ) => ! is_string( $type ) ) ) ) {
+ wc_doing_it_wrong( __METHOD__, 'The "object_types" argument must be a list of object types.', '11.4.0' );
+ return;
+ }
if ( isset( self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] ) ) {
wc_doing_it_wrong(
__METHOD__,
@@ -550,6 +562,9 @@ class ExtensibleAbilities {
$extensions = array();
foreach ( self::$fields[ $resource_name ] as $namespace => $fields ) {
foreach ( $fields as $field => $args ) {
+ if ( ! self::field_applies_to( $args, $subject ) ) {
+ continue;
+ }
try {
$value = call_user_func( $args['get_callback'], $subject );
} catch ( \Throwable $e ) {
@@ -574,6 +589,31 @@ class ExtensibleAbilities {
return $output;
}
+ /**
+ * Whether a field applies to an object, from the `object_types` of the field.
+ *
+ * @since 11.4.0
+ *
+ * @param array<string, mixed> $args Field arguments from `register_field()`.
+ * @param mixed $subject The object. It must have a `get_type()` method.
+ * @return bool
+ */
+ public static function field_applies_to( array $args, $subject ): bool {
+ return empty( $args['object_types'] ) || in_array( $subject->get_type(), $args['object_types'], true );
+ }
+
+ /**
+ * The type of an object, from its `get_type()` method.
+ *
+ * @since 11.4.0
+ *
+ * @param mixed $subject The object. It must have a `get_type()` method.
+ * @return string
+ */
+ public static function get_object_type( $subject ): string {
+ return $subject->get_type();
+ }
+
/**
* Add the `extensions` property to an object schema when the feature is on.
* It lists each namespace with its fields. Call it after the
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 103b047c00f..43991e12675 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\Domain;
use Automattic\WooCommerce\Abilities\ActionableAbility;
+use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
@@ -75,23 +76,7 @@ class ProductCreate extends ActionableAbility {
* @return \WC_Product|\WP_Error
*/
protected function load( 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 )
- );
- }
-
- return $product;
+ return self::get_product_object_for_type( $input['type'] ?? ProductType::SIMPLE );
}
/**
@@ -102,11 +87,7 @@ class ProductCreate extends ActionableAbility {
* @return null|\WP_Error
*/
protected function change( $subject, 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_config = self::get_supported_product_type_configs()[ $subject->get_type() ];
try {
self::apply_product_type_config( $subject, $product_config );
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index 0cbbbb01628..aaffcdadbdc 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -82,6 +82,10 @@ class ProductUpdate extends ActionableAbility {
return $product;
}
+ if ( $product instanceof \WC_Product_Variation ) {
+ return self::get_unsupported_product_type_error();
+ }
+
if ( empty( array_diff( array_keys( $input ), array( 'id' ) ) ) ) {
return new \WP_Error(
'woocommerce_product_update_no_fields',
@@ -90,12 +94,6 @@ class ProductUpdate extends ActionableAbility {
);
}
- $product_config = self::get_product_config_for_product( $product );
-
- if ( is_wp_error( $product_config ) ) {
- return $product_config;
- }
-
if (
isset( $input['status'] )
&& in_array(
@@ -113,22 +111,12 @@ class ProductUpdate extends ActionableAbility {
);
}
- 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 )
- );
+ if ( isset( $input['type'] ) ) {
+ $current_config = self::get_product_config_for_product( $product );
+ if ( is_wp_error( $current_config ) ) {
+ return $current_config;
}
+ return self::get_product_object_for_type( $input['type'], $product->get_id() );
}
return $product;
@@ -142,16 +130,14 @@ class ProductUpdate extends ActionableAbility {
* @return null|\WP_Error
*/
protected function change( $subject, array $input ) {
- $product_config = isset( $input['product_type_alias'] )
- ? self::get_product_config_for_alias( $input['product_type_alias'] )
- : self::get_product_config_for_product( $subject );
+ $product_config = self::get_product_config_for_product( $subject );
if ( is_wp_error( $product_config ) ) {
return $product_config;
}
try {
- if ( isset( $input['product_type_alias'] ) ) {
+ if ( isset( $input['type'] ) && \WC_Product_Factory::get_product_type( $subject->get_id() ) !== $input['type'] ) {
self::apply_product_type_config( $subject, $product_config );
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index a9f471534ef..67e6fffa67d 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -122,15 +122,14 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
}
}
- 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 ( isset( $input['type'] ) ) {
+ $args['type'] = $input['type'];
+ }
- if ( is_wp_error( $type_args ) ) {
- return $type_args;
+ foreach ( array( 'virtual', 'downloadable' ) as $field ) {
+ if ( isset( $input[ $field ] ) ) {
+ $args[ $field ] = wc_string_to_bool( $input[ $field ] );
}
-
- $args = array_merge( $args, $type_args );
}
if ( ! empty( $input['search'] ) ) {
@@ -184,37 +183,38 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
$schema = array(
'type' => 'object',
'properties' => array(
- 'id' => array(
+ 'id' => array(
'type' => 'integer',
'minimum' => 1,
),
- 'search' => array( 'type' => 'string' ),
- 'sku' => array(
+ '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(
+ '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(),
+ 'type' => self::get_product_type_schema( array_keys( wc_get_product_types() ) ),
+ 'virtual' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Limit results to products that are not shipped (true) or that are shipped (false).', 'woocommerce' ),
+ ),
+ 'downloadable' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Limit results to products that have files to download (true) or that do not (false).', 'woocommerce' ),
),
- 'stock_status' => array(
+ 'stock_status' => array(
'type' => 'string',
'enum' => array_keys( wc_get_product_stock_status_options() ),
),
- 'page' => array(
+ 'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
- 'per_page' => array(
+ 'per_page' => array(
'type' => 'integer',
'default' => 10,
'minimum' => 1,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index b55dbb2128b..0e794db8bb3 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -25,112 +25,101 @@ trait ProductAbilityTrait {
* @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 ),
- );
+ return self::get_product_mutation_input_schema( true );
}
/**
* 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(
+ return self::get_product_mutation_input_schema( false );
+ }
+
+ /**
+ * Get the input schema of product-create or product-update.
+ *
+ * The schema is one flat object with the fields of every supported product
+ * type. The description of `type` lists the fields that each type takes,
+ * and the ability refuses a field that the type does not take with
+ * `woocommerce_product_field_unsupported`, naming the fields it takes.
+ *
+ * @param bool $creating Whether the schema is for product-create.
+ * @return array
+ */
+ private static function get_product_mutation_input_schema( bool $creating ): array {
+ $configs = self::get_supported_product_type_configs();
+ $fields = array_merge( ...array_values( array_column( $configs, 'fields' ) ) );
+ $type_schema = self::get_product_type_schema( array_keys( $configs ) );
+ $lines = array();
+
+ foreach ( $configs as $product_type => $config ) {
+ $lines[] = $product_type . ': ' . implode( ', ', $config['fields'] );
+ }
+
+ $type_schema['description'] .= ' ' . __( 'Fields each type takes.', 'woocommerce' ) . ' ' . implode( '; ', $lines ) . '.';
+
+ if ( $creating ) {
+ $type_schema['default'] = ProductType::SIMPLE;
+ $type_schema['description'] .= ' ' . __( 'A product without a type is simple.', 'woocommerce' );
+ }
+
+ $properties = $creating ? array() : 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,
+ 'type' => 'object',
+ 'properties' => array_merge(
+ $properties,
+ array( 'type' => $type_schema ),
+ self::add_product_type_notes( self::get_product_mutation_field_schemas_for_fields( $fields ), $configs )
+ ),
+ 'required' => array( $creating ? 'name' : 'id' ),
+ 'additionalProperties' => false,
);
}
/**
- * Get alias-specific product input schema branches.
+ * Say in the description of a field which product types take it, when not
+ * every type does.
*
- * @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>
+ * @param array<string, array> $field_schemas Field schemas keyed by field.
+ * @param array<string, array> $configs Product type configurations keyed by type.
+ * @return array<string, 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,
+ private static function add_product_type_notes( array $field_schemas, array $configs ): array {
+ foreach ( $field_schemas as $field => $field_schema ) {
+ $product_types = array_keys(
+ array_filter(
+ $configs,
+ static fn( array $config ): bool => in_array( $field, $config['fields'], true )
+ )
);
+ if ( count( $product_types ) < count( $configs ) ) {
+ /* translators: %s: Comma-separated list of product types. */
+ $applies_to = sprintf( __( 'Applies to: %s.', 'woocommerce' ), implode( ', ', $product_types ) );
+ $field_schemas[ $field ]['description'] = trim( ( $field_schema['description'] ?? '' ) . ' ' . $applies_to );
+ }
}
-
- return $branches;
+ return $field_schemas;
}
/**
- * Get the product type alias schema.
+ * Get the product type schema.
*
- * @param string|null $product_type_alias Product type alias to restrict to, or null for all aliases.
+ * @param array<int, string> $product_types Product types to allow.
* @return array
*/
- private static function get_product_type_alias_schema( ?string $product_type_alias = null ): array {
+ protected static function get_product_type_schema( array $product_types ): 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 ),
+ 'description' => __( 'Product type, from the product types that WooCommerce and extensions register.', 'woocommerce' ),
+ 'enum' => $product_types,
);
}
@@ -165,6 +154,14 @@ trait ProductAbilityTrait {
'type' => 'string',
'enum' => self::get_product_mutation_status_slugs(),
),
+ 'virtual' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether the product is not shipped.', 'woocommerce' ),
+ ),
+ 'downloadable' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether the product gives the customer files to download.', 'woocommerce' ),
+ ),
'manage_stock' => array( 'type' => 'boolean' ),
'stock_quantity' => array(
'type' => self::get_product_stock_quantity_schema_type(),
@@ -176,12 +173,12 @@ trait ProductAbilityTrait {
),
'external_url' => array(
'type' => 'string',
- 'description' => __( 'External destination URL for affiliate products.', 'woocommerce' ),
+ 'description' => __( 'External destination URL for external products.', 'woocommerce' ),
'format' => 'uri',
),
'button_text' => array(
'type' => 'string',
- 'description' => __( 'Button text for affiliate products.', 'woocommerce' ),
+ 'description' => __( 'Button text for external products.', 'woocommerce' ),
),
'grouped_products' => array(
'type' => 'array',
@@ -214,195 +211,159 @@ trait ProductAbilityTrait {
}
/**
- * Supported agent-facing product type aliases.
+ * Get the configuration of every supported product type, keyed by type.
*
- * @return array<int, string>
+ * @return array<string, array{fields: array<int, string>, product_props: array<string, mixed>}>
*/
- protected static function get_supported_product_type_aliases(): array {
- return array_keys( self::get_product_type_alias_configs() );
- }
+ private static function get_supported_product_type_configs(): array {
+ $configs = array();
- /**
- * 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();
+ foreach ( array_keys( wc_get_product_types() ) as $product_type ) {
+ $config = self::get_product_class_config( \WC_Product_Factory::get_product_classname( 0, $product_type ), $product_type );
- 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 )
- );
+ if ( ! is_wp_error( $config ) ) {
+ $configs[ $product_type ] = $config;
+ }
}
- return $configs[ $product_type_alias ];
+ return $configs;
}
/**
- * Get product query arguments for an agent-facing product type alias.
+ * Get the configuration of a product class.
*
- * @param string $product_type_alias Agent-facing product type alias.
- * @return array|\WP_Error
+ * The product class decides which Core fields apply. A class that extends
+ * a Core class gets the fields of that class. The owner of a type changes
+ * the list with the `woocommerce_ability_product_type_fields` filter.
+ *
+ * `WC_Product_Factory` returns `WC_Product_Simple` itself for a type that
+ * has no class. Such a type is refused. A class that extends
+ * `WC_Product_Simple`, such as a subscription, is a class of its own.
+ *
+ * @param string $classname Product class.
+ * @param string $product_type Product type.
+ * @return array{fields: array<int, string>, product_props: array<string, mixed>}|\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 );
+ private static function get_product_class_config( string $classname, string $product_type ) {
+ if (
+ is_a( $classname, \WC_Product_Variable::class, true )
+ || is_a( $classname, \WC_Product_Variation::class, true )
+ ) {
+ return self::get_unsupported_product_type_error();
+ }
- if ( is_wp_error( $product_config ) ) {
- return $product_config;
+ if ( \WC_Product_Simple::class === $classname && ProductType::SIMPLE !== $product_type ) {
+ return self::get_invalid_product_type_error();
}
- $query_args = array(
- 'type' => $product_config['wc_type'],
- );
+ $fields = self::get_common_product_mutation_fields();
+ $product_props = array();
+ $price_fields = array( 'regular_price', 'sale_price' );
+
+ if ( is_a( $classname, \WC_Product_External::class, true ) ) {
+ $fields = array_merge( $fields, $price_fields, array( 'external_url', 'button_text' ) );
+ $product_props = array(
+ 'virtual' => false,
+ 'downloadable' => false,
+ 'manage_stock' => false,
+ 'stock_quantity' => '',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ );
+ } elseif ( is_a( $classname, \WC_Product_Grouped::class, true ) ) {
+ $fields = array_merge( $fields, array( 'grouped_products' ) );
+ $product_props = array(
+ 'manage_stock' => false,
+ 'stock_quantity' => '',
+ );
+ } else {
+ $fields = array_merge( $fields, array( 'virtual', 'downloadable' ) );
- if ( isset( $product_config['query_props'] ) ) {
- foreach ( $product_config['query_props'] as $field => $value ) {
- $query_args[ $field ] = (bool) $value;
+ if ( is_a( $classname, \WC_Product_Simple::class, true ) ) {
+ $fields = array_merge( $fields, $price_fields, array( 'manage_stock', 'stock_quantity', 'stock_status' ) );
}
}
- return $query_args;
+ /**
+ * Filter the Core fields that the product abilities accept for a product type.
+ *
+ * WooCommerce picks the fields from the product class. The owner of a
+ * type adds a Core field that its type needs, such as a price for a
+ * bundle, or removes one. An extension field names its types in
+ * `object_types` of `ExtensibleAbilities::register_field()`.
+ *
+ * @since 11.4.0
+ *
+ * @param array<int, string> $fields Field names.
+ * @param string $product_type Product type.
+ */
+ $fields = apply_filters( 'woocommerce_ability_product_type_fields', $fields, $product_type );
+
+ return array(
+ 'fields' => $fields,
+ 'product_props' => $product_props,
+ );
}
/**
- * Get product configuration for an existing product.
+ * The error for a product type that the ability does not support.
*
- * @param \WC_Product $product Product object.
- * @return array|\WP_Error
+ * @return \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 );
+ private static function get_unsupported_product_type_error(): \WP_Error {
+ return new \WP_Error(
+ 'woocommerce_product_type_unsupported',
+ __( 'Product type is not supported by this ability.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
}
/**
- * Get the supported agent-facing product type alias for a product.
+ * The error for a product type without a class.
*
- * @param \WC_Product $product Product object.
- * @return string|\WP_Error
+ * @return \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';
- }
-
+ private static function get_invalid_product_type_error(): \WP_Error {
return new \WP_Error(
- 'woocommerce_product_type_unsupported',
- __( 'Product type is not supported by this ability.', 'woocommerce' ),
+ 'woocommerce_invalid_product_type',
+ __( 'Invalid product type.', 'woocommerce' ),
array( 'status' => 400 )
);
}
/**
- * Get fields shared by all supported product mutation aliases.
+ * Get the product object of a product type.
*
- * @return array<int, string>
+ * @param string $product_type Product type.
+ * @param int $product_id Product ID, or 0 for a new product.
+ * @return \WC_Product|\WP_Error
*/
- private static function get_common_product_mutation_fields(): array {
- return array( 'name', 'sku', 'description', 'short_description', 'status' );
+ protected static function get_product_object_for_type( string $product_type, int $product_id = 0 ) {
+ $product = wc_get_product_object( $product_type, $product_id );
+
+ if ( $product->get_type() !== $product_type ) {
+ return self::get_invalid_product_type_error();
+ }
+
+ return $product;
}
/**
- * Get product type alias configuration.
+ * Get the configuration for a product object, from its class.
*
- * 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>}>
+ * @param \WC_Product $product Product object.
+ * @return array|\WP_Error
*/
- 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' )
- );
+ protected static function get_product_config_for_product( \WC_Product $product ) {
+ return self::get_product_class_config( get_class( $product ), $product->get_type() );
+ }
- 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' => '',
- ),
- ),
- );
+ /**
+ * Get fields shared by all supported product types.
+ *
+ * @return array<int, string>
+ */
+ private static function get_common_product_mutation_fields(): array {
+ return array( 'name', 'sku', 'description', 'short_description', 'status' );
}
/**
@@ -494,7 +455,7 @@ trait ProductAbilityTrait {
* @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 );
+ $validation_error = self::validate_product_fields_for_config( $product, $input, $product_config );
if ( is_wp_error( $validation_error ) ) {
return $validation_error;
}
@@ -507,6 +468,8 @@ trait ProductAbilityTrait {
'description' => 'set_description',
'short_description' => 'set_short_description',
'status' => 'set_status',
+ 'virtual' => 'set_virtual',
+ 'downloadable' => 'set_downloadable',
'manage_stock' => 'set_manage_stock',
'stock_quantity' => 'set_stock_quantity',
'stock_status' => 'set_stock_status',
@@ -560,27 +523,29 @@ trait ProductAbilityTrait {
}
/**
- * Validate that input fields are supported for the configured product type.
+ * Refuse a Core field that the product type does not take.
*
- * @param array $input Ability input.
- * @param array $product_config Product type configuration.
+ * @param \WC_Product $product Product object.
+ * @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', 'extensions' );
- $supported_fields = array_merge( $shared_fields, $product_config['fields'] );
- $unsupported_field_keys = array_diff( array_keys( $input ), $supported_fields );
+ private static function validate_product_fields_for_config( \WC_Product $product, array $input, array $product_config ) {
+ $supported = $product_config['fields'];
+ $unsupported = array_diff( array_keys( $input ), array( 'id', 'type', 'extensions' ), $supported );
- if ( empty( $unsupported_field_keys ) ) {
+ if ( empty( $unsupported ) ) {
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 )
+ /* translators: 1: product type, 2: comma-separated list of the fields sent, 3: comma-separated list of the fields the type takes. */
+ __( 'The %1$s product type does not take %2$s. It takes %3$s.', 'woocommerce' ),
+ $product->get_type(),
+ implode( ', ', $unsupported ),
+ implode( ', ', $supported )
),
array( 'status' => 400 )
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 47969bcb7b7..a6e6bab0ffa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -326,22 +326,20 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should expose agent-friendly product type alias inputs for product operations.
+ * @testdox Should expose registered product types as inputs for product operations.
*/
- public function test_product_schema_uses_agent_friendly_product_types(): void {
+ public function test_product_schema_uses_registered_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' );
+ $expected_types = array( 'simple', 'grouped', 'external' );
- $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->assertSame( array( 'simple', 'grouped', 'external', 'variable' ), $query_schema['properties']['type']['enum'] ?? array() );
+ $this->assertSame( $expected_types, $create_schema['properties']['type']['enum'] ?? array() );
+ $this->assertSame( $expected_types, $update_schema['properties']['type']['enum'] ?? array() );
+ $this->assertSame( 'simple', $create_schema['properties']['type']['default'] ?? null );
+ $this->assertArrayNotHasKey( 'default', $update_schema['properties']['type'] ?? array() );
$this->assertArrayNotHasKey(
'product_type_alias',
wp_get_ability( 'woocommerce/product-create' )->get_output_schema()['properties']['product']['properties'] ?? array()
@@ -351,33 +349,32 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should expose only alias-relevant product fields in mutation schema branches.
+ * @testdox Should list every field in the flat create and update schemas, and the fields of each type in the type description.
*/
- public function test_product_mutation_schema_branches_only_include_alias_relevant_fields(): void {
+ public function test_product_mutation_schemas_list_every_field(): 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 );
+ foreach ( array( $create_schema, $update_schema ) as $schema ) {
+ $this->assertArrayNotHasKey( 'oneOf', $schema );
+ $this->assertFalse( $schema['additionalProperties'] );
+ foreach ( array( 'type', 'virtual', 'downloadable', 'regular_price', 'external_url', 'grouped_products', 'manage_stock' ) as $field ) {
+ $this->assertArrayHasKey( $field, $schema['properties'] );
+ }
+ $description = $schema['properties']['type']['description'];
+ $this->assertStringContainsString( 'Fields each type takes.', $description );
+ $this->assertStringContainsString( 'external: name, sku, description, short_description, status, regular_price, sale_price, external_url, button_text', $description );
+ $this->assertStringContainsString( 'grouped: name, sku, description, short_description, status, grouped_products', $description );
+ $this->assertStringEndsWith( 'Applies to: grouped.', $schema['properties']['grouped_products']['description'] );
+ $this->assertStringContainsString( 'Applies to: ', $schema['properties']['regular_price']['description'] );
+ $this->assertStringNotContainsString( 'Applies to: ', $schema['properties']['sku']['description'] ?? '' );
+ }
- $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 );
+ $this->assertSame( array( 'name' ), $create_schema['required'] );
+ $this->assertArrayNotHasKey( 'id', $create_schema['properties'] );
+ $this->assertStringContainsString( 'A product without a type is simple.', $create_schema['properties']['type']['description'] );
+ $this->assertSame( array( 'id' ), $update_schema['required'] );
+ $this->assertStringNotContainsString( 'without a type', $update_schema['properties']['type']['description'] );
}
/**
@@ -1451,12 +1448,14 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
/**
* @testdox Should map digital products to simple virtual downloadable products.
*/
- public function test_product_create_maps_digital_product_type_to_simple_downloadable_product(): void {
+ public function test_product_create_sets_virtual_and_downloadable_on_a_simple_product(): void {
$created = wp_get_ability( 'woocommerce/product-create' )->execute(
array(
- 'name' => 'Digital Product',
- 'product_type_alias' => 'digital',
- 'regular_price' => '19.99',
+ 'name' => 'Digital Product',
+ 'type' => 'simple',
+ 'virtual' => true,
+ 'downloadable' => true,
+ 'regular_price' => '19.99',
)
);
@@ -1470,12 +1469,13 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
/**
* @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 {
+ public function test_product_create_sets_virtual_on_a_simple_product(): void {
$created = wp_get_ability( 'woocommerce/product-create' )->execute(
array(
- 'name' => 'Virtual Product',
- 'product_type_alias' => 'virtual',
- 'regular_price' => '29.99',
+ 'name' => 'Virtual Product',
+ 'type' => 'simple',
+ 'virtual' => true,
+ 'regular_price' => '29.99',
)
);
@@ -1487,9 +1487,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should query simple products by agent-friendly physical, virtual, and digital product type aliases.
+ * @testdox Should query simple products by type, virtual and downloadable.
*/
- public function test_products_query_maps_agent_friendly_product_types_to_product_fields(): void {
+ public function test_products_query_filters_by_type_virtual_and_downloadable(): void {
$physical_sku = wp_unique_id( 'abilities-physical-' );
$virtual_sku = wp_unique_id( 'abilities-virtual-' );
$digital_sku = wp_unique_id( 'abilities-digital-' );
@@ -1497,9 +1497,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$physical = wp_get_ability( 'woocommerce/product-create' )->execute(
array(
- 'name' => 'Physical Query Product',
- 'product_type_alias' => 'physical',
- 'sku' => $physical_sku,
+ 'name' => 'Physical Query Product',
+ 'type' => 'simple',
+ 'sku' => $physical_sku,
)
);
@@ -1508,9 +1508,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$virtual = wp_get_ability( 'woocommerce/product-create' )->execute(
array(
- 'name' => 'Virtual Query Product',
- 'product_type_alias' => 'virtual',
- 'sku' => $virtual_sku,
+ 'name' => 'Virtual Query Product',
+ 'type' => 'simple',
+ 'virtual' => true,
+ 'sku' => $virtual_sku,
)
);
@@ -1519,9 +1520,11 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$digital = wp_get_ability( 'woocommerce/product-create' )->execute(
array(
- 'name' => 'Digital Query Product',
- 'product_type_alias' => 'digital',
- 'sku' => $digital_sku,
+ 'name' => 'Digital Query Product',
+ 'type' => 'simple',
+ 'virtual' => true,
+ 'downloadable' => true,
+ 'sku' => $digital_sku,
)
);
@@ -1537,8 +1540,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$physical_result = wp_get_ability( 'woocommerce/products-query' )->execute(
array(
- 'product_type_alias' => 'physical',
- 'sku' => $physical_sku,
+ 'type' => 'simple',
+ 'virtual' => false,
+ 'downloadable' => false,
+ 'sku' => $physical_sku,
)
);
@@ -1546,10 +1551,23 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertSame( 1, $physical_result['total_pages'] );
$this->assertSame( $physical['product']['id'], $physical_result['products'][0]['id'] );
+ $string_booleans_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+ array(
+ 'virtual' => 'false',
+ 'downloadable' => 'false',
+ 'sku' => $physical_sku,
+ )
+ );
+
+ $this->assertNotWPError( $string_booleans_result );
+ $this->assertSame( array( $physical['product']['id'] ), array_column( $string_booleans_result['products'], 'id' ) );
+
$virtual_result = wp_get_ability( 'woocommerce/products-query' )->execute(
array(
- 'product_type_alias' => 'virtual',
- 'sku' => $virtual_sku,
+ 'type' => 'simple',
+ 'virtual' => true,
+ 'downloadable' => false,
+ 'sku' => $virtual_sku,
)
);
@@ -1559,8 +1577,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$digital_result = wp_get_ability( 'woocommerce/products-query' )->execute(
array(
- 'product_type_alias' => 'digital',
- 'sku' => $digital_sku,
+ 'type' => 'simple',
+ 'virtual' => true,
+ 'downloadable' => true,
+ 'sku' => $digital_sku,
)
);
@@ -1570,8 +1590,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$mismatched_result = wp_get_ability( 'woocommerce/products-query' )->execute(
array(
- 'product_type_alias' => 'physical',
- 'sku' => $digital_sku,
+ 'type' => 'simple',
+ 'virtual' => false,
+ 'downloadable' => false,
+ 'sku' => $digital_sku,
)
);
@@ -1589,8 +1611,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$physical_hybrid_result = wp_get_ability( 'woocommerce/products-query' )->execute(
array(
- 'product_type_alias' => 'physical',
- 'sku' => $hybrid_sku,
+ 'type' => 'simple',
+ 'virtual' => false,
+ 'downloadable' => false,
+ 'sku' => $hybrid_sku,
)
);
@@ -1599,16 +1623,16 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should map affiliate products to external products.
+ * @testdox Should create external products.
*/
- public function test_product_create_maps_affiliate_product_type_to_external_product(): void {
+ public function test_product_create_creates_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',
+ 'name' => 'Affiliate Product',
+ 'type' => 'external',
+ 'regular_price' => '49.99',
+ 'external_url' => 'https://example.com/buy',
+ 'button_text' => 'Buy elsewhere',
)
);
@@ -1620,17 +1644,17 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should map grouped products to grouped WooCommerce products with children.
+ * @testdox Should create grouped products with children.
*/
- public function test_product_create_maps_grouped_product_type_to_grouped_product(): void {
+ public function test_product_create_creates_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() ),
+ 'name' => 'Grouped Product',
+ 'type' => 'grouped',
+ 'grouped_products' => array( $child->get_id() ),
)
);
@@ -1640,6 +1664,194 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertSame( array( $child->get_id() ), $created['product']['grouped_products'] );
}
+ /**
+ * @testdox Should create products of a type that an extension registers, with the Core fields of its class.
+ */
+ public function test_product_create_accepts_a_registered_product_type(): void {
+ $this->unregister_domain_abilities();
+ add_filter( 'product_type_selector', array( $this, 'add_test_product_types' ) );
+ add_filter( 'woocommerce_product_class', array( $this, 'get_test_product_class' ), 10, 2 );
+
+ try {
+ $this->register_domain_abilities();
+
+ $create_schema = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+ $update_schema = wp_get_ability( 'woocommerce/product-update' )->get_input_schema();
+ $bundle = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Bundle',
+ 'type' => 'test-bundle',
+ 'virtual' => true,
+ 'downloadable' => true,
+ )
+ );
+ $subscription = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Subscription',
+ 'type' => 'test-subscription',
+ 'regular_price' => '9.00',
+ )
+ );
+ $bundle_price = wp_get_ability( 'woocommerce/product-create' )->execute_change(
+ array(
+ 'name' => 'Bundle with price',
+ 'type' => 'test-bundle',
+ 'regular_price' => '9.00',
+ )
+ );
+ } finally {
+ remove_filter( 'product_type_selector', array( $this, 'add_test_product_types' ) );
+ remove_filter( 'woocommerce_product_class', array( $this, 'get_test_product_class' ), 10 );
+ $this->unregister_domain_abilities();
+ $this->register_domain_abilities();
+ }
+
+ $this->assertNotWPError( $bundle );
+ $this->created_product_ids[] = $bundle['product']['id'];
+ $this->assertNotWPError( $subscription );
+ $this->created_product_ids[] = $subscription['product']['id'];
+
+ $this->assertContains( 'test-bundle', $create_schema['properties']['type']['enum'] );
+ $this->assertNotContains( 'test-missing', $create_schema['properties']['type']['enum'] );
+ $this->assertContains( 'test-bundle', $update_schema['properties']['type']['enum'] );
+ $this->assertContains( 'test-subscription', $update_schema['properties']['type']['enum'] );
+ $this->assertNotContains( 'test-missing', $update_schema['properties']['type']['enum'] );
+ $this->assertSame( 'test-bundle', $bundle['product']['type'] );
+ $this->assertTrue( $bundle['product']['virtual'] );
+ $this->assertTrue( $bundle['product']['downloadable'] );
+ $this->assertSame( 'test-subscription', $subscription['product']['type'] );
+ $this->assertSame( '9.00', $subscription['product']['regular_price'] );
+ $this->assertWPError( $bundle_price );
+ $this->assertSame( 'woocommerce_product_field_unsupported', $bundle_price->get_error_code() );
+ $this->assertSame( 'The test-bundle product type does not take regular_price. It takes name, sku, description, short_description, status, virtual, downloadable.', $bundle_price->get_error_message() );
+ }
+
+ /**
+ * @testdox Should change a product to a type that an extension registers, and query by the type.
+ */
+ public function test_product_update_changes_the_type_to_a_registered_product_type(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $this->unregister_domain_abilities();
+ add_filter( 'product_type_selector', array( $this, 'add_test_product_types' ) );
+ add_filter( 'woocommerce_product_class', array( $this, 'get_test_product_class' ), 10, 2 );
+
+ try {
+ $this->register_domain_abilities();
+
+ $subscription = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'type' => 'test-subscription',
+ 'regular_price' => '12.00',
+ )
+ );
+ $bundle = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Bundle',
+ 'type' => 'test-bundle',
+ )
+ );
+ $bundles = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'type' => 'test-bundle' ) );
+ } finally {
+ remove_filter( 'product_type_selector', array( $this, 'add_test_product_types' ) );
+ remove_filter( 'woocommerce_product_class', array( $this, 'get_test_product_class' ), 10 );
+ $this->unregister_domain_abilities();
+ $this->register_domain_abilities();
+ }
+
+ $this->assertNotWPError( $bundle );
+ $this->created_product_ids[] = $bundle['product']['id'];
+
+ $this->assertNotWPError( $subscription );
+ $this->assertSame( 'test-subscription', $subscription['product']['type'] );
+ $this->assertSame( '12.00', $subscription['product']['regular_price'] );
+ $this->assertNotWPError( $bundles );
+ $this->assertSame( array( $bundle['product']['id'] ), array_column( $bundles['products'], 'id' ) );
+ }
+
+ /**
+ * @testdox Should let the owner of a product type add a Core field to it with the woocommerce_ability_product_type_fields filter.
+ */
+ public function test_product_type_fields_filter_adds_a_core_field_to_a_registered_product_type(): void {
+ $this->unregister_domain_abilities();
+ add_filter( 'product_type_selector', array( $this, 'add_test_product_types' ) );
+ add_filter( 'woocommerce_product_class', array( $this, 'get_test_product_class' ), 10, 2 );
+ add_filter( 'woocommerce_ability_product_type_fields', array( $this, 'add_price_to_test_bundle' ), 10, 2 );
+
+ try {
+ $this->register_domain_abilities();
+
+ $bundle = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Bundle with price',
+ 'type' => 'test-bundle',
+ 'regular_price' => '9.00',
+ )
+ );
+ } finally {
+ remove_filter( 'product_type_selector', array( $this, 'add_test_product_types' ) );
+ remove_filter( 'woocommerce_product_class', array( $this, 'get_test_product_class' ), 10 );
+ remove_filter( 'woocommerce_ability_product_type_fields', array( $this, 'add_price_to_test_bundle' ), 10 );
+ $this->unregister_domain_abilities();
+ $this->register_domain_abilities();
+ }
+
+ $this->assertNotWPError( $bundle );
+ $this->created_product_ids[] = $bundle['product']['id'];
+
+ $this->assertSame( '9.00', $bundle['product']['regular_price'] );
+ }
+
+ /**
+ * Give the test bundle type a regular price.
+ *
+ * @internal
+ *
+ * @param array $fields Field names.
+ * @param string $product_type Product type.
+ * @return array
+ */
+ public function add_price_to_test_bundle( array $fields, string $product_type ): array {
+ if ( 'test-bundle' === $product_type ) {
+ $fields[] = 'regular_price';
+ }
+ return $fields;
+ }
+
+ /**
+ * Add test product types to the product type registry.
+ *
+ * @internal
+ *
+ * @param array $types Product types.
+ * @return array
+ */
+ public function add_test_product_types( array $types ): array {
+ $types['test-bundle'] = 'Test bundle';
+ $types['test-subscription'] = 'Test subscription';
+ $types['test-missing'] = 'Test type without a class';
+ return $types;
+ }
+
+ /**
+ * Map the test product types to their classes.
+ *
+ * @internal
+ *
+ * @param string $classname Product class name.
+ * @param string $product_type Product type.
+ * @return string
+ */
+ public function get_test_product_class( $classname, $product_type ) {
+ $classes = array(
+ 'test-bundle' => TestBundleProduct::class,
+ 'test-subscription' => TestSubscriptionProduct::class,
+ );
+ return $classes[ $product_type ] ?? $classname;
+ }
+
/**
* @testdox Should support the WooCommerce SKU wildcard for products with any non-empty SKU.
*/
@@ -1683,9 +1895,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
public function test_product_create_rejects_invalid_grouped_product_ids(): void {
$result = wp_get_ability( 'woocommerce/product-create' )->execute_change(
array(
- 'name' => 'Invalid Grouped Product',
- 'product_type_alias' => 'grouped',
- 'grouped_products' => array( -12 ),
+ 'name' => 'Invalid Grouped Product',
+ 'type' => 'grouped',
+ 'grouped_products' => array( -12 ),
)
);
@@ -1694,19 +1906,20 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should reject fields that are unsupported by the selected product type alias.
+ * @testdox Should reject fields that are unsupported by the selected product type.
*/
- public function test_product_create_rejects_fields_not_supported_by_product_type_alias(): void {
+ public function test_product_create_rejects_fields_not_supported_by_product_type(): void {
$result = wp_get_ability( 'woocommerce/product-create' )->execute_change(
array(
- 'name' => 'Invalid Grouped Product',
- 'product_type_alias' => 'grouped',
- 'regular_price' => '19.99',
+ 'name' => 'Invalid Grouped Product',
+ 'type' => 'grouped',
+ 'regular_price' => '19.99',
)
);
$this->assertWPError( $result );
$this->assertSame( 'woocommerce_product_field_unsupported', $result->get_error_code() );
+ $this->assertSame( 'The grouped product type does not take regular_price. It takes name, sku, description, short_description, status, grouped_products.', $result->get_error_message() );
}
/**
@@ -1743,7 +1956,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$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( 'number', $input_schema['properties']['stock_quantity']['type'] ?? null );
$this->assertSame(
array( 'number', 'null' ),
$output_schema['properties']['product']['properties']['stock_quantity']['type'] ?? null
@@ -1919,7 +2132,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should update product properties and reflect changes in the response.
+ * @testdox Should update a per-type field such as regular_price without a type in the input.
*/
public function test_product_update_changes_props_and_returns_updated_response(): void {
$product = \WC_Helper_Product::create_simple_product();
@@ -1927,10 +2140,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$result = wp_get_ability( 'woocommerce/product-update' )->execute(
array(
- 'id' => $product->get_id(),
- 'product_type_alias' => 'physical',
- 'name' => 'Updated Name',
- 'regular_price' => '99.00',
+ 'id' => $product->get_id(),
+ 'name' => 'Updated Name',
+ 'regular_price' => '99.00',
)
);
@@ -1939,6 +2151,25 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertSame( '99.00', $result['product']['regular_price'] );
}
+ /**
+ * @testdox Should refuse an update field that the type of the loaded product does not support.
+ */
+ public function test_product_update_rejects_fields_not_supported_by_loaded_product_type(): void {
+ $product = \WC_Helper_Product::create_grouped_product();
+ $this->created_product_ids[] = $product->get_id();
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'regular_price' => '19.99',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_field_unsupported', $result->get_error_code() );
+ $this->assertSame( '', wc_get_product( $product->get_id() )->get_regular_price() );
+ }
+
/**
* @testdox Should return a structured error when product updates throw during save.
*/
@@ -1992,7 +2223,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should apply product type configuration when updating a product type alias.
+ * @testdox Should apply product type configuration when updating a product type.
*/
public function test_product_update_applies_product_type_configuration(): void {
$product = \WC_Helper_Product::create_simple_product();
@@ -2000,8 +2231,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$result = wp_get_ability( 'woocommerce/product-update' )->execute(
array(
- 'id' => $product->get_id(),
- 'product_type_alias' => 'digital',
+ 'id' => $product->get_id(),
+ 'type' => 'simple',
+ 'virtual' => true,
+ 'downloadable' => true,
)
);
@@ -2011,6 +2244,45 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertTrue( $result['product']['downloadable'] );
}
+ /**
+ * @testdox Should apply the forced properties of a product type only when the type changes.
+ */
+ public function test_product_update_applies_forced_properties_only_on_a_type_change(): void {
+ $grouped = new \WC_Product_Grouped();
+ $grouped->set_name( 'Grouped' );
+ $grouped->set_manage_stock( true );
+ $grouped->set_stock_quantity( 5 );
+ $grouped->save();
+ $this->created_product_ids[] = $grouped->get_id();
+
+ $simple = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $simple->get_id();
+ $simple->set_manage_stock( true );
+ $simple->set_stock_quantity( 5 );
+ $simple->save();
+
+ $repeated = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $grouped->get_id(),
+ 'type' => 'grouped',
+ 'name' => 'Renamed',
+ )
+ );
+ $changed = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $simple->get_id(),
+ 'type' => 'grouped',
+ )
+ );
+
+ $this->assertNotWPError( $repeated );
+ $this->assertSame( 'yes', get_post_meta( $grouped->get_id(), '_manage_stock', true ) );
+ $this->assertSame( '5', get_post_meta( $grouped->get_id(), '_stock', true ) );
+ $this->assertNotWPError( $changed );
+ $this->assertSame( 'no', get_post_meta( $simple->get_id(), '_manage_stock', true ) );
+ $this->assertSame( '', get_post_meta( $simple->get_id(), '_stock', true ) );
+ }
+
/**
* @testdox Should reject product updates that include no changed fields.
*/
@@ -2065,6 +2337,51 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertNotNull( wc_get_product( $variation_id ) );
}
+ /**
+ * @testdox Should refuse a type change on a variable product, because WooCommerce would delete its variations.
+ */
+ public function test_product_update_rejects_a_type_change_on_a_variable_product(): void {
+ $variable = \WC_Helper_Product::create_variation_product();
+ $this->created_product_ids[] = $variable->get_id();
+ $children = $variable->get_children();
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $variable->get_id(),
+ 'type' => 'simple',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
+ $this->assertSame( 'variable', wc_get_product( $variable->get_id() )->get_type() );
+ $this->assertSame( $children, wc_get_product( $variable->get_id() )->get_children() );
+ }
+
+ /**
+ * @testdox Should reject product variation IDs when updating products, with and without a type.
+ */
+ public function test_product_update_rejects_variation_ids(): void {
+ $variation_id = $this->create_variation_product_id_for_test();
+
+ foreach ( array( array(), array( 'type' => 'simple' ) ) as $type_input ) {
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array_merge(
+ array(
+ 'id' => $variation_id,
+ 'name' => 'Changed variation',
+ ),
+ $type_input
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
+ }
+
+ $this->assertSame( 'variation', wc_get_product( $variation_id )->get_type() );
+ }
+
/**
* @testdox Should trash a product by default.
*/
@@ -3158,44 +3475,6 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$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.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestBundleProduct.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestBundleProduct.php
new file mode 100644
index 00000000000..b95fde2d908
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestBundleProduct.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * TestBundleProduct class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+/**
+ * A product type whose class extends WC_Product, like a bundle.
+ */
+class TestBundleProduct extends \WC_Product {
+
+ /**
+ * Get the product type.
+ *
+ * @return string
+ */
+ public function get_type() {
+ return 'test-bundle';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestSubscriptionProduct.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestSubscriptionProduct.php
new file mode 100644
index 00000000000..e2348965027
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestSubscriptionProduct.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * TestSubscriptionProduct class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+/**
+ * A product type whose class extends the simple product class, like a subscription.
+ */
+class TestSubscriptionProduct extends \WC_Product_Simple {
+
+ /**
+ * Get the product type.
+ *
+ * @return string
+ */
+ public function get_type() {
+ return 'test-subscription';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 6e4015cfa48..88c395c8d53 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -343,8 +343,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$output = $this->run_ability(
'woocommerce/products-query',
array(
- 'product_type_alias' => 'affiliate',
- 'filters' => array( 'extensions' => array( 'test-ext' => array( 'category' => 'gifts' ) ) ),
+ 'type' => 'external',
+ 'filters' => array( 'extensions' => array( 'test-ext' => array( 'category' => 'gifts' ) ) ),
)
);
@@ -1621,15 +1621,67 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( array( 'integer', 'null' ), $extensions['other_ext']['properties']['count']['type'] );
$this->assertArrayNotHasKey( 'code', $extensions['test-ext']['properties'] );
$this->assertSame( 1, substr_count( wp_json_encode( $schema ), '"color"' ) );
- foreach ( $schema['oneOf'] as $branch ) {
- $this->assertSame( 'object', $branch['properties']['extensions']['type'] );
- $this->assertArrayNotHasKey( 'properties', $branch['properties']['extensions'] );
- }
+ $this->assertArrayNotHasKey( 'oneOf', $schema );
}
$order = $this->get_ability( 'woocommerce/order-update-status' )['input_schema'];
$this->assertArrayNotHasKey( 'extensions', $order['properties'] );
}
+ /**
+ * @testdox Should leave a field with object_types out of the other objects and refuse a write to them.
+ */
+ public function test_object_types_limit_a_field_to_some_objects(): void {
+ $register = static function () {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'field' => 'plan',
+ 'schema' => array( 'type' => 'string' ),
+ 'object_types' => array( 'simple' ),
+ 'get_callback' => static fn() => 'monthly',
+ 'update_callback' => static function ( \WC_Product $product, $value ) {
+ $product->update_meta_data( '_test_plan', $value );
+ },
+ )
+ );
+ };
+ add_action( 'woocommerce_extensible_abilities_init', $register );
+ $simple = \WC_Helper_Product::create_simple_product();
+ $grouped = \WC_Helper_Product::create_grouped_product();
+
+ try {
+ $schema = $this->get_ability( 'woocommerce/product-create' )['input_schema'];
+ $rejected = $this->run_ability_response(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $grouped->get_id(),
+ 'extensions' => array( 'test-ext' => array( 'plan' => 'yearly' ) ),
+ )
+ );
+ $updated = $this->run_ability(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $simple->get_id(),
+ 'extensions' => array( 'test-ext' => array( 'plan' => 'yearly' ) ),
+ )
+ );
+ $read = $this->run_ability( 'woocommerce/products-query', array( 'id' => $grouped->get_id() ) );
+ } finally {
+ remove_action( 'woocommerce_extensible_abilities_init', $register );
+ }
+
+ $this->assertSame( 'Applies to: simple.', $schema['properties']['extensions']['properties']['test-ext']['properties']['plan']['description'] );
+ $this->assertArrayHasKey( 'plan', $schema['properties']['extensions']['properties']['test-ext']['properties'] );
+ $this->assertSame( 400, $rejected->get_status() );
+ $this->assertSame( 'test-ext:woocommerce_ability_field_object_type', $rejected->get_data()['code'] );
+ $this->assertSame( 'The extensions.test-ext.plan field does not apply to the grouped type. It applies to simple.', $rejected->get_data()['message'] );
+ $this->assertSame( '', wc_get_product( $grouped->get_id() )->get_meta( '_test_plan' ) );
+ $this->assertSame( 'monthly', $updated['product']['extensions']['test-ext']['plan'] );
+ $this->assertSame( 'yearly', wc_get_product( $simple->get_id() )->get_meta( '_test_plan' ) );
+ $this->assertArrayNotHasKey( 'plan', $read['products'][0]['extensions']['test-ext'] ?? array() );
+ }
+
/**
* @testdox Should not accept extension fields in the write abilities with the feature off.
*/
@@ -1655,7 +1707,6 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertInstanceOf( ActionableAbility::class, wp_get_ability( 'woocommerce/product-update' ) );
$this->assertArrayNotHasKey( 'extensions', $schema['properties'] ?? array() );
- $this->assertArrayNotHasKey( 'extensions', $schema['oneOf'][0]['properties'] );
$this->assertSame( 400, $rejected->get_status() );
$this->assertSame( 'Pencil', $output['product']['name'] );
$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_color' ) );