Commit 7513d9dbb72 for woocommerce

commit 7513d9dbb7295cc08b98cf0882ad3b67d21fd9e6
Author: SH Sajal Chowdhury <72102985+shsajalchowdhury@users.noreply.github.com>
Date:   Tue May 5 01:01:04 2026 +0600

    Fix report transient cache key instability caused by DateTime microsecond serialization (#64430)

    * Fix transient cache key instability caused by DateTime microsecond serialization

    When get_cache_key() serialized DateTime objects via wp_json_encode,
    the resulting cache key included microsecond precision from WC_DateTime.
    This caused a different transient key on every request, defeating caching.

    Normalize DateTimeInterface objects to ISO 8601 strings (DATE_ATOM format)
    before hashing, which strips microseconds and produces stable cache keys.

    Fixes #64177

    * Improve test: verify DateTime timestamp and timezone are not mutated after get_cache_key

    Address CodeRabbit feedback: use clone and compare getTimestamp() and
    getTimezone()->getName() instead of only asserting instanceof.

    * Disable report caching in customers test class

    The test_user_creation test exercises query semantics, not cache
    behaviour. Stable cache keys (introduced by the DateTime normalization
    fix) surfaced a latent cache-invalidation timing bug tracked in #64557.

    Bypass the cache via the existing woocommerce_analytics_report_should_use_cache
    filter so the tests are not affected by the separate caching issue.

    Per review feedback from @prettyboymp on #64430.

    ---------

    Co-authored-by: Michael Pretty <prettyboymp@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-64177-transient-cache-key-datetime b/plugins/woocommerce/changelog/fix-64177-transient-cache-key-datetime
new file mode 100644
index 00000000000..560519fce44
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-64177-transient-cache-key-datetime
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix report transient cache key instability caused by DateTime serialization with microseconds in get_cache_key
diff --git a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
index 6c65c93cc1b..925a523ac46 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
@@ -334,6 +334,18 @@ class DataStore extends SqlQuery implements DataStoreInterface {
 				return ! empty( $param );
 			}
 		);
+
+		// Normalize DateTime objects to ISO 8601 strings to avoid cache key
+		// instability caused by microsecond-level differences in serialization.
+		array_walk(
+			$params,
+			function ( &$value ) {
+				if ( $value instanceof \DateTimeInterface ) {
+					$value = $value->format( DATE_ATOM );
+				}
+			}
+		);
+
 		ksort( $params );
 		return implode(
 			'_',
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
index a1358ea66af..ed0c99d2617 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
@@ -40,6 +40,20 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
 				'role' => 'administrator',
 			)
 		);
+
+		// Disable report caching for this test class — these tests exercise
+		// query semantics, not cache behaviour.  A separate issue (#64557)
+		// tracks the cache-invalidation timing bug that stable cache keys
+		// have surfaced.
+		add_filter( 'woocommerce_analytics_report_should_use_cache', '__return_false' );
+	}
+
+	/**
+	 * Clean up after tests.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'woocommerce_analytics_report_should_use_cache', '__return_false' );
+		parent::tearDown();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/DataStore/CacheKeyTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/DataStore/CacheKeyTest.php
new file mode 100644
index 00000000000..6cb517b39e1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/DataStore/CacheKeyTest.php
@@ -0,0 +1,94 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\DataStore;
+
+use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the base Reports DataStore cache key generation.
+ */
+class CacheKeyTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox get_cache_key produces identical keys for DateTime objects representing the same second.
+	 */
+	public function test_cache_key_is_stable_across_datetime_objects_with_same_second(): void {
+		$store = new CustomersDataStore();
+
+		// Two DateTime objects with the same timestamp but different microseconds.
+		$dt1 = new \DateTime( '2026-04-27 12:00:00.123456' );
+		$dt2 = new \DateTime( '2026-04-27 12:00:00.654321' );
+
+		$key1 = $this->invoke_get_cache_key( $store, array( 'before' => $dt1 ) );
+		$key2 = $this->invoke_get_cache_key( $store, array( 'before' => $dt2 ) );
+
+		$this->assertSame( $key1, $key2, 'Cache keys should be identical when DateTime objects differ only in microseconds.' );
+	}
+
+	/**
+	 * @testdox get_cache_key produces different keys for DateTime objects with different seconds.
+	 */
+	public function test_cache_key_differs_for_different_dates(): void {
+		$store = new CustomersDataStore();
+
+		$dt1 = new \DateTime( '2026-04-27 12:00:00' );
+		$dt2 = new \DateTime( '2026-04-27 12:00:01' );
+
+		$key1 = $this->invoke_get_cache_key( $store, array( 'before' => $dt1 ) );
+		$key2 = $this->invoke_get_cache_key( $store, array( 'before' => $dt2 ) );
+
+		$this->assertNotSame( $key1, $key2, 'Cache keys should differ when DateTime objects represent different moments.' );
+	}
+
+	/**
+	 * @testdox get_cache_key produces identical keys for both before and after DateTime params in the same second.
+	 */
+	public function test_cache_key_is_stable_with_before_and_after(): void {
+		$store = new CustomersDataStore();
+
+		$params1 = array(
+			'before' => new \DateTime( '2026-04-27 12:00:00.111111' ),
+			'after'  => new \DateTime( '2026-04-20 12:00:00.222222' ),
+		);
+		$params2 = array(
+			'before' => new \DateTime( '2026-04-27 12:00:00.999999' ),
+			'after'  => new \DateTime( '2026-04-20 12:00:00.888888' ),
+		);
+
+		$key1 = $this->invoke_get_cache_key( $store, $params1 );
+		$key2 = $this->invoke_get_cache_key( $store, $params2 );
+
+		$this->assertSame( $key1, $key2, 'Cache keys should be identical when both before/after differ only in microseconds.' );
+	}
+
+	/**
+	 * @testdox get_cache_key does not modify the original params array's DateTime objects.
+	 */
+	public function test_get_cache_key_does_not_modify_original_params(): void {
+		$store = new CustomersDataStore();
+
+		$original = new \DateTime( '2026-04-27 12:00:00.123456', new \DateTimeZone( 'UTC' ) );
+		$params   = array( 'before' => clone $original );
+
+		$this->invoke_get_cache_key( $store, $params );
+
+		$this->assertInstanceOf( \DateTime::class, $params['before'], 'DateTime object in original params should remain a DateTime.' );
+		$this->assertSame( $original->getTimestamp(), $params['before']->getTimestamp(), 'DateTime timestamp should not be mutated.' );
+		$this->assertSame( $original->getTimezone()->getName(), $params['before']->getTimezone()->getName(), 'DateTime timezone should not be mutated.' );
+	}
+
+	/**
+	 * Call the protected get_cache_key method via reflection.
+	 *
+	 * @param CustomersDataStore $store  DataStore instance.
+	 * @param array              $params Parameters.
+	 * @return string Cache key.
+	 */
+	private function invoke_get_cache_key( CustomersDataStore $store, array $params ): string {
+		$method = new \ReflectionMethod( $store, 'get_cache_key' );
+		$method->setAccessible( true );
+		return $method->invoke( $store, $params );
+	}
+}