Commit 4f5b37676c5 for woocommerce

commit 4f5b37676c59af7ce07eaff83d90e4a33ff59a7e
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Thu Oct 29 16:11:51 2026 +0200

    Fix CSV import ending sales a day early for date-only end dates (#66634)

    * feat: add end-of-day parser for CSV 'date on sale to' field

    Date-only values are bumped to 23:59:59 to match the product edit
    screen; explicit time components and Unix timestamps are preserved.

    Part of the fix for #35321.

    * fix: store CSV 'date sale price ends' as end-of-day to match admin

    Closes #35321.

    * chore: add changelog for #35321 CSV sale end date fix

    * refactor: use wp_tempnam() for temp CSV fixtures in importer tests

    * fix: update legacy importer test for end-of-day sale end date parsing

    * fix: keep CSV sale end date on its calendar day in any timezone

    * fix: detect CSV sale end time with date_parse, not a colon regex

    * test: drop redundant CSV sale end date parser tests

    * chore: tighten changelog entry for CSV sale end date fix

    * chore: trim verbose comments in CSV sale end date code and tests

    * fix: keep timezone-qualified date-only sale end dates unchanged in CSV import

    Date-only values that carry a timezone (e.g. 2099-01-26Z) are points in time, so pass them through via date_parse()'s is_localtime flag instead of bumping them to end of day. Adds round-trip tests and shortens the changelog entry.

    * fix: treat weekday-prefixed date-only sale end dates as end of day in CSV import

    * fix: satisfy PHPStan and PHP 7.4 in sale end date import test

    Cast the preg_replace() probe to string so PHPStan accepts the date_parse()
    argument, and drop the hour-only ISO assertion, which strtotime() rejects
    on PHP 7.4.

    * refactor: trim comments in the CSV sale end date parser and its tests

    * chore: bump @since to 11.4.0

    * fix: restore @since 11.3.0 on existing method

    * fix: put @since 11.4.0 on the new method, keep 11.3.0 on the existing one

diff --git a/plugins/woocommerce/changelog/fix-35321-csv-import-sale-end-date b/plugins/woocommerce/changelog/fix-35321-csv-import-sale-end-date
new file mode 100644
index 00000000000..98342b72377
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-35321-csv-import-sale-end-date
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Imported sale end dates without a time now run to the end of that day, matching the product editor.
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 b7177108d19..075eec53a92 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -727,6 +727,42 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 		return null;
 	}

