Commit e18d7ec39f0 for woocommerce
commit e18d7ec39f044ed2bad9cd6aefe1b67f6887ea6b
Author: Christopher Hertel <mail@christopher-hertel.de>
Date: Thu Nov 5 23:41:55 2026 +0100
Let product abilities set terms, images and the remaining catalog fields (#70548)
* Let product writes set terms, images and the remaining catalog fields
product-create and product-update now accept categories, tags, brands,
images, catalog_visibility and featured for every product type, sale
dates and tax_status for priced products, sold_individually for simple
products, and weight, dimensions and shipping_class for physical ones.
products-query returns the fields it did not have yet, so every write
reads back.
Both writes now format the stored product. WC_Data::apply_changes()
merges with array_replace_recursive(), so after emptying a list the
saved object still held the old items, and it never saw the default
category the data store assigns.
* Refuse out-of-range sale times and name the unknown shipping class
The sale date pattern now limits hours to 0-23 and minutes and seconds
to 0-59, so 2030-01-31T24:00:00 is refused instead of rolling over to
the next day. checkdate() still catches impossible dates such as
2030-02-30.
An unknown shipping class now names the slug that was sent and says
that an empty string removes the class, so an agent can correct it.
diff --git a/plugins/woocommerce/changelog/product-writes-catalog-fields b/plugins/woocommerce/changelog/product-writes-catalog-fields
new file mode 100644
index 00000000000..9cb93287649
--- /dev/null
+++ b/plugins/woocommerce/changelog/product-writes-catalog-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let the product create and update abilities set categories, tags, brands, images, sale dates, shipping, tax and catalog visibility.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 9101b651cc7..c9e6012f8c4 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -276,67 +276,67 @@ trait ProductAbilityTrait {
*/
private static function get_product_mutation_field_schemas( bool $for_variation = false ): array {
return array(
- 'name' => array(
+ 'name' => array(
'type' => 'string',
'description' => __( 'Required, except on a variation, which takes its name from its parent.', 'woocommerce' ),
),
- 'parent_id' => array(
+ 'parent_id' => array(
'type' => 'integer',
'description' => __( 'ID of the variable product this variation belongs to.', 'woocommerce' ),
'minimum' => 1,
),
- 'attributes' => self::get_product_attributes_schema( $for_variation ),
- 'variations' => array( 'type' => 'array' ),
- 'sku' => array( 'type' => 'string' ),
- 'regular_price' => array(
+ 'attributes' => self::get_product_attributes_schema( $for_variation ),
+ 'variations' => array( 'type' => 'array' ),
+ '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(
+ '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(
+ 'description' => array(
'type' => 'string',
'description' => __( 'Product description content. Safe HTML is allowed.', 'woocommerce' ),
),
- 'short_description' => array(
+ 'short_description' => array(
'type' => 'string',
'description' => __( 'Short product description content. Safe HTML is allowed.', 'woocommerce' ),
),
- 'status' => array(
+ 'status' => array(
'type' => 'string',
'enum' => $for_variation ? self::get_variation_status_slugs() : self::get_product_mutation_status_slugs(),
),
- 'virtual' => array(
+ 'virtual' => array(
'type' => 'boolean',
'description' => __( 'Whether the product is not shipped.', 'woocommerce' ),
),
- 'downloadable' => array(
+ 'downloadable' => array(
'type' => 'boolean',
'description' => __( 'Whether the product gives the customer files to download.', 'woocommerce' ),
),
- 'manage_stock' => array( 'type' => 'boolean' ),
- 'stock_quantity' => array(
+ '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(
+ 'stock_status' => array(
'type' => 'string',
'enum' => array_keys( wc_get_product_stock_status_options() ),
),
- 'external_url' => array(
+ 'external_url' => array(
'type' => 'string',
'description' => __( 'External destination URL for external products.', 'woocommerce' ),
'format' => 'uri',
),
- 'button_text' => array(
+ 'button_text' => array(
'type' => 'string',
'description' => __( 'Button text for external products.', 'woocommerce' ),
),
- 'grouped_products' => array(
+ 'grouped_products' => array(
'type' => 'array',
'description' => __( 'Product IDs to include as children of a grouped product.', 'woocommerce' ),
'items' => array(
@@ -344,6 +344,79 @@ trait ProductAbilityTrait {
'minimum' => 1,
),
),
+ 'categories' => self::get_product_id_list_input_schema( __( 'Category IDs, replacing the current ones. Empty means the default category.', 'woocommerce' ) ),
+ 'tags' => self::get_product_id_list_input_schema( __( 'Tag IDs, replacing the current ones.', 'woocommerce' ) ),
+ 'brands' => self::get_product_id_list_input_schema( __( 'Brand IDs, replacing the current ones.', 'woocommerce' ) ),
+ 'images' => self::get_product_id_list_input_schema( __( 'Image attachment IDs, replacing the current ones. The first is the featured image.', 'woocommerce' ) ),
+ 'catalog_visibility' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( wc_get_product_visibility_options() ),
+ ),
+ 'featured' => array( 'type' => 'boolean' ),
+ 'date_on_sale_from' => array(
+ 'type' => 'string',
+ 'description' => __( 'Store time, YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS. Empty removes it.', 'woocommerce' ),
+ 'pattern' => self::get_product_date_input_pattern(),
+ ),
+ 'date_on_sale_to' => array(
+ 'type' => 'string',
+ 'description' => __( 'Store time, YYYY-MM-DD to end with that day or YYYY-MM-DDTHH:MM:SS. Empty removes it.', 'woocommerce' ),
+ 'pattern' => self::get_product_date_input_pattern(),
+ ),
+ 'tax_status' => array(
+ 'type' => 'string',
+ 'enum' => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
+ ),
+ 'sold_individually' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Limit to one per order.', 'woocommerce' ),
+ ),
+ 'weight' => array(
+ 'type' => 'string',
+ 'description' => sprintf(
+ /* translators: %s: weight unit, such as kg. */
+ __( 'Decimal in %s. Empty removes it.', 'woocommerce' ),
+ get_option( 'woocommerce_weight_unit' )
+ ),
+ 'pattern' => self::get_product_measurement_input_pattern(),
+ ),
+ 'dimensions' => array(
+ 'type' => 'object',
+ 'description' => sprintf(
+ /* translators: %s: dimension unit, such as cm. */
+ __( 'Decimals in %s. Only the keys sent change.', 'woocommerce' ),
+ get_option( 'woocommerce_dimension_unit' )
+ ),
+ 'properties' => array_fill_keys(
+ array( 'length', 'width', 'height' ),
+ array(
+ 'type' => 'string',
+ 'pattern' => self::get_product_measurement_input_pattern(),
+ )
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'shipping_class' => array(
+ 'type' => 'string',
+ 'description' => __( 'Shipping class slug. Empty removes it.', 'woocommerce' ),
+ ),
+ );
+ }
+
+ /**
+ * Schema of an input that takes positive IDs.
+ *
+ * @param string $description Field description.
+ * @return array
+ */
+ private static function get_product_id_list_input_schema( string $description ): array {
+ return array(
+ 'type' => 'array',
+ 'description' => $description,
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
);
}
@@ -428,7 +501,7 @@ trait ProductAbilityTrait {
$fields = self::get_common_product_mutation_fields();
$product_props = array();
- $price_fields = array( 'regular_price', 'sale_price' );
+ $price_fields = array( 'regular_price', 'sale_price', 'date_on_sale_from', 'date_on_sale_to', 'tax_status' );
$stock_fields = array( 'manage_stock', 'stock_quantity', 'stock_status' );
if ( is_a( $classname, \WC_Product_Variation::class, true ) ) {
@@ -439,7 +512,7 @@ trait ProductAbilityTrait {
$fields = array_values( array_diff( $fields, array( 'attributes' ) ) );
}
} elseif ( is_a( $classname, \WC_Product_Variable::class, true ) ) {
- $fields = array_merge( $fields, array( 'attributes', 'variations' ) );
+ $fields = array_merge( $fields, array( 'attributes', 'variations', 'tax_status', 'sold_individually', 'weight', 'dimensions', 'shipping_class' ) );
} elseif ( is_a( $classname, \WC_Product_External::class, true ) ) {
$fields = array_merge( $fields, $price_fields, array( 'external_url', 'button_text' ) );
$product_props = array(
@@ -456,10 +529,10 @@ trait ProductAbilityTrait {
'stock_quantity' => '',
);
} else {
- $fields = array_merge( $fields, array( 'virtual', 'downloadable' ) );
+ $fields = array_merge( $fields, array( 'virtual', 'downloadable', 'weight', 'dimensions', 'shipping_class' ) );
if ( is_a( $classname, \WC_Product_Simple::class, true ) ) {
- $fields = array_merge( $fields, $price_fields, $stock_fields );
+ $fields = array_merge( $fields, $price_fields, $stock_fields, array( 'sold_individually' ) );
}
}
@@ -544,7 +617,7 @@ trait ProductAbilityTrait {
* @return array<int, string>
*/
private static function get_common_product_mutation_fields(): array {
- return array( 'name', 'sku', 'description', 'short_description', 'status' );
+ return array( 'name', 'sku', 'description', 'short_description', 'status', 'categories', 'tags', 'brands', 'images', 'catalog_visibility', 'featured' );
}
/**
@@ -634,23 +707,34 @@ trait ProductAbilityTrait {
}
$setters = array(
- 'parent_id' => 'set_parent_id',
- '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',
- 'virtual' => 'set_virtual',
- 'downloadable' => 'set_downloadable',
- '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',
- 'attributes' => 'set_attributes',
+ 'parent_id' => 'set_parent_id',
+ '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',
+ 'virtual' => 'set_virtual',
+ 'downloadable' => 'set_downloadable',
+ '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',
+ 'attributes' => 'set_attributes',
+ 'categories' => 'set_category_ids',
+ 'tags' => 'set_tag_ids',
+ 'brands' => 'set_brand_ids',
+ 'catalog_visibility' => 'set_catalog_visibility',
+ 'featured' => 'set_featured',
+ 'date_on_sale_from' => 'set_date_on_sale_from',
+ 'date_on_sale_to' => 'set_date_on_sale_to',
+ 'tax_status' => 'set_tax_status',
+ 'sold_individually' => 'set_sold_individually',
+ 'weight' => 'set_weight',
+ 'shipping_class' => 'set_shipping_class_id',
);
foreach ( $setters as $field => $setter ) {
@@ -672,6 +756,23 @@ trait ProductAbilityTrait {
$product->{$setter}( $prepared_value );
}
+ foreach ( array( 'images', 'dimensions' ) as $field ) {
+ if ( ! in_array( $field, $product_config['fields'], true ) || ! array_key_exists( $field, $input ) ) {
+ continue;
+ }
+
+ $prepared_value = self::prepare_product_field_value( $field, $input[ $field ] );
+ if ( is_wp_error( $prepared_value ) ) {
+ return $prepared_value;
+ }
+
+ if ( 'images' === $field ) {
+ self::set_product_images( $product, $prepared_value );
+ } else {
+ self::set_product_dimensions( $product, $prepared_value );
+ }
+ }
+
return null;
}
@@ -1247,6 +1348,35 @@ trait ProductAbilityTrait {
);
}
+ /**
+ * Set the featured image from the first ID and the gallery from the rest.
+ *
+ * @param \WC_Product $product Product object.
+ * @param array<int, int> $image_ids Attachment IDs.
+ */
+ private static function set_product_images( \WC_Product $product, array $image_ids ): void {
+ $product->set_image_id( $image_ids[0] ?? '' );
+ $product->set_gallery_image_ids( array_slice( $image_ids, 1 ) );
+ }
+
+ /**
+ * Set only the dimensions in the input, so the others keep their value.
+ *
+ * @param \WC_Product $product Product object.
+ * @param array<string, string> $dimensions Dimensions keyed by length, width or height.
+ */
+ private static function set_product_dimensions( \WC_Product $product, array $dimensions ): void {
+ $setters = array(
+ 'length' => 'set_length',
+ 'width' => 'set_width',
+ 'height' => 'set_height',
+ );
+
+ foreach ( array_intersect_key( $dimensions, $setters ) as $dimension => $value ) {
+ $product->{$setters[ $dimension ]}( $value );
+ }
+ }
+
/**
* Apply product type configuration to a product object.
*
@@ -1305,7 +1435,7 @@ trait ProductAbilityTrait {
*
* @param string $field Field name.
* @param mixed $value Field value.
- * @return mixed
+ * @return mixed|\WP_Error
*/
private static function prepare_product_field_value( string $field, $value ) {
if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) ) {
@@ -1320,6 +1450,35 @@ trait ProductAbilityTrait {
return wc_format_decimal( $value );
}
+ $term_taxonomies = array(
+ 'categories' => 'product_cat',
+ 'tags' => 'product_tag',
+ 'brands' => 'product_brand',
+ );
+ if ( isset( $term_taxonomies[ $field ] ) ) {
+ return self::prepare_product_term_ids( $field, $value, $term_taxonomies[ $field ] );
+ }
+
+ if ( 'images' === $field ) {
+ return self::prepare_product_image_ids( $value );
+ }
+
+ if ( in_array( $field, array( 'date_on_sale_from', 'date_on_sale_to' ), true ) ) {
+ return self::prepare_product_sale_date( $field, $value );
+ }
+
+ if ( 'weight' === $field ) {
+ return wc_format_decimal( $value );
+ }
+
+ if ( 'dimensions' === $field ) {
+ return array_map( 'wc_format_decimal', $value );
+ }
+
+ if ( 'shipping_class' === $field ) {
+ return self::prepare_product_shipping_class_id( $value );
+ }
+
if ( is_string( $value ) ) {
return wc_clean( $value );
}
@@ -1327,6 +1486,134 @@ trait ProductAbilityTrait {
return $value;
}
+ /**
+ * Check that every ID is a term of the taxonomy.
+ *
+ * @param string $field Input field, for the error message.
+ * @param int[] $value Term IDs.
+ * @param string $taxonomy Taxonomy.
+ * @return array<int, int>|\WP_Error
+ */
+ private static function prepare_product_term_ids( string $field, array $value, string $taxonomy ) {
+ $term_ids = array_values( array_unique( $value ) );
+
+ $missing_ids = array_filter( $term_ids, static fn( int $term_id ): bool => ! get_term( $term_id, $taxonomy ) instanceof \WP_Term );
+
+ if ( ! empty( $missing_ids ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_product_term_not_found',
+ sprintf(
+ /* translators: 1: input field, such as categories. 2: comma-separated term IDs. */
+ __( 'These %1$s do not exist: %2$s.', 'woocommerce' ),
+ $field,
+ implode( ', ', $missing_ids )
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $term_ids;
+ }
+
+ /**
+ * Check that every ID is an image attachment.
+ *
+ * @param int[] $value Attachment IDs.
+ * @return array<int, int>|\WP_Error
+ */
+ private static function prepare_product_image_ids( array $value ) {
+ $image_ids = array_values( array_unique( $value ) );
+
+ $invalid_ids = array_filter( $image_ids, static fn( int $image_id ): bool => ! wp_attachment_is_image( $image_id ) );
+
+ if ( ! empty( $invalid_ids ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_product_image_invalid',
+ sprintf(
+ /* translators: %s: comma-separated attachment IDs. */
+ __( 'These IDs are not images in the media library: %s.', 'woocommerce' ),
+ implode( ', ', $invalid_ids )
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $image_ids;
+ }
+
+ /**
+ * A sale date in store time. A date-only end keeps the sale running until the end of that day.
+ *
+ * @param string $field Input field.
+ * @param string $value Date string, or empty to remove the date.
+ * @return string|\WP_Error
+ */
+ private static function prepare_product_sale_date( string $field, string $value ) {
+ $is_real_date = checkdate( (int) substr( $value, 5, 2 ), (int) substr( $value, 8, 2 ), (int) substr( $value, 0, 4 ) );
+
+ if ( '' !== $value && ! $is_real_date ) {
+ return new \WP_Error(
+ 'woocommerce_ability_product_date_invalid',
+ __( 'Sale dates must be real dates and times, such as 2030-01-31 or 2030-01-31T18:30:00.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( 'date_on_sale_to' === $field && 10 === strlen( $value ) ) {
+ return $value . ' 23:59:59';
+ }
+
+ return $value;
+ }
+
+ /**
+ * Get a schema-compatible pattern for sale date inputs.
+ *
+ * @return string
+ */
+ private static function get_product_date_input_pattern(): string {
+ return '^(?:[0-9]{4}-[0-9]{2}-[0-9]{2}(?:[T ](?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?)?)?$';
+ }
+
+ /**
+ * Get the term ID of a shipping class slug, or 0 for an empty slug.
+ *
+ * @param string $value Shipping class slug.
+ * @return int|\WP_Error
+ */
+ private static function prepare_product_shipping_class_id( string $value ) {
+ if ( '' === $value ) {
+ return 0;
+ }
+
+ $term = get_term_by( 'slug', sanitize_title( $value ), 'product_shipping_class' );
+
+ if ( ! $term instanceof \WP_Term ) {
+ return new \WP_Error(
+ 'woocommerce_ability_product_shipping_class_not_found',
+ sprintf(
+ /* translators: %s: shipping class slug sent in the input. */
+ __( 'Shipping class "%s" not found. Send the slug of an existing shipping class, or an empty string to remove it.', 'woocommerce' ),
+ $value
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $term->term_id;
+ }
+
+ /**
+ * Get a schema-compatible pattern for non-negative weight and dimension inputs.
+ *
+ * @return string
+ */
+ private static function get_product_measurement_input_pattern(): string {
+ $decimal_separators = preg_quote( implode( '', self::get_product_price_decimal_separators() ), '/' );
+
+ return '^(?:[0-9]+(?:[' . $decimal_separators . '][0-9]+)?|[' . $decimal_separators . '][0-9]+|)$';
+ }
+
/**
* Get a schema-compatible pattern for product price inputs.
*
@@ -1468,47 +1755,50 @@ trait ProductAbilityTrait {
$permalink = $product->get_permalink();
$data = array(
- 'id' => $product->get_id(),
- 'name' => $product->get_name(),
- 'slug' => $product->get_slug(),
- 'type' => $product->get_type(),
- 'status' => $product->get_status(),
- 'sku' => $product->get_sku(),
- 'currency' => get_woocommerce_currency(),
- 'currency_symbol' => html_entity_decode(
+ 'id' => $product->get_id(),
+ 'name' => $product->get_name(),
+ 'slug' => $product->get_slug(),
+ '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' => self::format_amount_for_response( $product->get_price() ),
- 'regular_price' => self::format_amount_for_response( $product->get_regular_price() ),
- 'sale_price' => self::format_amount_for_response( $product->get_sale_price() ),
- 'on_sale' => (bool) $product->is_on_sale(),
- 'stock_status' => $product->get_stock_status(),
- 'manage_stock' => (bool) $product->get_manage_stock(),
- 'virtual' => (bool) $product->get_virtual(),
- 'downloadable' => (bool) $product->get_downloadable(),
- 'grouped_products' => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
- 'categories' => self::format_product_terms_for_response( $product->get_category_ids( 'edit' ), 'product_cat' ),
- 'tags' => self::format_product_terms_for_response( $product->get_tag_ids( 'edit' ), 'product_tag' ),
- 'brands' => self::format_product_terms_for_response( $product->get_brand_ids( 'edit' ), 'product_brand' ),
- 'description' => $product->get_description(),
- 'short_description' => $product->get_short_description(),
- 'images' => self::format_product_images_for_response( $product ),
- 'weight' => $product->get_weight(),
- 'dimensions' => array(
+ 'price' => self::format_amount_for_response( $product->get_price() ),
+ 'regular_price' => self::format_amount_for_response( $product->get_regular_price() ),
+ 'sale_price' => self::format_amount_for_response( $product->get_sale_price() ),
+ 'on_sale' => (bool) $product->is_on_sale(),
+ 'stock_status' => $product->get_stock_status(),
+ 'manage_stock' => (bool) $product->get_manage_stock(),
+ 'sold_individually' => (bool) $product->get_sold_individually(),
+ 'virtual' => (bool) $product->get_virtual(),
+ 'downloadable' => (bool) $product->get_downloadable(),
+ 'grouped_products' => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
+ 'categories' => self::format_product_terms_for_response( $product->get_category_ids( 'edit' ), 'product_cat' ),
+ 'tags' => self::format_product_terms_for_response( $product->get_tag_ids( 'edit' ), 'product_tag' ),
+ 'brands' => self::format_product_terms_for_response( $product->get_brand_ids( 'edit' ), 'product_brand' ),
+ 'catalog_visibility' => $product->get_catalog_visibility(),
+ 'featured' => (bool) $product->get_featured(),
+ 'description' => $product->get_description(),
+ 'short_description' => $product->get_short_description(),
+ 'images' => self::format_product_images_for_response( $product ),
+ 'weight' => $product->get_weight(),
+ 'dimensions' => array(
'length' => $product->get_length(),
'width' => $product->get_width(),
'height' => $product->get_height(),
),
- 'shipping_required' => (bool) $product->needs_shipping(),
- 'shipping_class' => $product->get_shipping_class(),
- 'shipping_class_id' => $product->get_shipping_class_id(),
- 'tax_status' => $product->get_tax_status(),
- 'tax_class' => $product->get_tax_class(),
- 'backorders' => $product->get_backorders(),
- 'parent_id' => (int) $product->get_parent_id(),
- 'attributes' => self::format_product_attributes( $product ),
- 'variation_ids' => $product instanceof \WC_Product_Variable ? self::read_variation_children( $product, false )['all'] : array(),
+ 'shipping_required' => (bool) $product->needs_shipping(),
+ 'shipping_class' => $product->get_shipping_class(),
+ 'shipping_class_id' => $product->get_shipping_class_id(),
+ 'tax_status' => $product->get_tax_status(),
+ 'tax_class' => $product->get_tax_class(),
+ 'backorders' => $product->get_backorders(),
+ 'parent_id' => (int) $product->get_parent_id(),
+ 'attributes' => self::format_product_attributes( $product ),
+ 'variation_ids' => $product instanceof \WC_Product_Variable ? self::read_variation_children( $product, false )['all'] : array(),
);
if ( false !== $permalink ) {
@@ -1533,6 +1823,8 @@ trait ProductAbilityTrait {
$data += array_filter(
array(
+ 'date_on_sale_from' => self::format_date_for_response( $product->get_date_on_sale_from( 'edit' ), false ),
+ 'date_on_sale_to' => self::format_date_for_response( $product->get_date_on_sale_to( 'edit' ), false ),
'date_created' => self::format_date_for_response( $product->get_date_created(), false ),
'date_created_gmt' => self::format_date_for_response( $product->get_date_created() ),
'date_modified' => self::format_date_for_response( $product->get_date_modified(), false ),
@@ -1729,74 +2021,85 @@ trait ProductAbilityTrait {
return array(
'type' => 'object',
'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'name' => array( 'type' => 'string' ),
- 'slug' => array( 'type' => 'string' ),
- 'permalink' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'name' => array( 'type' => 'string' ),
+ 'slug' => array( 'type' => 'string' ),
+ 'permalink' => array(
'type' => 'string',
'description' => __( 'Product permalink. Omitted when no public permalink is available.', 'woocommerce' ),
'format' => 'uri',
),
- 'type' => array(
+ 'type' => array(
'type' => 'string',
'description' => __( 'Internal product type slug, such as simple, external, grouped, variable or variation.', 'woocommerce' ),
),
- 'status' => array(
+ 'status' => array(
'type' => 'string',
'enum' => self::get_product_output_status_slugs(),
),
- 'sku' => array( 'type' => 'string' ),
- 'currency' => array(
+ 'sku' => array( 'type' => 'string' ),
+ 'currency' => array(
'type' => 'string',
'enum' => array_keys( get_woocommerce_currencies() ),
),
- 'currency_symbol' => array( 'type' => 'string' ),
- 'price' => array(
+ 'currency_symbol' => array( 'type' => 'string' ),
+ 'price' => array(
'type' => 'string',
'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
),
- 'regular_price' => array(
+ 'regular_price' => array(
'type' => 'string',
'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
),
- 'sale_price' => array(
+ 'sale_price' => array(
'type' => 'string',
'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
),
- 'on_sale' => array(
+ 'on_sale' => array(
'type' => 'boolean',
'description' => __( 'Whether the sale price applies now, taking the sale dates into account.', 'woocommerce' ),
),
- 'stock_status' => array(
+ 'date_on_sale_from' => array(
+ 'type' => 'string',
+ 'description' => __( 'Start of the sale in the store timezone. Omitted when the sale has no start date.', 'woocommerce' ),
+ 'format' => 'date-time',
+ ),
+ 'date_on_sale_to' => array(
+ 'type' => 'string',
+ 'description' => __( 'End of the sale in the store timezone. Omitted when the sale has no end date.', 'woocommerce' ),
+ 'format' => 'date-time',
+ ),
+ 'stock_status' => array(
'type' => 'string',
'enum' => array_keys( wc_get_product_stock_status_options() ),
),
- 'stock_quantity' => array(
+ 'stock_quantity' => array(
'type' => self::get_product_stock_quantity_schema_type(),
'description' => __( 'Current stock quantity. Omitted when no stock quantity is set.', 'woocommerce' ),
),
- 'manage_stock' => array( 'type' => 'boolean' ),
- 'virtual' => array( 'type' => 'boolean' ),
- 'downloadable' => array( 'type' => 'boolean' ),
- 'external_url' => array(
+ 'manage_stock' => array( 'type' => 'boolean' ),
+ 'sold_individually' => array( 'type' => 'boolean' ),
+ 'virtual' => array( 'type' => 'boolean' ),
+ 'downloadable' => array( 'type' => 'boolean' ),
+ 'external_url' => array(
'type' => 'string',
'description' => __( 'External product URL. Omitted when the product is not external or no URL is set.', 'woocommerce' ),
'format' => 'uri',
),
- 'button_text' => array(
+ 'button_text' => array(
'type' => 'string',
'description' => __( 'Button text. Omitted when the product is not external or no button text is set.', 'woocommerce' ),
),
- 'grouped_products' => array(
+ 'grouped_products' => array(
'type' => 'array',
'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
'items' => array( 'type' => 'integer' ),
),
- 'parent_id' => array(
+ 'parent_id' => array(
'type' => 'integer',
'description' => __( 'ID of the variable product of a variation, or 0.', 'woocommerce' ),
),
- 'attributes' => array(
+ 'attributes' => array(
'type' => 'array',
'description' => __( 'Attributes with their options on a product, or the option each attribute picks on a variation. For a global attribute the options are term slugs.', 'woocommerce' ),
'items' => array(
@@ -1825,12 +2128,17 @@ trait ProductAbilityTrait {
'additionalProperties' => false,
),
),
- 'categories' => self::get_product_terms_output_schema(),
- 'tags' => self::get_product_terms_output_schema(),
- 'brands' => self::get_product_terms_output_schema(),
- 'description' => array( 'type' => 'string' ),
- 'short_description' => array( 'type' => 'string' ),
- 'images' => array(
+ 'categories' => self::get_product_terms_output_schema(),
+ 'tags' => self::get_product_terms_output_schema(),
+ 'brands' => self::get_product_terms_output_schema(),
+ 'catalog_visibility' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( wc_get_product_visibility_options() ),
+ ),
+ 'featured' => array( 'type' => 'boolean' ),
+ 'description' => array( 'type' => 'string' ),
+ 'short_description' => array( 'type' => 'string' ),
+ 'images' => array(
'type' => 'array',
'description' => __( 'Featured image first, then the gallery.', 'woocommerce' ),
'items' => array(
@@ -1847,7 +2155,7 @@ trait ProductAbilityTrait {
'additionalProperties' => false,
),
),
- 'weight' => array(
+ 'weight' => array(
'type' => 'string',
'description' => sprintf(
/* translators: %s: weight unit, such as kg. */
@@ -1855,7 +2163,7 @@ trait ProductAbilityTrait {
get_option( 'woocommerce_weight_unit' )
),
),
- 'dimensions' => array(
+ 'dimensions' => array(
'type' => 'object',
'description' => sprintf(
/* translators: %s: dimension unit, such as cm. */
@@ -1869,52 +2177,52 @@ trait ProductAbilityTrait {
),
'additionalProperties' => false,
),
- 'shipping_required' => array( 'type' => 'boolean' ),
- 'shipping_class' => array(
+ 'shipping_required' => array( 'type' => 'boolean' ),
+ 'shipping_class' => array(
'type' => 'string',
'description' => __( 'Shipping class slug, or empty when the product has none.', 'woocommerce' ),
),
- 'shipping_class_id' => array(
+ 'shipping_class_id' => array(
'type' => 'integer',
'description' => __( 'Shipping class term ID, or 0 when the product has none.', 'woocommerce' ),
),
- 'tax_status' => array(
+ 'tax_status' => array(
'type' => 'string',
'enum' => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
),
- 'tax_class' => array(
+ 'tax_class' => array(
'type' => 'string',
'description' => __( 'Tax class slug, or empty for the standard rate.', 'woocommerce' ),
),
- 'backorders' => array(
+ 'backorders' => array(
'type' => 'string',
'enum' => array_keys( wc_get_product_backorder_options() ),
),
- 'low_stock_amount' => array(
+ 'low_stock_amount' => array(
'type' => self::get_product_stock_quantity_schema_type(),
'description' => __( 'Low stock threshold of the product. Omitted when the store-wide threshold applies.', 'woocommerce' ),
),
- 'variation_ids' => array(
+ 'variation_ids' => array(
'type' => 'array',
'description' => __( 'IDs of the variations of a variable product.', 'woocommerce' ),
'items' => array( 'type' => 'integer' ),
),
- 'date_created' => array(
+ 'date_created' => array(
'type' => 'string',
'format' => 'date-time',
'description' => __( 'Product creation date in the store timezone. Omitted when no creation date is available.', 'woocommerce' ),
),
- 'date_created_gmt' => array(
+ 'date_created_gmt' => array(
'type' => 'string',
'format' => 'date-time',
'description' => __( 'Product creation date in UTC. Omitted when no creation date is available.', 'woocommerce' ),
),
- 'date_modified' => array(
+ 'date_modified' => array(
'type' => 'string',
'format' => 'date-time',
'description' => __( 'Product modification date in the store timezone. Omitted when no modification date is available.', 'woocommerce' ),
),
- 'date_modified_gmt' => array(
+ 'date_modified_gmt' => array(
'type' => 'string',
'format' => 'date-time',
'description' => __( 'Product modification date in UTC. Omitted when no modification date is available.', 'woocommerce' ),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index b4a870cc3b9..0c846de9aaf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -427,8 +427,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
$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->assertStringContainsString( 'external: name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, regular_price, sale_price, date_on_sale_from, date_on_sale_to, tax_status, external_url, button_text', $description );
+ $this->assertStringContainsString( 'grouped: name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, 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'] ?? '' );
@@ -1874,7 +1874,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$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() );
+ $this->assertSame( 'The test-bundle product type does not take regular_price. It takes name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, virtual, downloadable, weight, dimensions, shipping_class.', $bundle_price->get_error_message() );
}
/**
@@ -3194,7 +3194,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$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() );
+ $this->assertSame( 'The grouped product type does not take regular_price. It takes name, sku, description, short_description, status, categories, tags, brands, images, catalog_visibility, featured, grouped_products.', $result->get_error_message() );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductWritesTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductWritesTest.php
new file mode 100644
index 00000000000..490cf3fa8ea
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductWritesTest.php
@@ -0,0 +1,379 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the catalog fields of the product-create and product-update abilities.
+ */
+class ProductWritesTest extends WC_Unit_Test_Case {
+
+ /**
+ * Log in as an administrator, who may create, edit and publish products.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ }
+
+ /**
+ * @testdox Should read back every catalog field that product-create writes.
+ */
+ public function test_create_round_trips_catalog_fields(): void {
+ $category = wp_insert_term( 'Hoodies', 'product_cat' )['term_id'];
+ $tag = wp_insert_term( 'Winter', 'product_tag' )['term_id'];
+ $brand = wp_insert_term( 'Acme', 'product_brand' )['term_id'];
+ $shipping_class = wp_insert_term( 'Bulky', 'product_shipping_class' )['term_id'];
+ $featured_image = $this->create_image( 'front.jpg' );
+ $gallery_image = $this->create_image( 'back.jpg' );
+
+ $created = $this->execute(
+ 'woocommerce/product-create',
+ array(
+ 'name' => 'Hoodie',
+ 'regular_price' => '60',
+ 'sale_price' => '40',
+ 'categories' => array( $category ),
+ 'tags' => array( $tag ),
+ 'brands' => array( $brand ),
+ 'images' => array( $featured_image, $gallery_image ),
+ 'catalog_visibility' => 'search',
+ 'featured' => true,
+ 'date_on_sale_from' => '2030-01-01',
+ 'date_on_sale_to' => '2030-01-31T18:30:00',
+ 'tax_status' => ProductTaxStatus::SHIPPING,
+ 'sold_individually' => true,
+ 'weight' => '0.8',
+ 'dimensions' => array(
+ 'length' => '30',
+ 'width' => '20',
+ 'height' => '5',
+ ),
+ 'shipping_class' => 'bulky',
+ )
+ );
+
+ $product = $this->query_product( $created['product']['id'] );
+
+ $this->assertSame( array( $category ), array_column( $product['categories'], 'id' ) );
+ $this->assertSame( array( $tag ), array_column( $product['tags'], 'id' ) );
+ $this->assertSame( array( $brand ), array_column( $product['brands'], 'id' ) );
+ $this->assertSame( array( $featured_image, $gallery_image ), array_column( $product['images'], 'id' ) );
+ $this->assertSame( 'search', $product['catalog_visibility'] );
+ $this->assertTrue( $product['featured'] );
+ $this->assertSame( '2030-01-01T00:00:00+00:00', $product['date_on_sale_from'] );
+ $this->assertSame( '2030-01-31T18:30:00+00:00', $product['date_on_sale_to'] );
+ $this->assertFalse( $product['on_sale'], 'The sale has not started yet.' );
+ $this->assertSame( ProductTaxStatus::SHIPPING, $product['tax_status'] );
+ $this->assertTrue( $product['sold_individually'] );
+ $this->assertSame( '0.8', $product['weight'] );
+ $this->assertSame( '30', $product['dimensions']['length'] );
+ $this->assertSame( '20', $product['dimensions']['width'] );
+ $this->assertSame( '5', $product['dimensions']['height'] );
+ $this->assertSame( 'bulky', $product['shipping_class'] );
+ $this->assertSame( $shipping_class, $product['shipping_class_id'] );
+ }
+
+ /**
+ * @testdox Should set terms, images, visibility and featured when updating an external product.
+ */
+ public function test_update_sets_common_catalog_fields_on_external_product(): void {
+ $product = WC_Helper_Product::create_external_product();
+ $category = wp_insert_term( 'Gadgets', 'product_cat' )['term_id'];
+ $image = $this->create_image( 'gadget.jpg' );
+
+ $updated = $this->execute(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'categories' => array( $category ),
+ 'images' => array( $image ),
+ 'catalog_visibility' => 'hidden',
+ 'featured' => true,
+ )
+ )['product'];
+
+ $this->assertSame( array( $category ), array_column( $updated['categories'], 'id' ) );
+ $this->assertSame( array( $image ), array_column( $updated['images'], 'id' ) );
+ $this->assertSame( 'hidden', $updated['catalog_visibility'] );
+ $this->assertTrue( $updated['featured'] );
+ }
+
+ /**
+ * @testdox Should remove all images and fall back to the default category when sent empty lists.
+ */
+ public function test_update_with_empty_lists_clears_images_and_categories(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_category_ids( array( wp_insert_term( 'Old', 'product_cat' )['term_id'] ) );
+ $product->set_image_id( $this->create_image( 'old.jpg' ) );
+ $product->set_gallery_image_ids( array( $this->create_image( 'older.jpg' ) ) );
+ $product->save();
+
+ $updated = $this->execute(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'categories' => array(),
+ 'images' => array(),
+ )
+ )['product'];
+
+ $this->assertSame( array(), $updated['images'] );
+ $this->assertSame( array( (int) get_option( 'default_product_cat' ) ), array_column( $updated['categories'], 'id' ) );
+ }
+
+ /**
+ * @testdox Should change only the dimensions sent and remove a shipping class sent as an empty slug.
+ */
+ public function test_update_changes_only_sent_dimensions(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'length' => '10',
+ 'width' => '20',
+ 'height' => '30',
+ 'shipping_class_id' => wp_insert_term( 'Small', 'product_shipping_class' )['term_id'],
+ )
+ );
+
+ $updated = $this->execute(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'dimensions' => array(
+ 'width' => '25',
+ 'height' => '',
+ ),
+ 'shipping_class' => '',
+ )
+ )['product'];
+
+ $this->assertSame( '10', $updated['dimensions']['length'] );
+ $this->assertSame( '25', $updated['dimensions']['width'] );
+ $this->assertSame( '', $updated['dimensions']['height'] );
+ $this->assertSame( '', $updated['shipping_class'] );
+ }
+
+ /**
+ * @testdox Should end a sale at the end of the day in store time when the end date has no time.
+ */
+ public function test_date_only_sale_end_lasts_the_whole_day(): void {
+ update_option( 'timezone_string', 'Europe/Berlin' );
+
+ $created = $this->execute(
+ 'woocommerce/product-create',
+ array(
+ 'name' => 'Sale tee',
+ 'regular_price' => '20',
+ 'sale_price' => '15',
+ 'date_on_sale_to' => '2030-06-30',
+ )
+ );
+
+ $this->assertSame( '2030-06-30T23:59:59+02:00', $created['product']['date_on_sale_to'] );
+ }
+
+ /**
+ * @testdox Should set catalog fields on a variable product and the fields its variations inherit.
+ */
+ public function test_variable_product_fields(): void {
+ $category = wp_insert_term( 'Shirts', 'product_cat' )['term_id'];
+ $shipping_class = wp_insert_term( 'Bulky', 'product_shipping_class' )['term_id'];
+ $image = $this->create_image( 'shirt.jpg' );
+
+ $created = $this->execute(
+ 'woocommerce/product-create',
+ array(
+ 'name' => 'Shirt',
+ 'type' => 'variable',
+ 'categories' => array( $category ),
+ 'images' => array( $image ),
+ 'featured' => true,
+ 'tax_status' => ProductTaxStatus::NONE,
+ 'sold_individually' => true,
+ 'weight' => '0.3',
+ 'shipping_class' => 'bulky',
+ 'attributes' => array(
+ array(
+ 'name' => 'Size',
+ 'options' => array( 'S', 'M' ),
+ ),
+ ),
+ 'variations' => array(
+ array(
+ 'attributes' => array(
+ array(
+ 'name' => 'Size',
+ 'option' => 'S',
+ ),
+ ),
+ 'regular_price' => '10',
+ ),
+ ),
+ )
+ )['product'];
+
+ $this->assertSame( array( $category ), array_column( $created['categories'], 'id' ) );
+ $this->assertSame( array( $image ), array_column( $created['images'], 'id' ) );
+ $this->assertTrue( $created['featured'] );
+ $this->assertSame( ProductTaxStatus::NONE, $created['tax_status'] );
+
+ $variation = wc_get_product( $created['variation_ids'][0] );
+
+ $this->assertSame( ProductTaxStatus::NONE, $variation->get_tax_status(), 'A variation reads its tax status from the parent.' );
+ $this->assertTrue( $variation->get_sold_individually() );
+ $this->assertSame( '0.3', $variation->get_weight() );
+ $this->assertSame( $shipping_class, $variation->get_shipping_class_id() );
+ }
+
+ /**
+ * @testdox Should accept sale dates and the tax status on external products, but not sold individually.
+ */
+ public function test_external_product_fields(): void {
+ $created = $this->execute(
+ 'woocommerce/product-create',
+ array(
+ 'name' => 'Affiliate tee',
+ 'type' => 'external',
+ 'external_url' => 'https://example.com/tee',
+ 'date_on_sale_from' => '2030-01-01',
+ 'tax_status' => ProductTaxStatus::NONE,
+ )
+ );
+
+ $this->assertSame( '2030-01-01T00:00:00+00:00', $created['product']['date_on_sale_from'] );
+ $this->assertArrayNotHasKey( 'date_on_sale_to', $created['product'], 'A sale without an end date should omit it.' );
+ $this->assertSame( ProductTaxStatus::NONE, $created['product']['tax_status'] );
+
+ $rejected = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Affiliate tee',
+ 'type' => 'external',
+ 'sold_individually' => true,
+ )
+ );
+
+ $this->assertWPError( $rejected );
+ $this->assertSame( 'woocommerce_product_field_unsupported', $rejected->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject shipping fields on an external product.
+ *
+ * @testWith ["weight", "1"]
+ * ["shipping_class", "bulky"]
+ *
+ * @param string $field Shipping field.
+ * @param string $value Field value.
+ */
+ public function test_external_product_rejects_shipping_fields( string $field, string $value ): void {
+ $result = wp_get_ability( 'woocommerce/product-create' )->execute(
+ array(
+ 'name' => 'Affiliate tee',
+ 'type' => 'external',
+ $field => $value,
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_product_field_unsupported', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should reject unknown terms, non-image attachments, unknown shipping classes and impossible dates without saving.
+ */
+ public function test_rejects_unknown_references(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $image = $this->create_image( 'kept.jpg' );
+ $product->set_image_id( $image );
+ $product->save();
+ $document = self::factory()->attachment->create_object(
+ array(
+ 'file' => 'manual.pdf',
+ 'post_mime_type' => 'application/pdf',
+ )
+ );
+
+ $cases = array(
+ 'woocommerce_ability_product_term_not_found' => array( 'tags' => array( 999999 ) ),
+ 'woocommerce_ability_product_image_invalid' => array( 'images' => array( $document ) ),
+ 'woocommerce_ability_product_shipping_class_not_found' => array( 'shipping_class' => 'missing' ),
+ 'woocommerce_ability_product_date_invalid' => array( 'date_on_sale_from' => '2030-02-30' ),
+ );
+
+ foreach ( $cases as $error_code => $input ) {
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute( array( 'id' => $product->get_id() ) + $input );
+
+ $this->assertWPError( $result );
+ $this->assertSame( $error_code, $result->get_error_code() );
+ }
+
+ $unknown_class = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'shipping_class' => 'missing',
+ )
+ );
+ $this->assertStringContainsString( '"missing"', $unknown_class->get_error_message() );
+
+ $out_of_range_time = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'date_on_sale_to' => '2030-01-31T24:00:00',
+ )
+ );
+ $this->assertWPError( $out_of_range_time );
+
+ $stored = wc_get_product( $product->get_id() );
+
+ $this->assertSame( array(), $stored->get_tag_ids() );
+ $this->assertSame( $image, (int) $stored->get_image_id() );
+ $this->assertSame( 0, $stored->get_shipping_class_id() );
+ $this->assertNull( $stored->get_date_on_sale_from() );
+ }
+
+ /**
+ * Execute an ability and check it succeeded.
+ *
+ * @param string $ability Ability name.
+ * @param array $input Ability input.
+ * @return array
+ */
+ private function execute( string $ability, array $input ): array {
+ $result = wp_get_ability( $ability )->execute( $input );
+
+ $this->assertIsArray( $result, is_wp_error( $result ) ? $result->get_error_message() : '' );
+
+ return $result;
+ }
+
+ /**
+ * Read a product through products-query, as an agent would after a write.
+ *
+ * @param int $product_id Product ID.
+ * @return array
+ */
+ private function query_product( int $product_id ): array {
+ return $this->execute( 'woocommerce/products-query', array( 'id' => $product_id ) )['products'][0];
+ }
+
+ /**
+ * Create an image attachment.
+ *
+ * @param string $file File name.
+ * @return int
+ */
+ private function create_image( string $file ): int {
+ return self::factory()->attachment->create_object(
+ array(
+ 'file' => $file,
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ }
+}