Commit d142a9395d6 for woocommerce
commit d142a9395d683d2869afdef9c9509bcd607e2d64
Author: Justin P <228780+layoutd@users.noreply.github.com>
Date: Fri Oct 30 10:14:57 2026 +0100
Match CSV import rows by SKU when their ID does not exist on the site (#69991)
* Fall back to SKU matching when an updated CSV row's ID does not exist
* Add changelog entry for CSV import SKU fallback
* Fail variation rows that name a parent which cannot be found
* Refuse the SKU fallback across the variation boundary and share the row lookup
* Add changelog entry for variation rows with a missing parent
* Label failed import rows with the ID from the file
* Trim the row SKU before the update fallback looks it up
* Match each row with a repeated missing ID by its own SKU
* Refuse the SKU fallback when the row would save the product as another type
* Reword the mapping comment and reuse the update import test helper
* Share one temp-file helper across the SKU fallback import tests
* Check the variation parent before converting the product a row points at
* Label import result rows with the ID cell of that row
* Convert row cells to UTF-8 before the SKU fallback reads them
* Update CSV import changelog entries to cover all merchant-facing fixes
* Stop caching SKU matches as original ID mappings
* Reset the row label state after import and drop the redundant label test
* Make the pure type and encoding helpers static and lowercase the Type cell once
* Explain rows whose SKU fallback declined a product of another type
* Keep the parent for blank Parent cells and check untyped variation rows early
* Require an existing variable parent for variation rows in update imports
* Strip the export apostrophe from SKU cells and SKU references
* Compare the SKU fallback type with the matched product's own type
* Refine SKU fallback matching and SKU references after the final review
* Refuse variation rows for non-product posts and apply attributes when no parent is named
* Describe the no-SKU-column fix by the first column it used
* Look up row SKUs as stored before their cleaned form
* Require a parent when a variation row would convert an existing product
* Check the kept parent of variation rows with a blank Parent cell
* Drop the SKU fallback refusal messages and the CSV ID row label
* Clean the row SKU like the SKU column, tighten the changelog entries and assert the refused rows' errors
diff --git a/plugins/woocommerce/changelog/fix-csv-import-escaped-sku b/plugins/woocommerce/changelog/fix-csv-import-escaped-sku
new file mode 100644
index 00000000000..a1da04639e0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-escaped-sku
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep SKUs that start with =, +, - or @ intact when re-importing a product CSV export.
diff --git a/plugins/woocommerce/changelog/fix-csv-import-no-sku-column b/plugins/woocommerce/changelog/fix-csv-import-no-sku-column
new file mode 100644
index 00000000000..e7ddbefee0d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-no-sku-column
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop new-product CSV imports without a SKU column from saving the first column's value as the SKU.
diff --git a/plugins/woocommerce/changelog/fix-csv-import-sku-fallback b/plugins/woocommerce/changelog/fix-csv-import-sku-fallback
new file mode 100644
index 00000000000..a937f54533b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-sku-fallback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update the product matching a row's SKU when the row's ID is not on the site, and explain ID and SKU conflicts.
diff --git a/plugins/woocommerce/changelog/fix-csv-import-variation-missing-parent b/plugins/woocommerce/changelog/fix-csv-import-variation-missing-parent
new file mode 100644
index 00000000000..25ebaff84fe
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-variation-missing-parent
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fail product CSV variation rows whose parent is missing or cannot hold them, instead of moving or detaching the variation.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index 01ebe413501..9b0b81f47dd 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -307,6 +307,43 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
}
}
+ $post_type = empty( $data['id'] ) ? false : get_post_type( absint( $data['id'] ) );
+ $is_variation_row = isset( $data['type'] ) ? ProductType::VARIATION === $data['type'] : 'product_variation' === $post_type;
+
+ if ( $is_variation_row ) {
+ // get_product_object() would turn any post with this ID into a variation.
+ if ( $post_type && ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
+ return new WP_Error(
+ 'woocommerce_product_csv_importer_invalid_id',
+ /* translators: %d: product ID */
+ sprintf( __( 'Invalid product ID %d.', 'woocommerce' ), absint( $data['id'] ) ),
+ array(
+ 'id' => absint( $data['id'] ),
+ 'status' => 401,
+ )
+ );
+ }
+
+ // A blank or missing Parent keeps an existing variation's parent, as it does for a new or placeholder row,
+ // but an existing product turning into a variation needs one.
+ $converts_product = 'product' === $post_type && 'importing' !== get_post_status( absint( $data['id'] ) );
+
+ if ( ! $converts_product && '' === ( $data['parent_id'] ?? '' ) ) {
+ unset( $data['parent_id'] );
+
+ // The kept parent still has to hold the variation, or set_variation_data() would skip the row's attributes.
+ $current_parent = 'product_variation' === $post_type ? wp_get_post_parent_id( absint( $data['id'] ) ) : 0;
+ $parent = $current_parent ? $this->get_variation_parent( $current_parent ) : null;
+ } else {
+ // Check the parent before get_product_object() turns the post into a variation, so a row with an unusable parent changes nothing.
+ $parent = $this->get_variation_parent( $data['parent_id'] ?? null );
+ }
+
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+ }
+
$object = $this->get_product_object( $data );
$updating = false;
@@ -524,35 +561,50 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
}
/**
- * Set variation data.
+ * Get the parent product a variation row names.
*
- * @param WC_Product $variation Product instance.
- * @param array $data Item data.
- * @return WC_Product|WP_Error
- * @throws Exception If data cannot be set.
+ * An update import never fills in a placeholder parent, so there the parent must be an existing variable product.
+ *
+ * @param mixed $parent_id Parent ID from the row or the variation's current parent, or null when neither exists.
+ * @return WC_Product|WP_Error The parent, or an error when it cannot hold the variation.
*/
- protected function set_variation_data( &$variation, $data ) {
- $parent = false;
-
- // Check if parent exist.
- if ( isset( $data['parent_id'] ) ) {
- $parent = wc_get_product( $data['parent_id'] );
+ private function get_variation_parent( $parent_id ) {
+ $parent = null === $parent_id ? false : wc_get_product( $parent_id );
- if ( $parent ) {
- $variation->set_parent_id( $parent->get_id() );
- }
- }
-
- // Stop if parent does not exists.
if ( ! $parent ) {
return new WP_Error( 'woocommerce_product_importer_missing_variation_parent_id', __( 'Variation cannot be imported: Missing parent ID or parent does not exist yet.', 'woocommerce' ), array( 'status' => 401 ) );
}
- // Stop if parent is a product variation.
if ( $parent->is_type( ProductType::VARIATION ) ) {
return new WP_Error( 'woocommerce_product_importer_parent_set_as_variation', __( 'Variation cannot be imported: Parent product cannot be a product variation', 'woocommerce' ), array( 'status' => 401 ) );
}
+ if ( ! empty( $this->params['update_existing'] ) && ( ! $parent instanceof WC_Product_Variable || in_array( $parent->get_status(), array( 'importing', ProductStatus::TRASH ), true ) ) ) {
+ return new WP_Error( 'woocommerce_product_importer_variation_parent_not_variable', __( 'Variation cannot be imported: Parent product must be an existing variable product.', 'woocommerce' ), array( 'status' => 401 ) );
+ }
+
+ return $parent;
+ }
+
+ /**
+ * Set variation data.
+ *
+ * The parent is checked by process_item() before this runs, so the error is only returned to other callers.
+ *
+ * @param WC_Product $variation Product instance.
+ * @param array $data Item data.
+ * @return WP_Error|null Error when the parent cannot hold the variation.
+ * @throws Exception If data cannot be set.
+ */
+ protected function set_variation_data( &$variation, $data ) {
+ $parent = $this->get_variation_parent( $data['parent_id'] ?? ( $variation->get_parent_id() ? $variation->get_parent_id() : null ) );
+
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ $variation->set_parent_id( $parent->get_id() );
+
if ( isset( $data['raw_attributes'] ) ) {
$attributes = array();
$parent_attributes = $this->get_variation_parent_attributes( $data['raw_attributes'], $parent );
@@ -597,6 +649,8 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
$variation->set_attributes( $attributes );
}
+
+ return null;
}
/**
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 075eec53a92..dcdee146341 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -268,6 +268,8 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
return '';
}
+ $value = $this->unescape_data( $value );
+
// IDs are prefixed with id:.
if ( preg_match( '/^id:(\d+)$/', $value, $matches ) ) {
$id = intval( $matches[1] );
@@ -328,7 +330,9 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
* Parse the ID field.
*
* If we're not doing an update, create a placeholder product so mapping works
- * for rows following this one.
+ * for rows following this one. When updating, an ID that does not exist on
+ * this site falls back to the product matching the row's SKU, if the row can
+ * safely update it.
*
* @param string $value Field value.
*
@@ -350,10 +354,8 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
// Not updating? Make sure we have a new placeholder for this ID.
if ( ! $this->params['update_existing'] ) {
- $mapped_keys = $this->get_mapped_keys();
- $sku_column_index = absint( array_search( 'sku', $mapped_keys, true ) );
- $row_sku = isset( $this->raw_data[ $this->parsing_raw_data_index ][ $sku_column_index ] ) ? $this->raw_data[ $this->parsing_raw_data_index ][ $sku_column_index ] : '';
- $id_from_sku = $row_sku ? wc_get_product_id_by_sku( $row_sku ) : '';
+ $row_sku = $this->parse_sku_field( $this->get_row_value( 'sku' ) );
+ $id_from_sku = $this->get_product_id_by_row_sku();
// If row has a SKU, make sure placeholder was not made already.
if ( $id_from_sku ) {
@@ -375,9 +377,147 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
return $placeholder_id;
}
+ // An ID exported from another site may not exist here. Fall back to the row's SKU so the
+ // row updates the matching product instead of failing later with an invalid product error.
+ if ( ! in_array( get_post_type( $id ), array( 'product', 'product_variation' ), true ) ) {
+ $id_from_sku = $this->get_product_id_by_row_sku();
+
+ if ( $id_from_sku && $this->row_can_update_product( $id_from_sku ) ) {
+ return $id_from_sku;
+ }
+ }
+
return $id;
}
+ /**
+ * Find the product with the SKU of the row being parsed, as stored or as the import cleans it.
+ *
+ * SKUs saved through code can keep characters the import would clean, so the stored form is tried first.
+ *
+ * @return int Product ID, or 0 when no product has the SKU.
+ */
+ private function get_product_id_by_row_sku(): int {
+ $raw_sku = $this->unescape_data( $this->get_row_value( 'sku' ) );
+ $clean_sku = sanitize_text_field( $raw_sku );
+
+ if ( '' === $clean_sku ) {
+ return 0;
+ }
+
+ $id = $raw_sku !== $clean_sku ? wc_get_product_id_by_sku( $raw_sku ) : 0;
+
+ return $id ? $id : wc_get_product_id_by_sku( $clean_sku );
+ }
+
+ /**
+ * Get a column value of the row currently being parsed.
+ *
+ * @param string $key Mapped column key, such as 'sku' or 'type'.
+ * @return string Cell value converted to UTF-8, or an empty string when the file has no such column.
+ */
+ private function get_row_value( string $key ): string {
+ $column_index = array_search( $key, $this->get_mapped_keys(), true );
+
+ if ( false === $column_index ) {
+ return '';
+ }
+
+ return self::convert_to_utf8( (string) ( $this->raw_data[ $this->parsing_raw_data_index ][ $column_index ] ?? '' ) );
+ }
+
+ /**
+ * Convert a cell from the file to UTF-8 before it is parsed.
+ *
+ * @param string $value Raw cell value.
+ * @return string
+ */
+ private static function convert_to_utf8( $value ): string {
+ if ( ! function_exists( 'mb_convert_encoding' ) ) {
+ return wp_check_invalid_utf8( $value, true );
+ }
+
+ $encoding = mb_detect_encoding( $value, mb_detect_order(), true );
+
+ return (string) mb_convert_encoding( $value, 'UTF-8', $encoding ? $encoding : 'UTF-8' );
+ }
+
+ /**
+ * Whether the row can update the product its SKU matched.
+ *
+ * Another type would make get_product_object() convert the product. An `id:` reference in a row whose own ID
+ * comes from another site most likely points at a product of that site as well.
+ *
+ * @param int $product_id Existing product ID matched by SKU.
+ * @return bool
+ */
+ private function row_can_update_product( int $product_id ): bool {
+ if ( ! $this->row_type_matches_product( $product_id ) ) {
+ return false;
+ }
+
+ foreach ( array( 'parent_id', 'grouped_products', 'upsell_ids', 'cross_sell_ids' ) as $key ) {
+ foreach ( $this->explode_values( $this->get_row_value( $key ) ) as $reference ) {
+ if ( 0 === strpos( $reference, 'id:' ) ) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Whether the row would save the product matched by SKU as the type it already has.
+ *
+ * A file without a Type column cannot convert anything.
+ *
+ * @param int $product_id Existing product ID matched by SKU.
+ * @return bool
+ */
+ private function row_type_matches_product( int $product_id ): bool {
+ if ( ! in_array( 'type', $this->get_mapped_keys(), true ) ) {
+ return true;
+ }
+
+ $row_type = self::get_type_from_values( array_map( 'strtolower', $this->parse_comma_field( $this->get_row_value( 'type' ) ) ) );
+ $product_type = WC_Product_Factory::get_product_type( $product_id );
+
+ // Variations of extension types, such as subscription variations, report their own type.
+ if ( ProductType::VARIATION === $product_type && $row_type !== $product_type ) {
+ $product = wc_get_product( $product_id );
+ $product_type = $product ? $product->get_type() : $product_type;
+ }
+
+ return $product_type === $row_type;
+ }
+
+ /**
+ * Get the product type a parsed Type cell saves as.
+ *
+ * The cell also holds the virtual and downloadable flags. The first other value is the type, and an empty cell means simple.
+ *
+ * @param string[] $values Parsed Type cell values, lowercased.
+ * @return string
+ */
+ private static function get_type_from_values( array $values ): string {
+ $type = current( array_diff( $values, array( 'virtual', 'downloadable' ) ) );
+
+ return $type ? $type : ProductType::SIMPLE;
+ }
+
+ /**
+ * Parse a SKU cell, removing the apostrophe the exporter puts before a leading =, +, - or @.
+ *
+ * @since 11.4.0
+ *
+ * @param string $value Field value.
+ * @return string
+ */
+ public function parse_sku_field( $value ) {
+ return sanitize_text_field( $this->unescape_data( $value ) );
+ }
+
/**
* Parse relative comma-delineated field and return product ID.
*
@@ -955,6 +1095,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
'tag_ids_spaces' => array( $this, 'parse_tags_spaces_field' ),
'shipping_class_id' => array( $this, 'parse_shipping_class_field' ),
'images' => array( $this, 'parse_images_field' ),
+ 'sku' => array( $this, 'parse_sku_field' ),
'parent_id' => array( $this, 'parse_relative_field' ),
'grouped_products' => array( $this, 'parse_relative_comma_field' ),
'upsell_ids' => array( $this, 'parse_relative_comma_field' ),
@@ -1065,13 +1206,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
$data['type'] = array_map( 'strtolower', $data['type'] );
$data['virtual'] = in_array( 'virtual', $data['type'], true );
$data['downloadable'] = in_array( 'downloadable', $data['type'], true );
-
- // Convert type to string.
- $data['type'] = current( array_diff( $data['type'], array( 'virtual', 'downloadable' ) ) );
-
- if ( ! $data['type'] ) {
- $data['type'] = ProductType::SIMPLE;
- }
+ $data['type'] = self::get_type_from_values( $data['type'] );
}
// Status is mapped from a special published field.
@@ -1228,7 +1363,6 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
protected function set_parsed_data() {
$parse_functions = $this->get_formatting_callback();
$mapped_keys = $this->get_mapped_keys();
- $use_mb = function_exists( 'mb_convert_encoding' );
// Parse the data.
foreach ( $this->raw_data as $row_index => $row ) {
@@ -1249,19 +1383,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
continue;
}
- // Convert UTF8.
- if ( $use_mb ) {
- $encoding = mb_detect_encoding( $value, mb_detect_order(), true );
- if ( $encoding ) {
- $value = mb_convert_encoding( $value, 'UTF-8', $encoding );
- } else {
- $value = mb_convert_encoding( $value, 'UTF-8', 'UTF-8' );
- }
- } else {
- $value = wp_check_invalid_utf8( $value, true );
- }
-
- $data[ $mapped_keys[ $id ] ] = call_user_func( $parse_functions[ $id ], $value );
+ $data[ $mapped_keys[ $id ] ] = call_user_func( $parse_functions[ $id ], self::convert_to_utf8( $value ) );
}
/**
@@ -1481,6 +1603,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
$global_unique_id = $this->normalize_global_unique_id( $parsed_data['global_unique_id'] ?? '' );
$id_exists = false;
$sku_exists = false;
+ $id_from_sku = 0;
if ( $update_existing && ! $id && ! $sku && '' !== $global_unique_id ) {
$id = $this->match_product_id_by_global_unique_id( $global_unique_id, $parsed_data['type'] ?? '' );
@@ -1502,7 +1625,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
}
if ( $sku ) {
- $id_from_sku = wc_get_product_id_by_sku( $sku );
+ $id_from_sku = absint( wc_get_product_id_by_sku( $sku ) );
$product = $id_from_sku ? wc_get_product( $id_from_sku ) : false;
$sku_exists = $product && 'importing' !== $product->get_status();
}
@@ -1577,6 +1700,9 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
$result = $this->process_item( $parsed_data );
if ( is_wp_error( $result ) ) {
+ if ( $id_exists && $sku_exists && $id !== $id_from_sku ) {
+ $result = $this->add_sku_conflict_hint( $result, $id, (string) $sku, $id_from_sku );
+ }
$result->add_data( array( 'row' => $this->get_row_id( $parsed_data ) ) );
$data['failed'][] = $result;
} elseif ( $result['updated'] ) {
@@ -1597,4 +1723,32 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
return $data;
}
+
+ /**
+ * Explain a failed row whose ID and SKU point at two different products.
+ *
+ * A failure about the row's own type is left as it is.
+ *
+ * @param WP_Error $error Error returned when processing the row.
+ * @param int $id Existing product ID the row's ID column matched.
+ * @param string $sku SKU column value of the row.
+ * @param int $id_from_sku Product ID that already uses the row's SKU.
+ * @return WP_Error
+ */
+ private function add_sku_conflict_hint( WP_Error $error, int $id, string $sku, int $id_from_sku ): WP_Error {
+ if ( 'woocommerce_product_importer_invalid_type' === $error->get_error_code() ) {
+ return $error;
+ }
+
+ $message = sprintf(
+ /* translators: 1: original error message, 2: product ID matched by the row's ID, 3: SKU, 4: product ID that already uses the SKU */
+ __( '%1$s The ID of this row matches product #%2$d, but its SKU "%3$s" already belongs to product #%4$d. Point the ID and SKU at the same product, or remove one of them.', 'woocommerce' ),
+ $error->get_error_message(),
+ $id,
+ $sku,
+ $id_from_sku
+ );
+
+ return new WP_Error( $error->get_error_code(), $message, $error->get_error_data() );
+ }
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index fac0ae6af89..a931dd08af2 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19722,12 +19722,6 @@ parameters:
count: 1
path: includes/import/abstract-wc-product-importer.php
- -
- message: '#^Method WC_Product_Importer\:\:set_variation_data\(\) should return WC_Product\|WP_Error but return statement is missing\.$#'
- identifier: return.missing
- count: 2
- path: includes/import/abstract-wc-product-importer.php
-
-
message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(string\)\: bool\)\|null, ''strlen'' 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 de6b9ff619e..5f925f4d2ff 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
@@ -2452,6 +2452,651 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
}
}
+ /**
+ * Import temporary CSV contents, by default with "Update existing products" enabled.
+ *
+ * @param string $csv CSV contents.
+ * @param array|null $mapping Column mapping, or null for ID, Type, SKU and Name.
+ * @param bool $update_existing Whether existing products are updated.
+ * @return array Import results.
+ */
+ private function import_csv_rows( string $csv, ?array $mapping = null, bool $update_existing = true ): array {
+ $file = get_temp_dir() . 'product-import-' . wp_generate_uuid4() . '.csv';
+ file_put_contents( $file, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Temporary CSV fixture.
+
+ try {
+ $importer = new WC_Product_CSV_Importer(
+ $file,
+ array(
+ 'parse' => true,
+ 'update_existing' => $update_existing,
+ 'mapping' => $mapping ?? array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ ),
+ )
+ );
+
+ return $importer->import();
+ } finally {
+ wp_delete_file( $file );
+ }
+ }
+
+ /**
+ * @testdox Should update the product matched by SKU when a row's ID does not exist on the site and existing products are being updated.
+ */
+ public function test_import_falls_back_to_sku_when_id_does_not_exist_40274(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sku( 'IMPORT-40274-A' );
+ $product->set_name( 'Original name' );
+ $product->save();
+
+ $missing_id = $product->get_id() + 1000;
+ $csv = "ID,Type,SKU,Name\n{$missing_id},simple,IMPORT-40274-A,Updated by SKU\n" . ( $missing_id + 1 ) . ",simple,IMPORT-40274-NONE,Row with no match\n";
+
+ $data = $this->import_csv_rows( $csv );
+
+ $this->assertSame( array( $product->get_id() ), $data['updated'], 'The row with a missing ID updates the product matched by SKU' );
+ $this->assertEmpty( $data['failed'], 'A row whose ID is missing but whose SKU exists must not fail' );
+ $this->assertEmpty( $data['imported'], 'An update import never creates products' );
+ $this->assertCount( 1, $data['skipped'], 'A row matching neither ID nor SKU is still skipped' );
+ $this->assertSame( 'No matching product exists to update.', $data['skipped'][0]->get_error_message(), 'The skipped row says nothing matched' );
+ $this->assertSame( 'Updated by SKU', wc_get_product( $product->get_id() )->get_name(), 'The matched product gets the row\'s data' );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-40274-NONE' ), 'The unmatched row creates no product' );
+ }
+
+ /**
+ * @testdox Should explain the conflict when a row's ID and SKU point at two different existing products.
+ */
+ public function test_import_explains_id_and_sku_conflict_40274(): void {
+ $matched_by_id = WC_Helper_Product::create_simple_product();
+ $matched_by_id->set_sku( 'IMPORT-40274-B' );
+ $matched_by_id->save();
+
+ $owns_sku = WC_Helper_Product::create_simple_product();
+ $owns_sku->set_sku( 'IMPORT-40274-C' );
+ $owns_sku->save();
+
+ $csv = "ID,Type,SKU,Name\n{$matched_by_id->get_id()},simple,IMPORT-40274-C,Conflicting row\n";
+
+ $data = $this->import_csv_rows( $csv );
+
+ $this->assertEmpty( $data['updated'], 'A conflicting row updates nothing' );
+ $this->assertCount( 1, $data['failed'], 'A conflicting row fails' );
+ $message = $data['failed'][0]->get_error_message();
+ $this->assertStringContainsString( 'Invalid or duplicated SKU.', $message, 'The original error is kept' );
+ $this->assertStringContainsString( sprintf( 'matches product #%d', $matched_by_id->get_id() ), $message, 'The hint names the product matched by ID' );
+ $this->assertStringContainsString( sprintf( 'belongs to product #%d', $owns_sku->get_id() ), $message, 'The hint names the product that owns the SKU' );
+ $this->assertSame( 'IMPORT-40274-B', wc_get_product( $matched_by_id->get_id() )->get_sku(), 'The product matched by ID keeps its own SKU' );
+ }
+
+ /**
+ * @testdox Should update a variation matched by SKU when its ID does not exist and its parent resolves.
+ */
+ public function test_import_falls_back_to_sku_for_variation_rows_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_sku( 'IMPORT-40274-PARENT' );
+ $parent->save();
+ $variation = wc_get_product( $parent->get_children()[0] );
+ $variation->set_sku( 'IMPORT-40274-VAR' );
+ $variation->save();
+
+ $missing_id = $variation->get_id() + 1000;
+ $csv = "ID,Type,SKU,Regular price,Parent\n{$missing_id},variation,IMPORT-40274-VAR,42,IMPORT-40274-PARENT\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Regular price' => 'regular_price',
+ 'Parent' => 'parent_id',
+ )
+ );
+
+ $this->assertSame( array( $variation->get_id() ), $data['updated'], 'The variation row updates the variation matched by SKU' );
+ $this->assertEmpty( $data['failed'], 'A variation row with a valid parent does not fail' );
+ $updated = wc_get_product( $variation->get_id() );
+ $this->assertSame( '42', $updated->get_regular_price(), 'The matched variation gets the row\'s price' );
+ $this->assertSame( $parent->get_id(), $updated->get_parent_id(), 'The variation must stay attached to its parent' );
+ }
+
+ /**
+ * @testdox Should fail a variation row whose parent cannot be found instead of detaching the variation.
+ */
+ public function test_import_fails_variation_row_with_missing_parent_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_sku( 'IMPORT-40274-PARENT-B' );
+ $parent->save();
+ $variation = wc_get_product( $parent->get_children()[0] );
+ $variation->set_sku( 'IMPORT-40274-VAR-B' );
+ $variation->save();
+ $original_name = $variation->get_name();
+
+ $missing_id = $variation->get_id() + 1000;
+ $csv = "ID,Type,SKU,Name,Parent\n{$missing_id},variation,IMPORT-40274-VAR-B,Renamed variation,id:" . ( $missing_id + 1 ) . "\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ )
+ );
+
+ $this->assertEmpty( $data['updated'], 'A row with a missing parent updates nothing' );
+ $this->assertCount( 1, $data['failed'], 'A row with a missing parent fails' );
+ $this->assertStringContainsString( 'parent does not exist', $data['failed'][0]->get_error_message(), 'The failure names the missing parent' );
+ $unchanged = wc_get_product( $variation->get_id() );
+ $this->assertSame( $parent->get_id(), $unchanged->get_parent_id(), 'A failed row must not change the parent' );
+ $this->assertSame( $original_name, $unchanged->get_name(), 'A failed row must not change the name' );
+ }
+
+ /**
+ * @testdox Should not match a row to a product of the other post type by SKU, so nothing is converted.
+ */
+ public function test_import_sku_fallback_refuses_variation_to_simple_match_40274(): void {
+ $simple = WC_Helper_Product::create_simple_product();
+ $simple->set_sku( 'IMPORT-40274-SIMPLE' );
+ $simple->save();
+
+ $missing_id = $simple->get_id() + 1000;
+ $csv = "ID,Type,SKU,Name\n{$missing_id},variation,IMPORT-40274-SIMPLE,Should stay a simple product\n";
+
+ $data = $this->import_csv_rows( $csv );
+
+ $this->assertEmpty( $data['updated'], 'A variation row must not update a simple product' );
+ $this->assertCount( 1, $data['failed'], 'A variation row for a simple product fails' );
+ $this->assertStringContainsString( 'Invalid or duplicated SKU.', $data['failed'][0]->get_error_message(), 'The row fails on the SKU the simple product owns' );
+ $this->assertSame( 'product', get_post_type( $simple->get_id() ), 'The simple product must not be converted to a variation' );
+ }
+
+ /**
+ * @testdox Should not match by SKU when the type the row would save differs from the matched product's type.
+ */
+ public function test_import_sku_fallback_refuses_other_saved_types_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_sku( 'IMPORT-40274-TYPE-PARENT' );
+ $parent->save();
+ $variation = wc_get_product( $parent->get_children()[0] );
+ $variation->set_sku( 'IMPORT-40274-TYPE-VAR' );
+ $variation->save();
+
+ $missing_id = $variation->get_id() + 1000;
+ $csv = "ID,Type,SKU,Name\n" .
+ "{$missing_id},,IMPORT-40274-TYPE-VAR,Empty type saves as simple\n" .
+ ( $missing_id + 1 ) . ",simple,IMPORT-40274-TYPE-PARENT,Simple row for a variable product\n";
+
+ $data = $this->import_csv_rows( $csv );
+
+ $this->assertEmpty( $data['updated'], 'Neither row may update the product matched by SKU' );
+ $this->assertCount( 2, $data['failed'], 'Both rows fail' );
+ $this->assertSame( 'Invalid product.', $data['failed'][0]->get_error_message(), 'The empty-type row fails on its missing ID' );
+ $this->assertSame( 'Invalid product.', $data['failed'][1]->get_error_message(), 'The simple row fails on its missing ID' );
+ $this->assertSame( 'product_variation', get_post_type( $variation->get_id() ), 'The variation must not be converted to a simple product' );
+ $this->assertSame( ProductType::VARIABLE, WC_Product_Factory::get_product_type( $parent->get_id() ), 'The variable product must not be converted to a simple product' );
+ }
+
+ /**
+ * @testdox Should not use the first column as the SKU when the file has no SKU column.
+ */
+ public function test_import_without_sku_column_does_not_use_first_column_as_sku_40274(): void {
+ $csv = "ID,Type,Name\n987654,simple,Product without a SKU column\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'Name' => 'name',
+ ),
+ false
+ );
+
+ $this->assertCount( 1, $data['imported'], 'The row is imported as a new product' );
+ $this->assertSame( '', wc_get_product( $data['imported'][0] )->get_sku(), 'The ID must not be saved as the SKU' );
+ }
+
+ /**
+ * @testdox Should match by SKU when the SKU cell has surrounding spaces, as the saved SKU is trimmed too.
+ */
+ public function test_import_sku_fallback_trims_the_row_sku_40274(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sku( 'IMPORT-40274-TRIM' );
+ $product->save();
+
+ $missing_id = $product->get_id() + 1000;
+ $csv = "ID,Type,SKU,Name\n{$missing_id},simple,\" IMPORT-40274-TRIM \",Updated by trimmed SKU\n";
+
+ $data = $this->import_csv_rows( $csv );
+
+ $this->assertSame( array( $product->get_id() ), $data['updated'], 'The padded SKU matches the product' );
+ $this->assertSame( 'Updated by trimmed SKU', wc_get_product( $product->get_id() )->get_name(), 'The matched product gets the row\'s data' );
+ }
+
+ /**
+ * @testdox Should match each row by its own SKU when the file repeats a missing ID, so one product is not updated twice.
+ */
+ public function test_import_sku_fallback_does_not_reuse_mapping_for_repeated_ids_40274(): void {
+ $product_a = WC_Helper_Product::create_simple_product();
+ $product_a->set_sku( 'IMPORT-40274-REPEAT-A' );
+ $product_a->set_name( 'Original A' );
+ $product_a->save();
+
+ $product_c = WC_Helper_Product::create_simple_product();
+ $product_c->set_sku( 'IMPORT-40274-REPEAT-C' );
+ $product_c->save();
+ wp_set_object_terms( $product_c->get_id(), 'import-40274-kept-tag', 'product_tag' );
+
+ $id_one = $product_c->get_id() + 1000;
+ $id_two = $id_one + 1;
+ $csv = "ID,Type,SKU,Name\n" .
+ "{$id_one},simple,IMPORT-40274-REPEAT-A,Title A\n" .
+ "{$id_one},simple,IMPORT-40274-REPEAT-B,Title B\n" .
+ "{$id_two},simple,IMPORT-40274-REPEAT-C,Title C\n" .
+ "{$id_two},variation,IMPORT-40274-REPEAT-C,Title C\n";
+
+ $data = $this->import_csv_rows( $csv );
+
+ $this->assertSame( array( $product_a->get_id(), $product_c->get_id() ), $data['updated'], 'Only the rows whose own SKU matches update a product' );
+ $this->assertCount( 1, $data['skipped'], 'The repeated ID with an unknown SKU is skipped' );
+ $this->assertSame( 'No matching product exists to update.', $data['skipped'][0]->get_error_message(), 'The unknown SKU matches nothing' );
+ $this->assertCount( 1, $data['failed'], 'The repeated ID with a variation type is refused' );
+ $updated_a = wc_get_product( $product_a->get_id() );
+ $this->assertSame( 'Title A', $updated_a->get_name(), 'Product A keeps the first row\'s name' );
+ $this->assertSame( 'IMPORT-40274-REPEAT-A', $updated_a->get_sku(), 'Product A keeps its SKU' );
+ $this->assertSame( 'product', get_post_type( $product_c->get_id() ), 'The simple product must not be converted to a variation' );
+ $this->assertTrue( has_term( 'import-40274-kept-tag', 'product_tag', $product_c->get_id() ), 'The simple product keeps its tags' );
+ }
+
+ /**
+ * @testdox Should fail a variation row with an unusable parent, or an ID that is not a product, before converting the post its ID points at.
+ */
+ public function test_import_checks_variation_parent_before_converting_the_product_40274(): void {
+ $simple = WC_Helper_Product::create_simple_product();
+ $simple->set_sku( 'IMPORT-40274-PARENT-CHECK' );
+ $simple->save();
+ wp_set_object_terms( $simple->get_id(), 'import-40274-parent-check-tag', 'product_tag' );
+ $blank_parent = WC_Helper_Product::create_simple_product();
+ $page = self::factory()->post->create( array( 'post_type' => 'page' ) );
+
+ $csv = "ID,Type,SKU,Name,Parent\n" .
+ "{$simple->get_id()},variation,,Should stay simple,id:" . ( $simple->get_id() + 1000 ) . "\n" .
+ "{$blank_parent->get_id()},variation,,Should stay simple too,\n" .
+ "{$page},variation,IMPORT-40274-PARENT-CHECK,Should stay a page,\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ )
+ );
+
+ $this->assertCount( 3, $data['failed'], 'Each variation row fails' );
+ $this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][0]->get_error_code(), 'The row fails on its missing parent' );
+ $this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][1]->get_error_code(), 'A product turning into a variation needs a parent' );
+ $this->assertSame( 'woocommerce_product_csv_importer_invalid_id', $data['failed'][2]->get_error_code(), 'A post that is not a product is refused' );
+ $this->assertSame( 'product', get_post_type( $simple->get_id() ), 'The product must not be converted to a variation' );
+ $this->assertSame( 'product', get_post_type( $blank_parent->get_id() ), 'The product with a blank Parent cell must not be converted' );
+ $this->assertSame( 'page', get_post_type( $page ), 'The page must not be converted to a variation' );
+ $this->assertTrue( has_term( 'import-40274-parent-check-tag', 'product_tag', $simple->get_id() ), 'The product keeps its tags' );
+ }
+
+ /**
+ * @testdox Should fail an update row whose parent is not an existing variable product instead of moving the variation.
+ */
+ public function test_import_update_requires_an_existing_variable_parent_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_sku( 'IMPORT-40274-REAL-PARENT' );
+ $parent->save();
+ $children = $parent->get_children();
+
+ $simple = WC_Helper_Product::create_simple_product();
+ $simple->set_sku( 'IMPORT-40274-SIMPLE-PARENT' );
+ $simple->save();
+
+ $placeholder = WC_Helper_Product::create_variation_product();
+ wp_update_post(
+ array(
+ 'ID' => $placeholder->get_id(),
+ 'post_status' => 'importing',
+ )
+ );
+
+ $first = wc_get_product( $children[0] );
+ $second = wc_get_product( $children[1] );
+ $third = wc_get_product( $children[2] );
+ $csv = "ID,Type,SKU,Name,Parent\n" .
+ "{$first->get_id()},variation,{$first->get_sku()},Unknown parent,IMPORT-40274-NO-SUCH-PARENT\n" .
+ "{$second->get_id()},variation,{$second->get_sku()},Simple parent,IMPORT-40274-SIMPLE-PARENT\n" .
+ "{$third->get_id()},variation,{$third->get_sku()},Importing parent,id:{$placeholder->get_id()}\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ )
+ );
+
+ $this->assertEmpty( $data['updated'], 'No variation may be moved' );
+ $this->assertCount( 3, $data['failed'], 'Each row fails' );
+ $this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][0]->get_error_code(), 'An unknown parent SKU, resolved to a placeholder, is refused' );
+ $this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][1]->get_error_code(), 'A simple parent is refused' );
+ $this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][2]->get_error_code(), 'A variable parent still being imported is refused' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $first->get_id() )->get_parent_id(), 'The first variation keeps its parent' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $second->get_id() )->get_parent_id(), 'The second variation keeps its parent' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $third->get_id() )->get_parent_id(), 'The third variation keeps its parent' );
+ }
+
+ /**
+ * @testdox Should keep a variation's parent and apply its attributes when its Parent cell is blank, and check the parent of rows without a Type column.
+ */
+ public function test_import_blank_parent_keeps_parent_and_untyped_rows_are_checked_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $children = $parent->get_children();
+ $csv = "ID,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global\n" .
+ "{$children[0]},Blank parent,,size,large,1\n" .
+ "{$children[1]},Missing parent,id:" . ( max( $children ) + 1000 ) . ",size,large,1\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Attribute 1 global' => 'attributes:taxonomy1',
+ )
+ );
+
+ $this->assertSame( array( $children[0] ), $data['updated'], 'The row with a blank Parent cell is updated' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $children[0] )->get_parent_id(), 'A blank Parent cell keeps the parent' );
+ $this->assertSame( 'large', wc_get_product( $children[0] )->get_attribute( 'pa_size' ), 'The row\'s attributes still apply' );
+ $this->assertCount( 1, $data['failed'], 'The row naming a missing parent fails' );
+ $this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][0]->get_error_code(), 'The untyped row fails on its parent' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $children[1] )->get_parent_id(), 'The failed row keeps the parent' );
+ }
+
+ /**
+ * @testdox Should not add the SKU hint to a failure about the row's own type.
+ */
+ public function test_import_does_not_add_sku_hint_to_invalid_type_errors_40274(): void {
+ $matched_by_id = WC_Helper_Product::create_simple_product();
+ $owns_sku = WC_Helper_Product::create_simple_product();
+ $owns_sku->set_sku( 'IMPORT-40274-HINT' );
+ $owns_sku->save();
+
+ $data = $this->import_csv_rows( "ID,Type,SKU,Name\n{$matched_by_id->get_id()},not-a-type,IMPORT-40274-HINT,Bad type\n" );
+
+ $this->assertCount( 1, $data['failed'], 'A row with an unknown type fails' );
+ $this->assertSame( 'Invalid product type.', $data['failed'][0]->get_error_message(), 'The type error is shown on its own' );
+ }
+
+ /**
+ * @testdox Should match SKUs and SKU references that the exporter escaped with a leading apostrophe.
+ */
+ public function test_import_unescapes_exported_skus_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_sku( '-IMPORT-40274-DASH-PARENT' );
+ $parent->save();
+ $variation = wc_get_product( $parent->get_children()[0] );
+ $variation->set_sku( '=IMPORT-40274-DASH-VAR' );
+ $variation->save();
+
+ $missing_id = $variation->get_id() + 1000;
+ $csv = "ID,Type,SKU,Regular price,Parent\n{$missing_id},variation,'=IMPORT-40274-DASH-VAR,42,'-IMPORT-40274-DASH-PARENT\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Regular price' => 'regular_price',
+ 'Parent' => 'parent_id',
+ )
+ );
+
+ $this->assertSame( array( $variation->get_id() ), $data['updated'], 'The escaped SKU matches the variation' );
+ $updated = wc_get_product( $variation->get_id() );
+ $this->assertSame( '=IMPORT-40274-DASH-VAR', $updated->get_sku(), 'The SKU is saved without the apostrophe' );
+ $this->assertSame( $parent->get_id(), $updated->get_parent_id(), 'The escaped parent SKU resolves to the parent' );
+ }
+
+ /**
+ * @testdox Should match by SKU a variation whose class reports an extension's variation subtype.
+ */
+ public function test_import_sku_fallback_matches_extension_variation_subtypes_40274(): void {
+ $subtype_class = get_class(
+ new class() extends WC_Product_Variation {
+ /**
+ * Get the extension's variation subtype.
+ *
+ * @return string
+ */
+ public function get_type() {
+ return 'import-40274-sub-variation';
+ }
+ }
+ );
+
+ $parent = WC_Helper_Product::create_variation_product();
+ $variation_id = $parent->get_children()[0];
+ $variation = wc_get_product( $variation_id );
+ $variation->set_sku( 'IMPORT-40274-SUBTYPE' );
+ $variation->save();
+
+ $use_subtype = function ( $classname, $product_type, $context, $product_id ) use ( $subtype_class, $variation_id ) {
+ return ( 'import-40274-sub-variation' === $product_type || $variation_id === (int) $product_id ) ? $subtype_class : $classname;
+ };
+ $export_type = function ( $types ) {
+ return $types + array( 'import-40274-sub-variation' => 'Subtype variations' );
+ };
+ $data_store = function ( $stores ) {
+ return $stores + array( 'product-import-40274-sub-variation' => 'WC_Product_Variation_Data_Store_CPT' );
+ };
+ add_filter( 'woocommerce_product_class', $use_subtype, 10, 4 );
+ add_filter( 'woocommerce_exporter_product_types', $export_type );
+ add_filter( 'woocommerce_data_stores', $data_store );
+
+ $data = $this->import_csv_rows( "ID,Type,SKU,Name\n" . ( $variation_id + 1000 ) . ",import-40274-sub-variation,IMPORT-40274-SUBTYPE,Subtype row\n" );
+
+ $this->assertSame( array( $variation_id ), $data['updated'], 'The subtype row updates the variation matched by SKU' );
+ $this->assertSame( 'product_variation', get_post_type( $variation_id ), 'The variation stays a variation' );
+ }
+
+ /**
+ * @testdox Should not match by SKU a row that refers to other products by IDs from the site its own ID came from.
+ */
+ public function test_import_sku_fallback_refuses_rows_with_id_references_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $parent->get_children()[0] );
+ $variation->set_sku( 'IMPORT-40274-ID-REF' );
+ $variation->save();
+ $unrelated = WC_Helper_Product::create_variation_product();
+
+ $csv = "ID,Type,SKU,Name,Parent\n" . ( $unrelated->get_id() + 1000 ) . ",variation,IMPORT-40274-ID-REF,From another site,id:{$unrelated->get_id()}\n";
+
+ $data = $this->import_csv_rows(
+ $csv,
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ )
+ );
+
+ $this->assertEmpty( $data['updated'], 'The row must not update the variation matched by SKU' );
+ $this->assertCount( 1, $data['failed'], 'The row fails' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $variation->get_id() )->get_parent_id(), 'The variation keeps its parent' );
+ }
+
+ /**
+ * @testdox Should match a variation by SKU when the file has no Type column.
+ */
+ public function test_import_sku_fallback_without_type_column_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $parent->get_children()[0] );
+ $variation->set_sku( 'IMPORT-40274-NO-TYPE' );
+ $variation->save();
+
+ $data = $this->import_csv_rows(
+ "ID,SKU,Regular price\n" . ( $variation->get_id() + 1000 ) . ",IMPORT-40274-NO-TYPE,31\n",
+ array(
+ 'ID' => 'id',
+ 'SKU' => 'sku',
+ 'Regular price' => 'regular_price',
+ )
+ );
+
+ $this->assertSame( array( $variation->get_id() ), $data['updated'], 'The row updates the variation matched by SKU' );
+ $this->assertSame( '31', wc_get_product( $variation->get_id() )->get_regular_price(), 'The variation gets the row\'s price' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $variation->get_id() )->get_parent_id(), 'The variation keeps its parent' );
+ }
+
+ /**
+ * @testdox Should resolve a SKU reference to a product whose stored SKU keeps characters the import would clean.
+ */
+ public function test_import_resolves_references_to_raw_stored_skus_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_sku( 'IMPORT-40274 RAW' );
+ $parent->save();
+ $variation_id = $parent->get_children()[0];
+
+ $data = $this->import_csv_rows(
+ "ID,Type,Parent,Regular price\n{$variation_id},variation,IMPORT-40274 RAW,29\n",
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'Parent' => 'parent_id',
+ 'Regular price' => 'regular_price',
+ )
+ );
+
+ $this->assertSame( array( $variation_id ), $data['updated'], 'The row with the raw parent SKU is updated' );
+ $this->assertSame( $parent->get_id(), wc_get_product( $variation_id )->get_parent_id(), 'The reference resolves to the parent' );
+ }
+
+ /**
+ * @testdox Should find a product by its stored SKU when creating products, even if the import would clean that SKU.
+ */
+ public function test_import_create_finds_products_by_raw_stored_sku_40274(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sku( 'IMPORT-40274 STORED' );
+ $product->save();
+
+ $data = $this->import_csv_rows( "ID,Type,SKU,Name\n" . ( $product->get_id() + 1000 ) . ",simple,IMPORT-40274 STORED,Not a duplicate\n", null, false );
+
+ $this->assertEmpty( $data['imported'], 'No duplicate product is created' );
+ $this->assertCount( 1, $data['skipped'], 'The row is skipped' );
+ $this->assertSame( 'A product with this ID already exists.', $data['skipped'][0]->get_error_message(), 'The row resolves to the existing product' );
+ }
+
+ /**
+ * @testdox Should fail a variation row for an existing product when the file has no Parent column.
+ */
+ public function test_import_variation_row_without_parent_column_does_not_convert_a_product_40274(): void {
+ $simple = WC_Helper_Product::create_simple_product();
+
+ $data = $this->import_csv_rows(
+ "ID,Type,Name\n{$simple->get_id()},variation,Should stay simple\n",
+ array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'Name' => 'name',
+ )
+ );
+
+ $this->assertCount( 1, $data['failed'], 'The row fails' );
+ $this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][0]->get_error_code(), 'The row fails on its missing parent' );
+ $this->assertSame( 'product', get_post_type( $simple->get_id() ), 'The product must not be converted to a variation' );
+ }
+
+ /**
+ * @testdox Should not match by SKU a product whose stored type is not registered, so the type is not rewritten.
+ */
+ public function test_import_sku_fallback_keeps_unregistered_stored_types_40274(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sku( 'IMPORT-40274-UNREGISTERED' );
+ $product->save();
+ wp_set_object_terms( $product->get_id(), 'import-40274-unregistered', 'product_type' );
+
+ $data = $this->import_csv_rows( "ID,Type,SKU,Name\n" . ( $product->get_id() + 1000 ) . ",simple,IMPORT-40274-UNREGISTERED,Renamed\n" );
+
+ $this->assertEmpty( $data['updated'], 'The row must not update the product' );
+ $this->assertTrue( has_term( 'import-40274-unregistered', 'product_type', $product->get_id() ), 'The stored type is kept' );
+ }
+
+ /**
+ * @testdox Should let code outside the importer call the SKU formatting callback.
+ */
+ public function test_import_sku_callback_is_callable_from_outside_the_importer_40274(): void {
+ $wrap_callbacks = function ( $callbacks ) {
+ return array_map(
+ function ( $callback ) {
+ return function ( $value ) use ( $callback ) {
+ return call_user_func( $callback, $value );
+ };
+ },
+ $callbacks
+ );
+ };
+ add_filter( 'woocommerce_product_importer_formatting_callbacks', $wrap_callbacks );
+
+ $data = $this->import_csv_rows( "Type,SKU,Name\nsimple,'-IMPORT-40274-WRAPPED,Wrapped callbacks\n", null, false );
+
+ $this->assertCount( 1, $data['imported'], 'The row is imported' );
+ $this->assertSame( '-IMPORT-40274-WRAPPED', wc_get_product( $data['imported'][0] )->get_sku(), 'The SKU callback ran' );
+ }
+
+ /**
+ * @testdox Should fail a variation row with a blank Parent cell when the variation's current parent can no longer hold it.
+ */
+ public function test_import_blank_parent_checks_the_current_parent_40274(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $variation_id = $parent->get_children()[0];
+ wp_update_post(
+ array(
+ 'ID' => $parent->get_id(),
+ 'post_status' => 'trash',
+ )
+ );
+
+ $data = $this->import_csv_rows(
+ "ID,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global\n{$variation_id},Trashed parent,,size,large,1\n",
+ array(
+ 'ID' => 'id',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Attribute 1 global' => 'attributes:taxonomy1',
+ )
+ );
+
+ $this->assertEmpty( $data['updated'], 'The row must not be reported as updated' );
+ $this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][0]->get_error_code(), 'The row fails on its trashed parent' );
+ $this->assertSame( 'small', wc_get_product( $variation_id )->get_attribute( 'pa_size' ), 'The attributes are unchanged' );
+ }
+
/**
* @testdox Should keep double-quoted values together and leave other values untouched when splitting comma-separated fields.
* @dataProvider provider_parse_comma_field_quoted_values