+	/**
+	 * Parse the "date on sale to" field from a CSV.
+	 *
+	 * A date-only value becomes the end of that day (23:59:59), matching the product edit
+	 * screen. Values with a time, a timezone, a Unix timestamp or a relative expression are
+	 * left as written.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $value Field value.
+	 *
+	 * @return string|null
+	 */
+	public function parse_date_on_sale_to_field( $value ) {
+		$parsed = $this->parse_datetime_field( $value );
+
+		if ( null === $parsed ) {
+			return null;
+		}
+
+		// date_parse() reports hour=0 for "Monday, January 26, 2099", so strip the weekday before probing.
+		$probe = (string) preg_replace( '/^(?:mon|tue(?:s)?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)(?:day)?\.?,?\s+/i', '', $parsed );
+		$date  = date_parse( $probe );
+
+		// Date-only input reports hour === false; a time, a timezone (is_localtime) or a relative expression does not.
+		if (
+			false === $date['year'] || false === $date['month'] || false === $date['day']
+			|| false !== $date['hour'] || isset( $date['relative'] ) || ! empty( $date['is_localtime'] )
+		) {
+			return $parsed;
+		}
+
+		// Built from Y/M/D, not a timestamp, so no offset can move the day.
+		return sprintf( '%04d-%02d-%02d 23:59:59', $date['year'], $date['month'], $date['day'] );
+	}
+
 	/**
 	 * Parse backorders from a CSV.
 	 *
@@ -896,7 +932,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			'published'         => array( $this, 'parse_published_field' ),
 			'featured'          => array( $this, 'parse_bool_field' ),
 			'date_on_sale_from' => array( $this, 'parse_datetime_field' ),
-			'date_on_sale_to'   => array( $this, 'parse_datetime_field' ),
+			'date_on_sale_to'   => array( $this, 'parse_date_on_sale_to_field' ),
 			'name'              => array( $this, 'parse_skip_field' ),
 			'short_description' => array( $this, 'parse_description_field' ),
 			'description'       => array( $this, 'parse_description_field' ),
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php b/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
index 185a419c3d7..396911040bf 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
@@ -663,7 +663,7 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
 				'short_description'  => 'Lorem ipsum dolor sit amet, at exerci civibus appetere sit, iuvaret hendrerit mea no. Eam integre feugait liberavisse an.',
 				'description'        => 'Lorem ipsum dolor sit amet, at exerci civibus appetere sit, iuvaret hendrerit mea no. Eam integre feugait liberavisse an.',
 				'date_on_sale_from'  => '2023-07-08 05:10:15',
-				'date_on_sale_to'    => '2023/07/13',
+				'date_on_sale_to'    => '2023-07-13 23:59:59',
 				'tax_status'         => ProductTaxStatus::TAXABLE,
 				'tax_class'          => 'standard',
 				'stock_status'       => ProductStockStatus::IN_STOCK,
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 18fa7d90186..de6b9ff619e 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
@@ -1872,6 +1872,342 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( '', $importer->parse_float_field( '' ), 'Empty values should be returned unchanged.' );
 	}

+	/**
+	 * @testdox Date-only "date on sale to" values are stored as end-of-day to match admin behaviour (#35321).
+	 */
+	public function test_parse_date_on_sale_to_field_date_only_is_end_of_day() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( '2099-01-26 23:59:59', $importer->parse_date_on_sale_to_field( '2099-01-26' ) );
+		$this->assertSame( '2022-10-25 23:59:59', $importer->parse_date_on_sale_to_field( '2022-10-25' ) );
+	}
+
+	/**
+	 * @testdox "date on sale to" values that already include a time component are preserved.
+	 */
+	public function test_parse_date_on_sale_to_field_preserves_time_component() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( '2099-01-26 10:30:00', $importer->parse_date_on_sale_to_field( '2099-01-26 10:30:00' ) );
+		$this->assertSame( '2099-01-26 10:30', $importer->parse_date_on_sale_to_field( '2099-01-26 10:30' ) );
+		$this->assertSame( '2099-01-26T10:30:00Z', $importer->parse_date_on_sale_to_field( '2099-01-26T10:30:00Z' ) );
+	}
+
+	/**
+	 * @testdox "date on sale to" date-only values that carry a timezone are preserved.
+	 */
+	public function test_parse_date_on_sale_to_field_preserves_timezone_qualified_date() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( '2099-01-26Z', $importer->parse_date_on_sale_to_field( '2099-01-26Z' ) );
+		$this->assertSame( '2099-01-26 +02:00', $importer->parse_date_on_sale_to_field( '2099-01-26 +02:00' ) );
+	}
+
+	/**
+	 * @testdox "date on sale to" values with a colonless time component are preserved.
+	 */
+	public function test_parse_date_on_sale_to_field_preserves_colonless_time_component() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( '20990126T103000', $importer->parse_date_on_sale_to_field( '20990126T103000' ) );
+		$this->assertSame( 'January 26, 2099 10am', $importer->parse_date_on_sale_to_field( 'January 26, 2099 10am' ) );
+		$this->assertSame( '2099-01-26 10.30', $importer->parse_date_on_sale_to_field( '2099-01-26 10.30' ) );
+		// Epoch syntax parses as a relative expression.
+		$this->assertSame( '@4073068800', $importer->parse_date_on_sale_to_field( '@4073068800' ) );
+	}
+
+	/**
+	 * @testdox Relative "date on sale to" expressions are never bumped to end-of-day.
+	 */
+	public function test_parse_date_on_sale_to_field_preserves_relative_expressions() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( 'now', $importer->parse_date_on_sale_to_field( 'now' ) );
+		$this->assertSame( '+1 week', $importer->parse_date_on_sale_to_field( '+1 week' ) );
+		$this->assertSame( 'tomorrow', $importer->parse_date_on_sale_to_field( 'tomorrow' ) );
+	}
+
+	/**
+	 * @testdox Date-only "date on sale to" values in non-Y-m-d formats are normalised to Y-m-d end-of-day.
+	 */
+	public function test_parse_date_on_sale_to_field_alternate_date_formats() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( '2099-01-26 23:59:59', $importer->parse_date_on_sale_to_field( '26-01-2099' ) );
+		$this->assertSame( '2099-01-26 23:59:59', $importer->parse_date_on_sale_to_field( '2099/01/26' ) );
+		$this->assertSame( '2099-01-26 23:59:59', $importer->parse_date_on_sale_to_field( 'January 26, 2099' ) );
+	}
+
+	/**
+	 * @testdox Date-only "date on sale to" values with a leading weekday name (Excel "Long Date") are bumped to end-of-day.
+	 */
+	public function test_parse_date_on_sale_to_field_weekday_prefixed_date_only_is_end_of_day() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( '2099-01-26 23:59:59', $importer->parse_date_on_sale_to_field( 'Monday, January 26, 2099' ) );
+		$this->assertSame( '2099-01-26 23:59:59', $importer->parse_date_on_sale_to_field( 'Mon 26 Jan 2099' ) );
+		$this->assertSame( '2099-01-26 23:59:59', $importer->parse_date_on_sale_to_field( 'Monday 26 January 2099' ) );
+	}
+
+	/**
+	 * @testdox Weekday-prefixed "date on sale to" values with a time, and bare or relative weekdays, are preserved.
+	 */
+	public function test_parse_date_on_sale_to_field_weekday_prefixed_values_with_time_or_relative_are_preserved() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertSame( 'Monday, January 26, 2099 00:00:00', $importer->parse_date_on_sale_to_field( 'Monday, January 26, 2099 00:00:00' ) );
+		$this->assertSame( 'Monday, January 26, 2099 10:00', $importer->parse_date_on_sale_to_field( 'Monday, January 26, 2099 10:00' ) );
+		$this->assertSame( 'next monday', $importer->parse_date_on_sale_to_field( 'next monday' ) );
+		$this->assertSame( 'monday', $importer->parse_date_on_sale_to_field( 'monday' ) );
+	}
+
+	/**
+	 * @testdox "date on sale to" Unix timestamps are normalised to ISO8601 without being shifted to end-of-day.
+	 */
+	public function test_parse_date_on_sale_to_field_unix_timestamp_preserved() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		// The Unix timestamp 4073068800 corresponds to 2099-01-26 00:00:00 UTC.
+		$this->assertSame( '2099-01-26T00:00:00Z', $importer->parse_date_on_sale_to_field( '4073068800' ) );
+	}
+
+	/**
+	 * @testdox Empty / invalid "date on sale to" values are returned as null.
+	 */
+	public function test_parse_date_on_sale_to_field_empty_and_invalid() {
+		$csv_file = __DIR__ . '/sample.csv';
+		$importer = new WC_Product_CSV_Importer( $csv_file );
+
+		$this->assertNull( $importer->parse_date_on_sale_to_field( '' ) );
+		$this->assertNull( $importer->parse_date_on_sale_to_field( 'not-a-date' ) );
+	}
+
+	/**
+	 * @testdox Importing a CSV with a date-only "date sale price ends" stores end-of-day, matching admin (#35321).
+	 */
+	public function test_import_date_on_sale_to_date_only_is_end_of_day() {
+		// Non-UTC zone so any day drift shows up.
+		update_option( 'timezone_string', 'Pacific/Auckland' );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'issue-35321-sku' );
+		$product->set_regular_price( '10.00' );
+		$product->save();
+
+		$timed_product = WC_Helper_Product::create_simple_product();
+		$timed_product->set_sku( 'issue-35321-timed-sku' );
+		$timed_product->set_regular_price( '10.00' );
+		$timed_product->save();
+
+		$excel_product = WC_Helper_Product::create_simple_product();
+		$excel_product->set_sku( 'issue-35321-excel-sku' );
+		$excel_product->set_regular_price( '10.00' );
+		$excel_product->save();
+
+		// wp_tempnam() always appends .tmp; the importer requires a .csv/.txt extension.
+		$tmp_path = wp_tempnam( 'wc-csv' );
+		$csv_path = $tmp_path . '.csv';
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Tests rename a tmp file we control.
+		rename( $tmp_path, $csv_path );
+		$lines = array(
+			'"ID","date sale price starts","date sale price ends","Sale price"',
+			sprintf( '%d,"2022-10-25","2099-01-26","2.00"', $product->get_id() ),
+			sprintf( '%d,"2022-10-25 09:00:00","2099-01-26 10:30:00","2.00"', $timed_product->get_id() ),
+			// Excel "Long Date" cell format.
+			sprintf( '%d,"Tuesday, October 25, 2022","Monday, January 26, 2099","2.00"', $excel_product->get_id() ),
+		);
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Tests write to a tmp file we control.
+		file_put_contents( $csv_path, implode( "\n", $lines ) . "\n" );
+
+		$importer = new WC_Product_CSV_Importer(
+			$csv_path,
+			array(
+				'update_existing' => true,
+				'parse'           => true,
+				'mapping'         => array(
+					'ID'                     => 'id',
+					'date sale price starts' => 'date_on_sale_from',
+					'date sale price ends'   => 'date_on_sale_to',
+					'Sale price'             => 'sale_price',
+				),
+			)
+		);
+		$importer->import();
+
+		$updated   = wc_get_product( $product->get_id() );
+		$date_to   = $updated->get_date_on_sale_to();
+		$date_from = $updated->get_date_on_sale_from();
+
+		$this->assertNotNull( $date_to, 'date_on_sale_to should be set' );
+		$this->assertNotNull( $date_from, 'date_on_sale_from should be set' );
+		$this->assertSame( '23:59:59', $date_to->date( 'H:i:s' ), 'date_on_sale_to should be end-of-day (site-local) to match admin' );
+		$this->assertSame( '2099-01-26', $date_to->date( 'Y-m-d' ), 'date_on_sale_to calendar day must not drift in a non-UTC site timezone' );
+		$this->assertSame( '00:00:00', $date_from->date( 'H:i:s' ), 'date_on_sale_from should remain start-of-day' );
+		$this->assertSame( '2022-10-25', $date_from->date( 'Y-m-d' ) );
+
+		$updated_timed   = wc_get_product( $timed_product->get_id() );
+		$timed_date_to   = $updated_timed->get_date_on_sale_to();
+		$timed_date_from = $updated_timed->get_date_on_sale_from();
+
+		$this->assertNotNull( $timed_date_to, 'timed date_on_sale_to should be set' );
+		$this->assertNotNull( $timed_date_from, 'timed date_on_sale_from should be set' );
+		$this->assertSame( '2099-01-26 10:30:00', $timed_date_to->date( 'Y-m-d H:i:s' ), 'explicit end time must be preserved site-local, not bumped to end-of-day' );
+		$this->assertSame( '2022-10-25 09:00:00', $timed_date_from->date( 'Y-m-d H:i:s' ), 'explicit start time must be preserved site-local' );
+
+		$updated_excel = wc_get_product( $excel_product->get_id() );
+		$this->assertSame( '2099-01-26 23:59:59', $updated_excel->get_date_on_sale_to()->date( 'Y-m-d H:i:s' ), 'weekday-prefixed date-only end date must be bumped to end-of-day' );
+		$this->assertSame( '2022-10-25 00:00:00', $updated_excel->get_date_on_sale_from()->date( 'Y-m-d H:i:s' ) );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		WC_Helper_Product::delete_product( $timed_product->get_id() );
+		WC_Helper_Product::delete_product( $excel_product->get_id() );
+		wp_delete_file( $csv_path );
+	}
+
+	/**
+	 * @testdox Re-importing the same CSV leaves the sale-end timestamp and the pending end-sale action unchanged.
+	 */
+	public function test_import_date_on_sale_to_reimport_is_idempotent() {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'issue-35321-idempotent-sku' );
+		$product->set_regular_price( '10.00' );
+		$product->save();
+
+		// wp_tempnam() always appends .tmp; the importer requires a .csv/.txt extension.
+		$tmp_path = wp_tempnam( 'wc-csv' );
+		$csv_path = $tmp_path . '.csv';
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Tests rename a tmp file we control.
+		rename( $tmp_path, $csv_path );
+		$lines = array(
+			'"ID","date sale price starts","date sale price ends","Sale price"',
+			sprintf( '%d,"2022-10-25","2099-01-26","2.00"', $product->get_id() ),
+		);
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Tests write to a tmp file we control.
+		file_put_contents( $csv_path, implode( "\n", $lines ) . "\n" );
+
+		$make_importer = function () use ( $csv_path ) {
+			return new WC_Product_CSV_Importer(
+				$csv_path,
+				array(
+					'update_existing' => true,
+					'parse'           => true,
+					'mapping'         => array(
+						'ID'                     => 'id',
+						'date sale price starts' => 'date_on_sale_from',
+						'date sale price ends'   => 'date_on_sale_to',
+						'Sale price'             => 'sale_price',
+					),
+				)
+			);
+		};
+
+		// Args and group per wc_schedule_product_sale_events() in wc-product-functions.php.
+		$pending_end_actions = function () use ( $product ) {
+			return as_get_scheduled_actions(
+				array(
+					'hook'     => 'wc_product_end_scheduled_sale',
+					'args'     => array( 'product_id' => $product->get_id() ),
+					'group'    => 'woocommerce-sales',
+					'status'   => ActionScheduler_Store::STATUS_PENDING,
+					'per_page' => 10,
+				),
+				'ids'
+			);
+		};
+
+		$make_importer()->import();
+		$first = wc_get_product( $product->get_id() )->get_date_on_sale_to();
+
+		$this->assertNotNull( $first, 'date_on_sale_to should be set after the first import' );
+		$this->assertSame( '23:59:59', $first->date( 'H:i:s' ) );
+		$first_timestamp = $first->getTimestamp();
+		$this->assertCount( 1, $pending_end_actions(), 'exactly one pending end-sale action after the first import' );
+
+		$make_importer()->import();
+		$second = wc_get_product( $product->get_id() )->get_date_on_sale_to();
+
+		$this->assertSame( $first_timestamp, $second->getTimestamp(), 're-import must not change the stored sale-end timestamp' );
+		$this->assertSame( '23:59:59', $second->date( 'H:i:s' ), 'the end-of-day bump must not compound on re-import' );
+		$this->assertCount( 1, $pending_end_actions(), 'still exactly one pending end-sale action after re-import' );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		wp_delete_file( $csv_path );
+	}
+
+	/**
+	 * @testdox Re-importing a date-only value stored as midnight before the fix moves it to 23:59:59 and reschedules the end-sale action (#35321).
+	 */
+	public function test_import_date_on_sale_to_first_reimport_after_upgrade_is_not_a_noop() {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'issue-35321-upgrade-sku' );
+		$product->set_regular_price( '10.00' );
+		// Midnight is what the importer stored for a date-only value before the fix.
+		$product->set_date_on_sale_to( '2099-01-26 00:00:00' );
+		$product->save();
+
+		$pending_end_actions = function () use ( $product ) {
+			return as_get_scheduled_actions(
+				array(
+					'hook'     => 'wc_product_end_scheduled_sale',
+					'args'     => array( 'product_id' => $product->get_id() ),
+					'group'    => 'woocommerce-sales',
+					'status'   => ActionScheduler_Store::STATUS_PENDING,
+					'per_page' => 10,
+				),
+				'ids'
+			);
+		};
+
+		$before = wc_get_product( $product->get_id() )->get_date_on_sale_to();
+		$this->assertSame( '00:00:00', $before->date( 'H:i:s' ), 'the pre-upgrade value should start at midnight' );
+
+		// wp_tempnam() always appends .tmp; the importer requires a .csv/.txt extension.
+		$tmp_path = wp_tempnam( 'wc-csv' );
+		$csv_path = $tmp_path . '.csv';
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Tests rename a tmp file we control.
+		rename( $tmp_path, $csv_path );
+		$lines = array(
+			'"ID","date sale price ends","Sale price"',
+			sprintf( '%d,"2099-01-26","2.00"', $product->get_id() ),
+		);
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Tests write to a tmp file we control.
+		file_put_contents( $csv_path, implode( "\n", $lines ) . "\n" );
+
+		$importer = new WC_Product_CSV_Importer(
+			$csv_path,
+			array(
+				'update_existing' => true,
+				'parse'           => true,
+				'mapping'         => array(
+					'ID'                   => 'id',
+					'date sale price ends' => 'date_on_sale_to',
+					'Sale price'           => 'sale_price',
+				),
+			)
+		);
+		$importer->import();
+
+		$after = wc_get_product( $product->get_id() )->get_date_on_sale_to();
+
+		$this->assertNotNull( $after, 'date_on_sale_to should still be set after the re-import' );
+		$this->assertSame( '23:59:59', $after->date( 'H:i:s' ), 'the same date-only value re-imported after upgrade should move to end-of-day' );
+		$this->assertSame( '2099-01-26', $after->date( 'Y-m-d' ), 'the calendar day should not change' );
+		$this->assertNotSame( $before->getTimestamp(), $after->getTimestamp(), 'the stored timestamp must change: this re-import is not a no-op' );
+		$this->assertCount( 1, $pending_end_actions(), 'exactly one pending end-sale action after the re-import, rescheduled for the new end-of-day time' );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		wp_delete_file( $csv_path );
+	}
+
 	/**
 	 * @testdox Constructing the importer with a CSV file that cannot be opened should fail with a clear error.
 	 */