Commit 9c17a04f1af for woocommerce
commit 9c17a04f1af7f4ed8e2f244ff42f21833d015ffb
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Mon Aug 24 21:07:37 2026 +0300
Refuse CSV variation rows whose attributes the parent product does not offer (#67959)
* Report why a variation row was refused during update imports
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Refuse variation rows whose attributes the parent product does not offer
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Share the existing-attribute lookup with the importer base class
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Assert no variation is created rather than that no positive ID exists
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop two baseline entries the shared attribute lookup resolves
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Assert the full refusal message in the variation import tests
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Name the import test helper for what it actually does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Decode the refusal message with ENT_QUOTES so PHP 7.4 matches
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Cover the re-import that creates the previously skipped variation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-import-variation-attribute-validation b/plugins/woocommerce/changelog/fix-import-variation-attribute-validation
new file mode 100644
index 00000000000..b9d6cc8ffdf
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-import-variation-attribute-validation
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Fixes an unreleased 11.1 feature; no merchant-facing change.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index aede47e4730..8d5946712aa 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -678,6 +678,35 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
return $id;
}
+ /**
+ * Get the taxonomy name of a global attribute as written in the imported data.
+ *
+ * @since 11.1.0
+ *
+ * @param string $raw_name Attribute name or label.
+ * @return string
+ */
+ protected function get_attribute_taxonomy_name_from_raw_name( $raw_name ) {
+ // These are exported as labels, so convert the label to a name if possible first.
+ $attribute_labels = wp_list_pluck( wc_get_attribute_taxonomies(), 'attribute_label', 'attribute_name' );
+ $attribute_name = array_search( $raw_name, $attribute_labels, true );
+
+ // Cast because a numeric attribute name is returned as an integer array key by array_search().
+ return $attribute_name ? (string) $attribute_name : wc_sanitize_taxonomy_name( $raw_name );
+ }
+
+ /**
+ * Get the ID of an existing global attribute taxonomy, without creating it when it is missing.
+ *
+ * @since 11.1.0
+ *
+ * @param string $raw_name Attribute name or label.
+ * @return int Attribute taxonomy ID, or 0 when no such global attribute exists.
+ */
+ protected function get_existing_attribute_taxonomy_id( $raw_name ) {
+ return (int) wc_attribute_taxonomy_id_by_name( $this->get_attribute_taxonomy_name_from_raw_name( $raw_name ) );
+ }
+
/**
* Get attribute taxonomy ID from the imported data.
* If does not exists register a new attribute.
@@ -689,15 +718,8 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
public function get_attribute_taxonomy_id( $raw_name ) {
global $wpdb, $wc_product_attributes;
- // These are exported as labels, so convert the label to a name if possible first.
- $attribute_labels = wp_list_pluck( wc_get_attribute_taxonomies(), 'attribute_label', 'attribute_name' );
- $attribute_name = array_search( $raw_name, $attribute_labels, true );
-
- if ( ! $attribute_name ) {
- $attribute_name = wc_sanitize_taxonomy_name( $raw_name );
- }
-
- $attribute_id = wc_attribute_taxonomy_id_by_name( $attribute_name );
+ $attribute_name = $this->get_attribute_taxonomy_name_from_raw_name( $raw_name );
+ $attribute_id = $this->get_existing_attribute_taxonomy_id( $raw_name );
// Get the ID from the name.
if ( $attribute_id ) {
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index 517113a8305..5adf3b4ace1 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -1178,33 +1178,136 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
* @since 11.1.0
*
* @param array $parsed_data Parsed row data.
- * @return bool
+ * @return true|WP_Error True when the variation can be created, a WP_Error describing the refusal otherwise.
*/
protected function can_create_variation( $parsed_data ) {
// A row ID cannot be honored when creating a new variation: reusing an existing
// post's ID would corrupt that post, and a nonexistent ID cannot be assigned.
if ( ! empty( $parsed_data['id'] ) ) {
- return false;
+ return new WP_Error(
+ 'woocommerce_product_importer_variation_has_id',
+ esc_html__( 'A new variation cannot be created for a row that specifies an ID.', 'woocommerce' )
+ );
}
// A CSV ID cannot be assigned to a new variation, so without a SKU the created variation
// could never be matched again and every re-import would duplicate it.
if ( empty( $parsed_data['sku'] ) ) {
- return false;
+ return new WP_Error(
+ 'woocommerce_product_importer_variation_missing_sku',
+ esc_html__( 'A new variation cannot be created without a SKU.', 'woocommerce' )
+ );
}
if ( empty( $parsed_data['parent_id'] ) ) {
- return false;
+ return new WP_Error(
+ 'woocommerce_product_importer_variation_missing_parent',
+ esc_html__( 'A new variation cannot be created without a parent product.', 'woocommerce' )
+ );
}
$parent = wc_get_product( $parsed_data['parent_id'] );
if ( ! $parent || ! $parent->is_type( ProductType::VARIABLE ) ) {
- return false;
+ return new WP_Error(
+ 'woocommerce_product_importer_variation_parent_not_variable',
+ esc_html__( 'A new variation can only be created for a variable parent product.', 'woocommerce' )
+ );
}
// A parent with the 'importing' status is a placeholder, meaning the parent does not exist either.
- return ! in_array( $parent->get_status(), array( 'importing', ProductStatus::TRASH ), true );
+ if ( in_array( $parent->get_status(), array( 'importing', ProductStatus::TRASH ), true ) ) {
+ return new WP_Error(
+ 'woocommerce_product_importer_variation_parent_missing',
+ esc_html__( 'A new variation cannot be created for a parent product that does not exist.', 'woocommerce' )
+ );
+ }
+
+ return $this->validate_new_variation_attributes( $parsed_data, $parent );
+ }
+
+ /**
+ * Check that a new variation's attributes are offered by its parent product.
+ *
+ * The storefront variation selector only renders values the parent declares, so a variation
+ * carrying a value the parent does not offer would be created but never selectable. Likewise,
+ * an attribute the parent does not have at all is dropped on save, silently turning the row
+ * into an "any" variation that matches every combination.
+ *
+ * @since 11.1.0
+ *
+ * @param array $parsed_data Parsed row data.
+ * @param WC_Product $parent_product Parent product the variation would be created under.
+ * @return true|WP_Error True when every attribute is offered by the parent, a WP_Error describing the refusal otherwise.
+ */
+ protected function validate_new_variation_attributes( $parsed_data, $parent_product ) {
+ if ( empty( $parsed_data['raw_attributes'] ) ) {
+ return true;
+ }
+
+ $parent_attributes = $parent_product->get_attributes();
+
+ foreach ( $parsed_data['raw_attributes'] as $attribute ) {
+ if ( empty( $attribute['name'] ) ) {
+ continue;
+ }
+
+ // Resolve the row's attribute the same way set_variation_data() does, so a row that would
+ // have been stored correctly is never refused here. get_attribute_taxonomy_id() is deliberately
+ // not used: it creates the global attribute when it is missing, which must not happen for a
+ // row that is about to be refused.
+ $attribute_id = empty( $attribute['taxonomy'] ) ? 0 : $this->get_existing_attribute_taxonomy_id( $attribute['name'] );
+ $attribute_name = $attribute_id ? sanitize_title( wc_attribute_taxonomy_name_by_id( $attribute_id ) ) : sanitize_title( $attribute['name'] );
+
+ // An attribute the parent does not have is dropped on save. An attribute the parent has but
+ // does not use for variations is allowed through: get_variation_parent_attributes() promotes it.
+ if ( ! isset( $parent_attributes[ $attribute_name ] ) ) {
+ return new WP_Error(
+ 'woocommerce_product_importer_variation_unknown_attribute',
+ sprintf(
+ /* translators: %s: attribute name */
+ esc_html__( 'A new variation cannot be created because the parent product has no "%s" attribute.', 'woocommerce' ),
+ esc_html( $attribute['name'] )
+ )
+ );
+ }
+
+ $parent_attribute = $parent_attributes[ $attribute_name ];
+ $raw_value = isset( $attribute['value'] ) ? current( (array) $attribute['value'] ) : '';
+
+ // An empty value is a valid "any" variation.
+ if ( '' === $raw_value || false === $raw_value ) {
+ continue;
+ }
+
+ if ( $parent_attribute->is_taxonomy() ) {
+ $taxonomy = $parent_attribute->get_name();
+ $term = get_term_by( 'name', $raw_value, $taxonomy );
+ $value = ( $term && ! is_wp_error( $term ) ) ? $term->slug : sanitize_title( $raw_value );
+
+ // The terms assigned to the parent are the exact set the storefront selector renders.
+ // WC_Product_Attribute::get_terms() is avoided here because it inserts any term that does
+ // not exist yet, which must not happen while deciding whether to refuse a row.
+ $options = wc_get_product_terms( $parent_product->get_id(), $taxonomy, array( 'fields' => 'slugs' ) );
+ } else {
+ $value = $raw_value;
+ $options = $parent_attribute->get_options();
+ }
+
+ if ( ! in_array( $value, $options, true ) ) {
+ return new WP_Error(
+ 'woocommerce_product_importer_variation_unknown_attribute_value',
+ sprintf(
+ /* translators: 1: attribute value, 2: attribute name */
+ esc_html__( 'A new variation cannot be created because "%1$s" is not an option of the parent product\'s "%2$s" attribute.', 'woocommerce' ),
+ esc_html( $raw_value ),
+ esc_html( wc_attribute_label( $parent_attribute->get_name(), $parent_product ) )
+ )
+ );
+ }
+ }
+
+ return true;
}
/**
@@ -1273,25 +1376,36 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
if ( $update_existing && ( isset( $parsed_data['id'] ) || isset( $parsed_data['sku'] ) ) && ! $id_exists && ! $sku_exists ) {
$create_variation = false;
+ $refusal = null;
+
+ if ( ProductType::VARIATION === ( $parsed_data['type'] ?? '' ) ) {
+ $can_create_variation = $this->can_create_variation( $parsed_data );
- if ( ProductType::VARIATION === ( $parsed_data['type'] ?? '' ) && $this->can_create_variation( $parsed_data ) ) {
- /**
- * Filters whether a new variation should be created for an existing variable product when updating existing products.
- *
- * Only fires for variation rows that passed validation, so it can veto the creation but not force it.
- *
- * @since 11.1.0
- *
- * @param bool $create_variation Whether to create the new variation instead of skipping the row.
- * @param array $parsed_data Parsed row data.
- */
- $create_variation = apply_filters( 'woocommerce_product_import_create_variation_of_existing_product', true, $parsed_data );
+ // Anything other than an explicit pass refuses, so an override still written against
+ // the previous boolean contract cannot turn a refusal into a creation.
+ if ( true !== $can_create_variation ) {
+ $refusal = is_wp_error( $can_create_variation ) ? $can_create_variation : null;
+ } else {
+ /**
+ * Filters whether a new variation should be created for an existing variable product when updating existing products.
+ *
+ * Only fires for variation rows that passed validation, so it can veto the creation but not force it.
+ *
+ * @since 11.1.0
+ *
+ * @param bool $create_variation Whether to create the new variation instead of skipping the row.
+ * @param array $parsed_data Parsed row data.
+ */
+ $create_variation = apply_filters( 'woocommerce_product_import_create_variation_of_existing_product', true, $parsed_data );
+ }
}
if ( ! $create_variation ) {
+ // A refused variation row reports why it was refused; anything else is a row whose
+ // ID or SKU simply matches nothing on the site.
$data['skipped'][] = new WP_Error(
'woocommerce_product_importer_error',
- esc_html__( 'No matching product exists to update.', 'woocommerce' ),
+ $refusal ? $refusal->get_error_message() : esc_html__( 'No matching product exists to update.', 'woocommerce' ),
array(
'id' => $id,
'sku' => esc_attr( $sku ),
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 87e42963f50..1a748176170 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -20106,18 +20106,6 @@ parameters:
count: 2
path: includes/import/abstract-wc-product-importer.php
- -
- message: '#^Parameter \#1 \$attribute_name of function wc_attribute_taxonomy_name expects string, int\<min, \-1\>\|int\<1, max\>\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/import/abstract-wc-product-importer.php
-
- -
- message: '#^Parameter \#1 \$name of function wc_attribute_taxonomy_id_by_name expects string, int\<min, \-1\>\|int\<1, max\>\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/import/abstract-wc-product-importer.php
-
-
message: '#^Parameter \#2 \$array of function array_map expects array, list\<string\>\|false given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index 830d80232be..f4dceea43f6 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -515,6 +515,290 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
WC_Helper_Product::delete_product( $product->get_id() );
}
+ /**
+ * Run an import with "update existing products" against an already existing parent.
+ *
+ * @param string $csv_body CSV contents, header row included. Usually a lone variation row, but a
+ * parent row can be included to cover ordering between the two.
+ * @param string $file_name Temp file name to write the CSV to.
+ * @return array Import results.
+ */
+ private function import_with_update_existing( $csv_body, $file_name ) {
+ $csv_file = trailingslashit( get_temp_dir() ) . $file_name;
+ file_put_contents( $csv_file, $csv_body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Attribute 1 global' => 'attributes:taxonomy1',
+ 'Regular price' => 'regular_price',
+ ),
+ );
+
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+ wp_delete_file( $csv_file );
+
+ return $data;
+ }
+
+ /**
+ * @testdox Test that a variation row carrying an attribute value the parent does not offer is skipped, since the storefront could never select it.
+ */
+ public function test_import_skips_new_variations_with_a_value_the_parent_does_not_offer() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import Tee' );
+ $product->set_sku( 'IMPORT-VALUE-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $data = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-VALUE-L,Import Tee - L,IMPORT-VALUE-PARENT,Size,L,0,12\n",
+ 'import-unknown-value.csv'
+ );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertSame(
+ 'A new variation cannot be created because "L" is not an option of the parent product\'s "Size" attribute.',
+ html_entity_decode( $data['skipped'][0]->get_error_message(), ENT_QUOTES ),
+ 'Expected the skip message to name the unavailable value and its attribute'
+ );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-VALUE-L' ), 'Expected no variation to be created for a value the parent does not offer' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that a variation row carrying a global attribute term the parent does not offer is skipped.
+ */
+ public function test_import_skips_new_variations_with_a_taxonomy_value_the_parent_does_not_offer() {
+ $attribute_data = WC_Helper_Product::create_attribute( 'size-airr19', array( 'S', 'M', 'L' ) );
+ $taxonomy = $attribute_data['attribute_taxonomy'];
+ $small = get_term_by( 'name', 'S', $taxonomy );
+
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_id( $attribute_data['attribute_id'] );
+ $attribute->set_name( $taxonomy );
+ $attribute->set_options( array( $small->term_id ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import Global Tee' );
+ $product->set_sku( 'IMPORT-TAX-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $data = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-TAX-L,Import Global Tee - L,IMPORT-TAX-PARENT,size-airr19,L,1,12\n",
+ 'import-unknown-taxonomy-value.csv'
+ );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ // Asserted in full so this cannot pass because the global attribute failed to resolve at all,
+ // which would report the "parent has no such attribute" refusal instead.
+ $this->assertSame(
+ sprintf(
+ 'A new variation cannot be created because "L" is not an option of the parent product\'s "%s" attribute.',
+ wc_attribute_label( $taxonomy )
+ ),
+ html_entity_decode( $data['skipped'][0]->get_error_message(), ENT_QUOTES ),
+ 'Expected the refusal to name the term and the global attribute, not an unresolved attribute'
+ );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-TAX-L' ), 'Expected no variation to be created for a term the parent does not offer' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ WC_Helper_Product::delete_attribute( $attribute_data['attribute_id'] );
+ }
+
+ /**
+ * @testdox Test that a variation row using a global attribute term the parent already offers is still created.
+ */
+ public function test_import_creates_new_variations_with_a_taxonomy_value_the_parent_offers() {
+ $attribute_data = WC_Helper_Product::create_attribute( 'size-airr19-ok', array( 'S', 'L' ) );
+ $taxonomy = $attribute_data['attribute_taxonomy'];
+
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_id( $attribute_data['attribute_id'] );
+ $attribute->set_name( $taxonomy );
+ $attribute->set_options( $attribute_data['term_ids'] );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import Global Tee' );
+ $product->set_sku( 'IMPORT-TAX-OK-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $data = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-TAX-OK-L,Import Global Tee - L,IMPORT-TAX-OK-PARENT,size-airr19-ok,L,1,12\n",
+ 'import-known-taxonomy-value.csv'
+ );
+
+ $this->assertCount( 1, $data['imported_variations'], 'Expected 1 imported variation, got ' . count( $data['imported_variations'] ) );
+ $this->assertEmpty( $data['skipped'], 'Expected 0 skipped products, got ' . count( $data['skipped'] ) );
+
+ $variation = wc_get_product( $data['imported_variations'][0] );
+ $this->assertEquals( array( $taxonomy => 'l' ), $variation->get_attributes() );
+
+ WC_Helper_Product::delete_product( $variation->get_id() );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ WC_Helper_Product::delete_attribute( $attribute_data['attribute_id'] );
+ }
+
+ /**
+ * @testdox Test that a variation row is created when an earlier parent row in the same CSV adds the global attribute term it uses.
+ */
+ public function test_import_creates_new_variations_when_an_earlier_parent_row_adds_the_taxonomy_value() {
+ $attribute_data = WC_Helper_Product::create_attribute( 'size-airr19-widen', array( 'S', 'L' ) );
+ $taxonomy = $attribute_data['attribute_taxonomy'];
+ $small = get_term_by( 'name', 'S', $taxonomy );
+
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_id( $attribute_data['attribute_id'] );
+ $attribute->set_name( $taxonomy );
+ $attribute->set_options( array( $small->term_id ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import Widen Tee' );
+ $product->set_sku( 'IMPORT-WIDEN-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ // Parent row first, as WooCommerce exports it: the variation must see the widened term list.
+ $data = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\n"
+ . "variable,IMPORT-WIDEN-PARENT,Import Widen Tee,,size-airr19-widen,\"S, L\",1,\n"
+ . "variation,IMPORT-WIDEN-L,Import Widen Tee - L,IMPORT-WIDEN-PARENT,size-airr19-widen,L,1,12\n",
+ 'import-parent-widens-taxonomy.csv'
+ );
+
+ $this->assertCount( 1, $data['imported_variations'], 'Expected the variation to be created after the parent row added the term' );
+ $this->assertEmpty( $data['skipped'], 'Expected 0 skipped products, got ' . count( $data['skipped'] ) );
+
+ WC_Helper_Product::delete_product( $data['imported_variations'][0] );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ WC_Helper_Product::delete_attribute( $attribute_data['attribute_id'] );
+ }
+
+ /**
+ * @testdox Test that a variation row naming an attribute the parent does not have is skipped, instead of being saved as an "any" variation.
+ */
+ public function test_import_skips_new_variations_with_an_attribute_the_parent_does_not_have() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import Tee' );
+ $product->set_sku( 'IMPORT-ATTR-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $data = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-ATTR-RED,Import Tee - Red,IMPORT-ATTR-PARENT,Colour,Red,0,12\n",
+ 'import-unknown-attribute.csv'
+ );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertSame(
+ 'A new variation cannot be created because the parent product has no "Colour" attribute.',
+ html_entity_decode( $data['skipped'][0]->get_error_message(), ENT_QUOTES ),
+ 'Expected the skip message to name the missing attribute'
+ );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-ATTR-RED' ), 'Expected no variation to be created for an attribute the parent does not have' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that a variation row is still created when the parent has the attribute but does not yet use it for variations.
+ */
+ public function test_import_creates_new_variations_when_the_parent_attribute_is_not_yet_used_for_variations() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M', 'L' ) );
+ $attribute->set_variation( false );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import Tee' );
+ $product->set_sku( 'IMPORT-PROMOTE-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $data = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-PROMOTE-L,Import Tee - L,IMPORT-PROMOTE-PARENT,Size,L,0,12\n",
+ 'import-promoted-attribute.csv'
+ );
+
+ $this->assertCount( 1, $data['imported_variations'], 'Expected the row to be created once the parent attribute is promoted for variations' );
+ $this->assertEmpty( $data['skipped'], 'Expected 0 skipped products, got ' . count( $data['skipped'] ) );
+
+ WC_Helper_Product::delete_product( $data['imported_variations'][0] );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that a variation row listed before the parent row that would widen the attribute is skipped, since rows are validated against the parent as it stands when the row is reached.
+ */
+ public function test_import_skips_new_variations_listed_before_the_parent_row_that_adds_their_value() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import Tee' );
+ $product->set_sku( 'IMPORT-ORDER-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ // The variation row comes first, so the parent still offers only S and M when it is validated.
+ $data = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\n"
+ . "variation,IMPORT-ORDER-L,Import Tee - L,IMPORT-ORDER-PARENT,Size,L,0,12\n"
+ . "variable,IMPORT-ORDER-PARENT,Import Tee,,Size,\"S, M, L\",0,\n",
+ 'import-variation-before-parent.csv'
+ );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected the variation row to be skipped, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertCount( 1, $data['updated'], 'Expected the parent row to still be updated' );
+
+ // The parent row widened the options, so the same import creates the variation on a second run.
+ $product = wc_get_product( $product->get_id() );
+ $this->assertEquals( array( 'S', 'M', 'L' ), $product->get_attributes()['size']->get_options() );
+
+ $rerun = $this->import_with_update_existing(
+ "Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\n"
+ . "variation,IMPORT-ORDER-L,Import Tee - L,IMPORT-ORDER-PARENT,Size,L,0,12\n"
+ . "variable,IMPORT-ORDER-PARENT,Import Tee,,Size,\"S, M, L\",0,\n",
+ 'import-variation-before-parent-rerun.csv'
+ );
+
+ $this->assertCount( 1, $rerun['imported_variations'], 'Expected the variation to be created on the second run' );
+ $this->assertEmpty( $rerun['skipped'], 'Expected 0 skipped products on the second run, got ' . count( $rerun['skipped'] ) );
+
+ WC_Helper_Product::delete_product( $rerun['imported_variations'][0] );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
/**
* @testdox Test that attributes with non-ASCII characters are correctly set to "Used for Variations" during import.
*/