Commit 2c17a3cdf85 for woocommerce

commit 2c17a3cdf85fa1fd2a5bfc59dc6aff24e3f8ad56
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Tue Sep 1 09:44:54 2026 +0200

    Fix stale product term caches after reordering (#67829)

    * Fix stale product term caches after reordering

    * Add changelog entry for product term cache fix

    * Add ProductTermCacheInvalidator to handle cache invalidation for product terms

    - Introduced ProductTermCacheInvalidator class to invalidate cached product terms when taxonomy caches are cleaned.
    - Updated WC_AJAX to remove cache invalidation logic, relying on the new invalidator.
    - Added tests for ProductTermCacheInvalidator to ensure proper functionality.

    * consolidate unt tests

diff --git a/plugins/woocommerce/changelog/67709-invalidate-product-term-cache b/plugins/woocommerce/changelog/67709-invalidate-product-term-cache
new file mode 100644
index 00000000000..6699c4db8f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/67709-invalidate-product-term-cache
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Invalidate cached product terms when their taxonomy terms are reordered.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index e91dcef4100..9791b115d7c 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\AssignDefaultCategory;
 use Automattic\WooCommerce\Internal\TermCount;
 use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
 use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
+use Automattic\WooCommerce\Internal\Caches\ProductTermCacheInvalidator;
 use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonAdminBarBadge;
 use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonCacheInvalidator;
 use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler;
@@ -385,6 +386,7 @@ final class WooCommerce {
 		$container->get( RestockRefundedItemsAdjuster::class );
 		$container->get( CustomOrdersTableController::class );
 		$container->get( ProductCacheController::class );
+		$container->get( ProductTermCacheInvalidator::class );
 		$container->get( OptionSanitizer::class );
 		$container->get( BatchProcessingController::class );
 		$container->get( FeaturesController::class );
diff --git a/plugins/woocommerce/includes/wc-term-functions.php b/plugins/woocommerce/includes/wc-term-functions.php
index c0b24a935ec..f74c91954ed 100644
--- a/plugins/woocommerce/includes/wc-term-functions.php
+++ b/plugins/woocommerce/includes/wc-term-functions.php
@@ -152,7 +152,7 @@ function wc_get_object_terms( $object_id, $taxonomy, $field = null, $index_key =
  * @return array
  */
 function _wc_get_cached_product_terms( $product_id, $taxonomy, $args = array() ) {
-	$cache_key   = 'wc_' . $taxonomy . md5( wp_json_encode( $args ) );
+	$cache_key   = WC_Cache_Helper::get_cache_prefix( 'product_terms_' . $taxonomy ) . 'wc_' . $taxonomy . md5( wp_json_encode( $args ) );
 	$cache_group = WC_Cache_Helper::get_cache_prefix( 'product_' . $product_id ) . $product_id;
 	$terms       = wp_cache_get( $cache_key, $cache_group );

diff --git a/plugins/woocommerce/src/Internal/Caches/ProductTermCacheInvalidator.php b/plugins/woocommerce/src/Internal/Caches/ProductTermCacheInvalidator.php
new file mode 100644
index 00000000000..1c2c665ddfd
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/ProductTermCacheInvalidator.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * ProductTermCacheInvalidator class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+use WC_Cache_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Invalidates cached product terms when taxonomy caches are cleaned.
+ *
+ * @since 11.2.0
+ *
+ * @internal
+ */
+class ProductTermCacheInvalidator {
+	/**
+	 * Register cache invalidation hooks.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		add_action( 'clean_taxonomy_cache', array( $this, 'handle_clean_taxonomy_cache' ) );
+	}
+
+	/**
+	 * Invalidate cached product terms after a taxonomy cache is cleaned.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @internal
+	 *
+	 * @param mixed $taxonomy Taxonomy name.
+	 */
+	public function handle_clean_taxonomy_cache( $taxonomy ): void {
+		if ( ! is_string( $taxonomy ) || ! is_object_in_taxonomy( 'product', $taxonomy ) ) {
+			return;
+		}
+
+		WC_Cache_Helper::invalidate_cache_group( 'product_terms_' . $taxonomy );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/ProductTermCacheInvalidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductTermCacheInvalidatorTest.php
new file mode 100644
index 00000000000..23f6525af05
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductTermCacheInvalidatorTest.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * ProductTermCacheInvalidator tests.
+ *
+ * @package WooCommerce\Tests\Internal\Caches
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\ProductTermCacheInvalidator;
+use WC_Cache_Helper;
+use WC_Helper_Product;
+use WC_Product;
+use WC_Unit_Test_Case;
+use WP_Term;
+
+/**
+ * Tests for ProductTermCacheInvalidator.
+ */
+final class ProductTermCacheInvalidatorTest extends WC_Unit_Test_Case {
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ProductTermCacheInvalidator
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut = wc_get_container()->get( ProductTermCacheInvalidator::class );
+	}
+
+	/**
+	 * @testdox Reordering product terms invalidates cached results.
+	 */
+	public function test_reordering_product_terms_invalidates_cached_results(): void {
+		$taxonomy = 'product_tag';
+		$suffix   = (string) wp_rand( 1000, 9999 );
+		$names    = array( 'First tag ' . $suffix, 'Second tag ' . $suffix );
+		$term_ids = array();
+		$product  = null;
+
+		try {
+			foreach ( $names as $index => $name ) {
+				$term = wp_insert_term( $name, $taxonomy );
+				$this->assertIsArray( $term, "The {$name} term should be created." );
+
+				$term_ids[] = $term['term_id'];
+				update_term_meta( $term['term_id'], 'order', $index + 1 );
+			}
+
+			$product = WC_Helper_Product::create_simple_product();
+			wp_set_object_terms( $product->get_id(), $term_ids, $taxonomy );
+
+			$args         = array(
+				'fields'     => 'all',
+				'menu_order' => 'ASC',
+			);
+			$cached_terms = wc_get_product_terms( $product->get_id(), $taxonomy, $args );
+			$this->assertSame(
+				$names,
+				wp_list_pluck( $cached_terms, 'name' ),
+				'Product terms should initially use the configured order.'
+			);
+
+			$term_to_move = get_term( $term_ids[1], $taxonomy );
+			$this->assertInstanceOf( WP_Term::class, $term_to_move );
+
+			wc_reorder_terms( $term_to_move, $term_ids[0], $taxonomy );
+
+			$reordered_terms = wc_get_product_terms( $product->get_id(), $taxonomy, $args );
+			$this->assertSame(
+				array_reverse( $names ),
+				wp_list_pluck( $reordered_terms, 'name' ),
+				'Product terms should use the new order without saving the product.'
+			);
+		} finally {
+			if ( $product instanceof WC_Product ) {
+				$product->delete( true );
+			}
+
+			foreach ( $term_ids as $term_id ) {
+				wp_delete_term( $term_id, $taxonomy );
+			}
+		}
+	}
+}