Commit 94fd68fd4f for woocommerce

commit 94fd68fd4fd128ff4950025174c7c30b65e8701e
Author: Joshua T Flowers <joshuatf@gmail.com>
Date:   Thu Dec 18 02:06:09 2025 -0800

    Add a product version string invalidator (#61613)

    Co-authored-by: Néstor Soriano <konamiman@konamiman.com>

diff --git a/plugins/woocommerce/changelog/add-cache-invalidator b/plugins/woocommerce/changelog/add-cache-invalidator
new file mode 100644
index 0000000000..681c2905f7
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-cache-invalidator
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Add cache invalidation interface and product invalidator
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index f9ccb73282..5dd564d80d 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -35,6 +35,7 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\{LoggingUtil, RestApiUtil, TimeUtil};
 use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
 use Automattic\WooCommerce\Caches\OrderCountCacheService;
+use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
 use Automattic\Jetpack\Constants;

@@ -358,6 +359,7 @@ final class WooCommerce {
 		$container->get( AddressProviderController::class );
 		$container->get( AbilitiesRegistry::class );
 		$container->get( MCPAdapterProvider::class );
+		$container->get( ProductVersionStringInvalidator::class );

 		// Feature flags.
 		if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
index c366a14380..3805a8ebae 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
@@ -5,6 +5,7 @@
  * @package WooCommerce\Classes
  */

+use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Utilities\CallbackUtil;
@@ -749,6 +750,13 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 					$product->get_id()
 				)
 			);
+
+			$invalidator = wc_get_container()->get( ProductVersionStringInvalidator::class );
+			$children    = $product->get_children();
+			foreach ( $children as $child_id ) {
+				$invalidator->invalidate( $child_id );
+			}
+			$invalidator->invalidate( $product->get_id() );
 		}
 	}

@@ -764,8 +772,9 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 		global $wpdb;

 		if ( $product->get_manage_stock() ) {
-			$children = $product->get_children();
-			$changed  = false;
+			$children    = $product->get_children();
+			$changed     = false;
+			$invalidator = wc_get_container()->get( ProductVersionStringInvalidator::class );

 			if ( $children ) {
 				$status   = $product->get_stock_status();
@@ -777,6 +786,8 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 					if ( update_post_meta( $managed_child, '_stock_status', $status ) ) {
 						$this->update_lookup_table( $managed_child, 'wc_product_meta_lookup' );
 						$changed = true;
+
+						$invalidator->invalidate( $managed_child );
 					}
 				}
 			}
@@ -785,6 +796,8 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 				$children = $this->read_children( $product, true );
 				$product->set_children( $children['all'] );
 				$product->set_visible_children( $children['visible'] );
+
+				$invalidator->invalidate( $product->get_id() );
 			}
 		}
 	}
diff --git a/plugins/woocommerce/src/Internal/Caches/ProductVersionStringInvalidator.php b/plugins/woocommerce/src/Internal/Caches/ProductVersionStringInvalidator.php
new file mode 100644
index 0000000000..75bd23d9ff
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/ProductVersionStringInvalidator.php
@@ -0,0 +1,603 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+
+/**
+ * Product version string invalidation handler.
+ *
+ * This class provides an 'invalidate' method that will invalidate
+ * the version string for a given product, which in turn invalidates
+ * any cached REST API responses containing that product.
+ */
+class ProductVersionStringInvalidator {
+
+	/**
+	 * Default cache TTL in seconds for term/taxonomy entity lookups.
+	 */
+	const DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL = 300;
+
+	/**
+	 * Initialize the invalidator and register hooks.
+	 *
+	 * Hooks are only registered when both conditions are met:
+	 * - The REST API caching feature is enabled
+	 * - The backend caching setting is active
+	 *
+	 * @param FeaturesController $features_controller The features controller.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	final public function init( FeaturesController $features_controller ): void {
+		if ( ! $features_controller->feature_is_enabled( 'rest_api_caching' ) ) {
+			return;
+		}
+
+		if ( 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' ) ) {
+			$this->register_hooks();
+		}
+	}
+
+	/**
+	 * Register all product-related hooks.
+	 *
+	 * Registers ALL hooks (WordPress and WooCommerce) to ensure comprehensive coverage.
+	 * This handles both standard data stores and custom implementations, as well as
+	 * third-party plugins that may use direct SQL with manual hook firing.
+	 *
+	 * @return void
+	 */
+	private function register_hooks(): void {
+		// WordPress post hooks for products.
+		add_action( 'save_post_product', array( $this, 'handle_save_post_product' ), 10, 1 );
+		add_action( 'delete_post', array( $this, 'handle_delete_post' ), 10, 2 );
+		add_action( 'trashed_post', array( $this, 'handle_trashed_post' ), 10, 1 );
+		add_action( 'untrashed_post', array( $this, 'handle_untrashed_post' ), 10, 1 );
+
+		// WooCommerce CRUD hooks for products.
+		add_action( 'woocommerce_new_product', array( $this, 'handle_woocommerce_new_product' ), 10, 1 );
+		add_action( 'woocommerce_update_product', array( $this, 'handle_woocommerce_update_product' ), 10, 1 );
+		add_action( 'woocommerce_before_delete_product', array( $this, 'handle_woocommerce_before_delete_product' ), 10, 1 );
+		add_action( 'woocommerce_trash_product', array( $this, 'handle_woocommerce_trash_product' ), 10, 1 );
+
+		// WooCommerce CRUD hooks for variations.
+		add_action( 'woocommerce_new_product_variation', array( $this, 'handle_woocommerce_new_product_variation' ), 10, 2 );
+		add_action( 'woocommerce_update_product_variation', array( $this, 'handle_woocommerce_update_product_variation' ), 10, 2 );
+		add_action( 'woocommerce_before_delete_product_variation', array( $this, 'handle_woocommerce_before_delete_product_variation' ), 10, 1 );
+		add_action( 'woocommerce_trash_product_variation', array( $this, 'handle_woocommerce_trash_product_variation' ), 10, 1 );
+
+		// SQL-level operation hooks.
+		add_action( 'woocommerce_updated_product_stock', array( $this, 'handle_woocommerce_updated_product_stock' ), 10, 1 );
+		add_action( 'woocommerce_updated_product_price', array( $this, 'handle_woocommerce_updated_product_price' ), 10, 1 );
+		add_action( 'woocommerce_updated_product_sales', array( $this, 'handle_woocommerce_updated_product_sales' ), 10, 1 );
+
+		// Attribute-related hooks (only for CPT data store).
+		// These hooks use direct SQL queries that assume CPT storage.
+		if ( $this->is_using_cpt_data_store() ) {
+			add_action( 'woocommerce_attribute_updated', array( $this, 'handle_woocommerce_attribute_updated' ), 10, 2 );
+			add_action( 'woocommerce_attribute_deleted', array( $this, 'handle_woocommerce_attribute_deleted' ), 10, 3 );
+			add_action( 'woocommerce_updated_product_attribute_summary', array( $this, 'handle_woocommerce_updated_product_attribute_summary' ), 10, 1 );
+			add_action( 'edited_term', array( $this, 'handle_edited_term' ), 10, 3 );
+		}
+	}
+
+	/**
+	 * Check if the product data store is CPT-based.
+	 *
+	 * @return bool True if using CPT data store, false otherwise.
+	 */
+	private function is_using_cpt_data_store(): bool {
+		$data_store = \WC_Data_Store::load( 'product' );
+		return $data_store->get_current_class_name() === 'WC_Product_Data_Store_CPT';
+	}
+
+	/**
+	 * Handle the save_post_product hook.
+	 *
+	 * @param int $post_id The post ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_save_post_product( $post_id ): void {
+		$post_id = (int) $post_id;
+
+		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
+			return;
+		}
+
+		$this->invalidate( $post_id );
+	}
+
+	/**
+	 * Handle the delete_post hook.
+	 *
+	 * @param int           $post_id The post ID.
+	 * @param \WP_Post|null $post The post object, or null if not provided.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_delete_post( $post_id, $post = null ): void {
+		$post_id = (int) $post_id;
+
+		if ( ! $post instanceof \WP_Post ) {
+			$post = get_post( $post_id );
+		}
+
+		if ( ! $post ) {
+			return;
+		}
+
+		if ( 'product_variation' === $post->post_type ) {
+			$this->invalidate_variation_and_parent( $post_id, (int) $post->post_parent );
+		} elseif ( 'product' === $post->post_type ) {
+			$this->invalidate( $post_id );
+		}
+	}
+
+	/**
+	 * Handle the trashed_post hook.
+	 *
+	 * @param int $post_id The post ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_trashed_post( $post_id ): void {
+		$this->handle_trashed_or_untrashed_post( (int) $post_id );
+	}
+
+	/**
+	 * Handle the untrashed_post hook.
+	 *
+	 * @param int $post_id The post ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_untrashed_post( $post_id ): void {
+		$this->handle_trashed_or_untrashed_post( (int) $post_id );
+	}
+
+	/**
+	 * Handle the trashed_post and untrashed_post hooks.
+	 *
+	 * @param int $post_id The post ID.
+	 *
+	 * @return void
+	 */
+	private function handle_trashed_or_untrashed_post( int $post_id ): void {
+		$post = get_post( $post_id );
+
+		if ( ! $post ) {
+			return;
+		}
+
+		if ( 'product_variation' === $post->post_type ) {
+			$this->invalidate_variation_and_parent( $post_id, $post->post_parent );
+		} elseif ( 'product' === $post->post_type ) {
+			$this->invalidate( $post_id );
+		}
+	}
+
+	/**
+	 * Handle the woocommerce_new_product_variation hook.
+	 *
+	 * @param int         $variation_id The variation ID.
+	 * @param \WC_Product $variation The variation object.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_new_product_variation( $variation_id, $variation ): void {
+		$variation_id = (int) $variation_id;
+		$parent_id    = $variation instanceof \WC_Product ? $variation->get_parent_id() : null;
+		$this->invalidate_variation_and_parent( $variation_id, $parent_id );
+	}
+
+	/**
+	 * Handle the woocommerce_update_product_variation hook.
+	 *
+	 * @param int         $variation_id The variation ID.
+	 * @param \WC_Product $variation The variation object.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_update_product_variation( $variation_id, $variation ): void {
+		$variation_id = (int) $variation_id;
+		$parent_id    = $variation instanceof \WC_Product ? $variation->get_parent_id() : null;
+		$this->invalidate_variation_and_parent( $variation_id, $parent_id );
+	}
+
+	/**
+	 * Handle the woocommerce_new_product hook.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_new_product( $product_id ): void {
+		$this->invalidate( (int) $product_id );
+	}
+
+	/**
+	 * Handle the woocommerce_update_product hook.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_update_product( $product_id ): void {
+		$this->invalidate( (int) $product_id );
+	}
+
+	/**
+	 * Handle the woocommerce_before_delete_product hook.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_before_delete_product( $product_id ): void {
+		$this->invalidate( (int) $product_id );
+	}
+
+	/**
+	 * Handle the woocommerce_trash_product hook.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_trash_product( $product_id ): void {
+		$this->invalidate( (int) $product_id );
+	}
+
+	/**
+	 * Handle the woocommerce_before_delete_product_variation hook.
+	 *
+	 * @param int $variation_id The variation ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_before_delete_product_variation( $variation_id ): void {
+		$this->invalidate_variation_and_parent( (int) $variation_id );
+	}
+
+	/**
+	 * Handle the woocommerce_trash_product_variation hook.
+	 *
+	 * @param int $variation_id The variation ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_trash_product_variation( $variation_id ): void {
+		$this->invalidate_variation_and_parent( (int) $variation_id );
+	}
+
+	/**
+	 * Handle the woocommerce_updated_product_stock hook.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_updated_product_stock( $product_id ): void {
+		$this->invalidate( (int) $product_id );
+	}
+
+	/**
+	 * Handle the woocommerce_updated_product_price hook.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_updated_product_price( $product_id ): void {
+		$this->invalidate( (int) $product_id );
+	}
+
+	/**
+	 * Handle the woocommerce_updated_product_sales hook.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_updated_product_sales( $product_id ): void {
+		$this->invalidate( (int) $product_id );
+	}
+
+	/**
+	 * Handle the woocommerce_attribute_updated hook.
+	 *
+	 * @param int   $id The attribute ID.
+	 * @param array $data The attribute data.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_attribute_updated( $id, $data ): void {
+		if ( ! is_array( $data ) || ! isset( $data['attribute_name'] ) ) {
+			return;
+		}
+
+		$taxonomy = wc_attribute_taxonomy_name( $data['attribute_name'] );
+		$this->invalidate_products_with_attribute( $taxonomy );
+	}
+
+	/**
+	 * Handle the woocommerce_attribute_deleted hook.
+	 *
+	 * @param int    $id The attribute ID.
+	 * @param string $name The attribute name.
+	 * @param string $taxonomy The attribute taxonomy.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_attribute_deleted( $id, $name, $taxonomy ): void {
+		if ( ! is_string( $taxonomy ) || '' === $taxonomy ) {
+			return;
+		}
+
+		$this->invalidate_products_with_attribute( $taxonomy );
+	}
+
+	/**
+	 * Handle the woocommerce_updated_product_attribute_summary hook.
+	 *
+	 * @param int $variation_id The variation ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_updated_product_attribute_summary( $variation_id ): void {
+		$this->invalidate_variation_and_parent( (int) $variation_id );
+	}
+
+	/**
+	 * Handle the edited_term hook.
+	 *
+	 * @param int    $term_id The term ID.
+	 * @param int    $tt_id The term taxonomy ID.
+	 * @param string $taxonomy The taxonomy slug.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 *
+	 * @internal
+	 */
+	public function handle_edited_term( $term_id, $tt_id, $taxonomy ): void {
+		if ( ! is_string( $taxonomy ) ) {
+			return;
+		}
+
+		// Only handle product attribute taxonomies.
+		if ( 0 !== strpos( $taxonomy, 'pa_' ) ) {
+			return;
+		}
+
+		$this->invalidate_products_with_term( (int) $tt_id );
+	}
+
+	/**
+	 * Invalidate a variation and its parent product.
+	 *
+	 * @param int      $variation_id The variation ID.
+	 * @param int|null $parent_id Optional parent product ID. If not provided, will be looked up.
+	 *
+	 * @return void
+	 */
+	private function invalidate_variation_and_parent( int $variation_id, ?int $parent_id = null ): void {
+		$this->invalidate( $variation_id );
+
+		if ( is_null( $parent_id ) ) {
+			if ( $this->is_using_cpt_data_store() ) {
+				$parent_id = wp_get_post_parent_id( $variation_id );
+			} else {
+				$variation = wc_get_product( $variation_id );
+				$parent_id = $variation ? $variation->get_parent_id() : 0;
+			}
+		}
+
+		if ( ! $parent_id ) {
+			return;
+		}
+
+		$this->invalidate( $parent_id );
+	}
+
+	/**
+	 * Invalidate all products and variations that have a specific term assigned.
+	 *
+	 * Uses the indexed wp_term_relationships table for efficient lookups.
+	 * The list of entities associated with the term is cached for performance;
+	 * the TTL can be customized via the 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl' filter.
+	 *
+	 * @param int $tt_id The term taxonomy ID.
+	 *
+	 * @return void
+	 */
+	private function invalidate_products_with_term( int $tt_id ): void {
+		global $wpdb;
+
+		$cache_key  = 'wc_cache_inv_term_' . $tt_id;
+		$entity_ids = wp_cache_get( $cache_key, 'woocommerce' );
+
+		if ( false === $entity_ids ) {
+			$entity_ids = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT tr.object_id
+					FROM {$wpdb->term_relationships} tr
+					INNER JOIN {$wpdb->posts} p ON tr.object_id = p.ID
+					WHERE tr.term_taxonomy_id = %d
+					AND p.post_type IN ('product', 'product_variation')",
+					$tt_id
+				)
+			);
+
+			/**
+			 * Filters the cache TTL for queries that find entities associated with a term or taxonomy.
+			 *
+			 * These queries are used during cache invalidation to determine which entities
+			 * (e.g., products, variations) need their cache cleared when a term or attribute changes.
+			 *
+			 * @since 10.5.0
+			 *
+			 * @param int    $ttl         Cache TTL in seconds. Default 300 (5 minutes).
+			 * @param string $entity_type The type of entity being invalidated ('product').
+			 */
+			$ttl = apply_filters( 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl', self::DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL, 'product' );
+			wp_cache_set( $cache_key, $entity_ids, 'woocommerce', $ttl );
+		}
+
+		foreach ( $entity_ids as $entity_id ) {
+			$post_type = get_post_type( (int) $entity_id );
+			if ( 'product_variation' === $post_type ) {
+				$this->invalidate_variation_and_parent( (int) $entity_id );
+			} else {
+				$this->invalidate( (int) $entity_id );
+			}
+		}
+	}
+
+	/**
+	 * Invalidate all products using a specific attribute taxonomy.
+	 *
+	 * The list of entities associated with the taxonomy is cached for performance;
+	 * the TTL can be customized via the 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl' filter.
+	 *
+	 * @param string $taxonomy The attribute taxonomy slug.
+	 *
+	 * @return void
+	 */
+	private function invalidate_products_with_attribute( string $taxonomy ): void {
+		global $wpdb;
+
+		$cache_key = 'wc_cache_inv_attr_' . $taxonomy;
+		$cached    = wp_cache_get( $cache_key, 'woocommerce' );
+
+		if ( false === $cached ) {
+			$product_ids = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT DISTINCT post_id FROM {$wpdb->postmeta}
+					WHERE meta_key = '_product_attributes'
+					AND meta_value LIKE %s",
+					'%' . $wpdb->esc_like( 's:' . strlen( $taxonomy ) . ':"' . $taxonomy . '"' ) . '%'
+				)
+			);
+
+			$variation_ids = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT DISTINCT post_id FROM {$wpdb->postmeta}
+					WHERE meta_key = %s",
+					'attribute_' . $taxonomy
+				)
+			);
+
+			$cached = array(
+				'product_ids'   => $product_ids,
+				'variation_ids' => $variation_ids,
+			);
+
+			// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Documented above.
+			$ttl = apply_filters( 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl', self::DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL, 'product' );
+			wp_cache_set( $cache_key, $cached, 'woocommerce', $ttl );
+		}
+
+		foreach ( $cached['product_ids'] as $product_id ) {
+			$this->invalidate( (int) $product_id );
+		}
+
+		foreach ( $cached['variation_ids'] as $variation_id ) {
+			$this->invalidate_variation_and_parent( (int) $variation_id );
+		}
+	}
+
+	/**
+	 * Invalidate a product version string.
+	 *
+	 * @param int $product_id The product ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.5.0
+	 */
+	public function invalidate( int $product_id ): void {
+		wc_get_container()->get( VersionStringGenerator::class )->delete_version( "product_{$product_id}" );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/ProductVersionStringInvalidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductVersionStringInvalidatorTest.php
new file mode 100644
index 0000000000..9fbfd48e74
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductVersionStringInvalidatorTest.php
@@ -0,0 +1,739 @@
+<?php
+/**
+ * ProductVersionStringInvalidatorTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
+use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+
+/**
+ * Tests for the ProductVersionStringInvalidator class.
+ */
+class ProductVersionStringInvalidatorTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ProductVersionStringInvalidator
+	 */
+	private $sut;
+
+	/**
+	 * Version string generator.
+	 *
+	 * @var VersionStringGenerator
+	 */
+	private $version_generator;
+
+	/**
+	 * Setup test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut               = new ProductVersionStringInvalidator();
+		$this->version_generator = wc_get_container()->get( VersionStringGenerator::class );
+	}
+
+	/**
+	 * Tear down test.
+	 */
+	public function tearDown(): void {
+		delete_option( 'woocommerce_rest_api_enable_backend_caching' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Enable the feature and backend caching, and initialize a new invalidator with hooks registered.
+	 *
+	 * @return ProductVersionStringInvalidator The initialized invalidator.
+	 */
+	private function get_invalidator_with_hooks_enabled(): ProductVersionStringInvalidator {
+		$features_controller = $this->createMock( FeaturesController::class );
+		$features_controller->method( 'feature_is_enabled' )
+			->with( 'rest_api_caching' )
+			->willReturn( true );
+
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+
+		$invalidator = new ProductVersionStringInvalidator();
+		$invalidator->init( $features_controller );
+
+		return $invalidator;
+	}
+
+	/**
+	 * @testdox Invalidate method deletes the product version string from cache.
+	 */
+	public function test_invalidate_deletes_version_string() {
+		$product_id = 123;
+
+		$this->version_generator->generate_version( "product_{$product_id}" );
+
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before invalidation' );
+
+		$this->sut->invalidate( $product_id );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after invalidation' );
+	}
+
+	/**
+	 * @testdox Hooks are registered when feature is enabled and backend caching is active.
+	 */
+	public function test_hooks_registered_when_feature_and_setting_enabled() {
+		$invalidator = $this->get_invalidator_with_hooks_enabled();
+
+		$this->assertNotFalse( has_action( 'save_post_product', array( $invalidator, 'handle_save_post_product' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_new_product', array( $invalidator, 'handle_woocommerce_new_product' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_update_product', array( $invalidator, 'handle_woocommerce_update_product' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when feature is disabled.
+	 */
+	public function test_hooks_not_registered_when_feature_disabled() {
+		$features_controller = $this->createMock( FeaturesController::class );
+		$features_controller->method( 'feature_is_enabled' )
+			->with( 'rest_api_caching' )
+			->willReturn( false );
+
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+
+		$invalidator = new ProductVersionStringInvalidator();
+		$invalidator->init( $features_controller );
+
+		$this->assertFalse( has_action( 'save_post_product', array( $invalidator, 'handle_save_post_product' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_new_product', array( $invalidator, 'handle_woocommerce_new_product' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_update_product', array( $invalidator, 'handle_woocommerce_update_product' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when backend caching setting is disabled.
+	 */
+	public function test_hooks_not_registered_when_backend_caching_disabled() {
+		$features_controller = $this->createMock( FeaturesController::class );
+		$features_controller->method( 'feature_is_enabled' )
+			->with( 'rest_api_caching' )
+			->willReturn( true );
+
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+
+		$invalidator = new ProductVersionStringInvalidator();
+		$invalidator->init( $features_controller );
+
+		$this->assertFalse( has_action( 'save_post_product', array( $invalidator, 'handle_save_post_product' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_new_product', array( $invalidator, 'handle_woocommerce_new_product' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_update_product', array( $invalidator, 'handle_woocommerce_update_product' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when backend caching setting is not set (defaults to no).
+	 */
+	public function test_hooks_not_registered_when_backend_caching_not_set() {
+		$features_controller = $this->createMock( FeaturesController::class );
+		$features_controller->method( 'feature_is_enabled' )
+			->with( 'rest_api_caching' )
+			->willReturn( true );
+
+		delete_option( 'woocommerce_rest_api_enable_backend_caching' );
+
+		$invalidator = new ProductVersionStringInvalidator();
+		$invalidator->init( $features_controller );
+
+		$this->assertFalse( has_action( 'save_post_product', array( $invalidator, 'handle_save_post_product' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_new_product', array( $invalidator, 'handle_woocommerce_new_product' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_update_product', array( $invalidator, 'handle_woocommerce_update_product' ) ) );
+	}
+
+	/**
+	 * @testdox Creating a new product invalidates the version string.
+	 */
+	public function test_product_creation_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		// Version string should have been deleted during creation.
+		$version = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version, 'Version string should be deleted after product creation' );
+
+		// Now create a version string and verify update deletes it.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before update' );
+
+		$product->set_name( 'Updated Product' );
+		$product->save();
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after product update' );
+	}
+
+	/**
+	 * @testdox Updating an existing product invalidates the version string.
+	 */
+	public function test_product_update_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before update' );
+
+		// Update product.
+		$product->set_name( 'Updated Product Name' );
+		$product->save();
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after product update' );
+	}
+
+	/**
+	 * @testdox Deleting a product invalidates the version string.
+	 */
+	public function test_product_deletion_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before deletion' );
+
+		// Delete product.
+		wp_delete_post( $product_id, true );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after product deletion' );
+	}
+
+	/**
+	 * @testdox Trashing a product invalidates the version string.
+	 */
+	public function test_product_trash_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before trashing' );
+
+		// Trash product.
+		wp_trash_post( $product_id );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after product trashing' );
+	}
+
+	/**
+	 * @testdox Untrashing a product invalidates the version string.
+	 */
+	public function test_product_untrash_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		// Trash product first.
+		wp_trash_post( $product_id );
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before untrashing' );
+
+		// Untrash product.
+		wp_untrash_post( $product_id );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after product untrashing' );
+	}
+
+	/**
+	 * @testdox Updating a variation invalidates both variation and parent version strings.
+	 */
+	public function test_variation_update_invalidates_parent_and_variation() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$parent_product = \WC_Helper_Product::create_variation_product();
+		$parent_id      = $parent_product->get_id();
+		$variations     = $parent_product->get_children();
+		$variation_id   = $variations[0];
+		$variation      = wc_get_product( $variation_id );
+
+		// Create version strings for both parent and variation.
+		$this->version_generator->generate_version( "product_{$parent_id}" );
+		$this->version_generator->generate_version( "product_{$variation_id}" );
+
+		$parent_version_before    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_before = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNotNull( $parent_version_before, 'Parent version string should exist before variation update' );
+		$this->assertNotNull( $variation_version_before, 'Variation version string should exist before update' );
+
+		// Update variation.
+		$variation->set_regular_price( '99.99' );
+		$variation->save();
+
+		$parent_version_after    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_after = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNull( $variation_version_after, 'Variation version string should be deleted after update' );
+		$this->assertNull( $parent_version_after, 'Parent version string should be deleted after variation update' );
+	}
+
+	/**
+	 * @testdox Deleting a variation invalidates both variation and parent version strings.
+	 */
+	public function test_variation_deletion_invalidates_parent() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$parent_product = \WC_Helper_Product::create_variation_product();
+		$parent_id      = $parent_product->get_id();
+		$variations     = $parent_product->get_children();
+		$variation_id   = $variations[0];
+
+		// Create version strings for both parent and variation.
+		$this->version_generator->generate_version( "product_{$parent_id}" );
+		$this->version_generator->generate_version( "product_{$variation_id}" );
+
+		$parent_version_before    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_before = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNotNull( $parent_version_before, 'Parent version string should exist before variation deletion' );
+		$this->assertNotNull( $variation_version_before, 'Variation version string should exist before deletion' );
+
+		// Delete variation.
+		$variation = wc_get_product( $variation_id );
+		$variation->delete( true );
+
+		$parent_version_after    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_after = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNull( $variation_version_after, 'Variation version string should be deleted after deletion' );
+		$this->assertNull( $parent_version_after, 'Parent version string should be deleted after variation deletion' );
+	}
+
+	/**
+	 * @testdox Trashing a variation invalidates both variation and parent version strings.
+	 */
+	public function test_variation_trash_invalidates_parent() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$parent_product = \WC_Helper_Product::create_variation_product();
+		$parent_id      = $parent_product->get_id();
+		$variations     = $parent_product->get_children();
+		$variation_id   = $variations[0];
+
+		// Create version strings for both parent and variation.
+		$this->version_generator->generate_version( "product_{$parent_id}" );
+		$this->version_generator->generate_version( "product_{$variation_id}" );
+
+		$parent_version_before    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_before = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNotNull( $parent_version_before, 'Parent version string should exist before variation trash' );
+		$this->assertNotNull( $variation_version_before, 'Variation version string should exist before trash' );
+
+		// Trash variation.
+		$variation = wc_get_product( $variation_id );
+		$variation->delete( false );
+
+		$parent_version_after    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_after = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNull( $variation_version_after, 'Variation version string should be deleted after trash' );
+		$this->assertNull( $parent_version_after, 'Parent version string should be deleted after variation trash' );
+	}
+
+	/**
+	 * @testdox Untrashing a variation invalidates both variation and parent version strings.
+	 */
+	public function test_variation_untrash_invalidates_parent() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$parent_product = \WC_Helper_Product::create_variation_product();
+		$parent_id      = $parent_product->get_id();
+		$variations     = $parent_product->get_children();
+		$variation_id   = $variations[0];
+
+		// Trash variation first.
+		$variation = wc_get_product( $variation_id );
+		$variation->delete( false );
+
+		// Create version strings for both parent and variation.
+		$this->version_generator->generate_version( "product_{$parent_id}" );
+		$this->version_generator->generate_version( "product_{$variation_id}" );
+
+		$parent_version_before    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_before = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNotNull( $parent_version_before, 'Parent version string should exist before variation untrash' );
+		$this->assertNotNull( $variation_version_before, 'Variation version string should exist before untrash' );
+
+		// Untrash variation.
+		wp_untrash_post( $variation_id );
+
+		$parent_version_after    = $this->version_generator->get_version( "product_{$parent_id}", false );
+		$variation_version_after = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNull( $variation_version_after, 'Variation version string should be deleted after untrash' );
+		$this->assertNull( $parent_version_after, 'Parent version string should be deleted after variation untrash' );
+	}
+
+	/**
+	 * @testdox Autosaves and revisions do not trigger invalidation.
+	 */
+	public function test_autosaves_and_revisions_are_filtered() {
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		// Create version string that should NOT be deleted by revision save.
+		$this->version_generator->generate_version( 'product_999999' );
+
+		$revision_id = wp_save_post_revision( $product->get_id() );
+
+		if ( $revision_id && ! is_wp_error( $revision_id ) ) {
+			$revision = get_post( $revision_id );
+
+			// Create a version string for the revision ID.
+			$this->version_generator->generate_version( "product_{$revision_id}" );
+
+			$this->sut->handle_save_post_product( $revision_id, $revision, true );
+
+			// Version string for the revision should still exist (not invalidated).
+			$version = $this->version_generator->get_version( "product_{$revision_id}", false );
+			$this->assertNotNull( $version, 'Revisions should not trigger invalidation' );
+		} else {
+			$this->assertTrue( true, 'Revision creation not supported in this environment' );
+		}
+	}
+
+	/**
+	 * @testdox SQL-level stock update triggers invalidation.
+	 */
+	public function test_sql_stock_update_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product_id = 123;
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before stock update' );
+
+		// Trigger stock update hook.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'woocommerce_updated_product_stock', $product_id );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after stock update' );
+	}
+
+	/**
+	 * @testdox SQL-level price update triggers invalidation.
+	 */
+	public function test_sql_price_update_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product_id = 123;
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before price update' );
+
+		// Trigger price update hook.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'woocommerce_updated_product_price', $product_id );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after price update' );
+	}
+
+	/**
+	 * @testdox SQL-level sales update triggers invalidation.
+	 */
+	public function test_sql_sales_update_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$product_id = 123;
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before sales update' );
+
+		// Trigger sales update hook.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'woocommerce_updated_product_sales', $product_id );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after sales update' );
+	}
+
+	/**
+	 * @testdox Product attribute term update invalidates products using that attribute.
+	 */
+	public function test_attribute_term_update_invalidates_products() {
+		if ( ! $this->is_cpt_data_store() ) {
+			$this->markTestSkipped( 'Attribute hooks only registered for CPT data store' );
+		}
+
+		$this->get_invalidator_with_hooks_enabled();
+
+		register_taxonomy( 'pa_test_color', array( 'product' ) );
+
+		$red_term  = wp_insert_term( 'Red', 'pa_test_color' );
+		$blue_term = wp_insert_term( 'Blue', 'pa_test_color' );
+
+		if ( is_wp_error( $red_term ) || is_wp_error( $blue_term ) ) {
+			$this->markTestSkipped( 'Could not create test terms' );
+			return;
+		}
+
+		$product   = \WC_Helper_Product::create_simple_product();
+		$attribute = new \WC_Product_Attribute();
+		$attribute->set_id( 0 );
+		$attribute->set_name( 'pa_test_color' );
+		$attribute->set_options( array( $red_term['term_id'], $blue_term['term_id'] ) );
+		$attribute->set_visible( true );
+		$attribute->set_variation( false );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		// Create term relationships so the product appears in wp_term_relationships.
+		wp_set_object_terms( $product->get_id(), array( $red_term['term_id'], $blue_term['term_id'] ), 'pa_test_color' );
+
+		// Create version string.
+		$this->version_generator->generate_version( "product_{$product->get_id()}" );
+		$version_before = $this->version_generator->get_version( "product_{$product->get_id()}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before attribute term update' );
+
+		// Trigger edited_term hook.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'edited_term', $red_term['term_id'], $red_term['term_taxonomy_id'], 'pa_test_color' );
+
+		$version_after = $this->version_generator->get_version( "product_{$product->get_id()}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after attribute term update' );
+	}
+
+	/**
+	 * @testdox The taxonomy lookup cache TTL is filterable via woocommerce_version_string_invalidator_taxonomy_lookup_ttl.
+	 */
+	public function test_taxonomy_lookup_cache_ttl_is_filterable() {
+		if ( ! $this->is_cpt_data_store() ) {
+			$this->markTestSkipped( 'Attribute hooks only registered for CPT data store' );
+		}
+
+		$filter_calls = array();
+
+		add_filter(
+			'woocommerce_version_string_invalidator_taxonomy_lookup_ttl',
+			function ( $ttl, $entity_type ) use ( &$filter_calls ) {
+				$filter_calls[] = array(
+					'ttl'         => $ttl,
+					'entity_type' => $entity_type,
+				);
+				return 600;
+			},
+			10,
+			2
+		);
+
+		$invalidator = $this->get_invalidator_with_hooks_enabled();
+
+		register_taxonomy( 'pa_filter_test', array( 'product' ) );
+		$term = wp_insert_term( 'TestTerm', 'pa_filter_test' );
+
+		if ( is_wp_error( $term ) ) {
+			$this->markTestSkipped( 'Could not create test term' );
+			return;
+		}
+
+		$product   = \WC_Helper_Product::create_simple_product();
+		$attribute = new \WC_Product_Attribute();
+		$attribute->set_id( 0 );
+		$attribute->set_name( 'pa_filter_test' );
+		$attribute->set_options( array( $term['term_id'] ) );
+		$attribute->set_visible( true );
+		$attribute->set_variation( false );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		// Clear cache to ensure the filter is called.
+		wp_cache_delete( 'wc_cache_inv_term_' . $term['term_taxonomy_id'], 'woocommerce' );
+
+		// Trigger the edited_term hook via the handler.
+		$invalidator->handle_edited_term( $term['term_id'], $term['term_taxonomy_id'], 'pa_filter_test' );
+
+		$this->assertNotEmpty( $filter_calls, 'Filter should have been called' );
+		$this->assertSame( ProductVersionStringInvalidator::DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL, $filter_calls[0]['ttl'] );
+		$this->assertSame( 'product', $filter_calls[0]['entity_type'] );
+
+		remove_all_filters( 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl' );
+	}
+
+	/**
+	 * @testdox DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL is set to 300 seconds.
+	 */
+	public function test_default_taxonomy_cache_ttl() {
+		$this->assertSame( 300, ProductVersionStringInvalidator::DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL );
+	}
+
+	/**
+	 * Helper to check if CPT data store is in use.
+	 *
+	 * @return bool
+	 */
+	private function is_cpt_data_store(): bool {
+		$data_store = \WC_Data_Store::load( 'product' );
+		return 'WC_Product_Data_Store_CPT' === $data_store->get_current_class_name();
+	}
+
+	/**
+	 * @testdox Hook handlers accept string IDs and cast them to integers.
+	 */
+	public function test_handlers_accept_string_ids() {
+		$product_id = '123';
+
+		$this->version_generator->generate_version( 'product_123' );
+		$version_before = $this->version_generator->get_version( 'product_123', false );
+		$this->assertNotNull( $version_before, 'Version string should exist before invalidation' );
+
+		$this->sut->handle_woocommerce_new_product( $product_id );
+
+		$version_after = $this->version_generator->get_version( 'product_123', false );
+		$this->assertNull( $version_after, 'Version string should be deleted after invalidation with string ID' );
+	}
+
+	/**
+	 * @testdox Hook handlers gracefully handle non-WC_Product variation objects.
+	 */
+	public function test_variation_handlers_handle_invalid_variation_object() {
+		$variation_id = 456;
+
+		$this->version_generator->generate_version( "product_{$variation_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before invalidation' );
+
+		// Call with invalid variation object - should not throw and should still invalidate the variation.
+		$this->sut->handle_woocommerce_new_product_variation( $variation_id, 'not_a_product' );
+
+		$version_after = $this->version_generator->get_version( "product_{$variation_id}", false );
+		$this->assertNull( $version_after, 'Variation version string should be deleted even with invalid variation object' );
+	}
+
+	/**
+	 * @testdox handle_woocommerce_attribute_updated gracefully handles invalid data array.
+	 */
+	public function test_attribute_updated_handler_handles_invalid_data() {
+		$this->sut->handle_woocommerce_attribute_updated( 1, 'not_an_array' );
+		$this->assertTrue( true, 'Handler should not throw with non-array data' );
+
+		$this->sut->handle_woocommerce_attribute_updated( 1, array( 'other_key' => 'value' ) );
+		$this->assertTrue( true, 'Handler should not throw with array missing attribute_name' );
+	}
+
+	/**
+	 * @testdox handle_woocommerce_attribute_deleted gracefully handles invalid taxonomy.
+	 */
+	public function test_attribute_deleted_handler_handles_invalid_taxonomy() {
+		$this->sut->handle_woocommerce_attribute_deleted( 1, 'name', null );
+		$this->assertTrue( true, 'Handler should not throw with null taxonomy' );
+
+		$this->sut->handle_woocommerce_attribute_deleted( 1, 'name', '' );
+		$this->assertTrue( true, 'Handler should not throw with empty taxonomy' );
+
+		$this->sut->handle_woocommerce_attribute_deleted( 1, 'name', array( 'taxonomy' ) );
+		$this->assertTrue( true, 'Handler should not throw with array taxonomy' );
+	}
+
+	/**
+	 * @testdox handle_edited_term gracefully handles invalid taxonomy.
+	 */
+	public function test_edited_term_handler_handles_invalid_taxonomy() {
+		$this->sut->handle_edited_term( 1, 1, null );
+		$this->assertTrue( true, 'Handler should not throw with null taxonomy' );
+
+		$this->sut->handle_edited_term( 1, 1, array( 'pa_color' ) );
+		$this->assertTrue( true, 'Handler should not throw with array taxonomy' );
+
+		$this->sut->handle_edited_term( 1, 1, 123 );
+		$this->assertTrue( true, 'Handler should not throw with integer taxonomy' );
+	}
+
+	/**
+	 * @testdox handle_delete_post gracefully handles invalid post object.
+	 */
+	public function test_delete_post_handler_handles_invalid_post_object() {
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before deletion' );
+
+		// Call with invalid post object - should fetch post by ID and still work.
+		$this->sut->handle_delete_post( $product_id, 'not_a_post' );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted even with invalid post object' );
+	}
+
+	/**
+	 * @testdox handle_save_post_product accepts string post ID.
+	 */
+	public function test_save_post_product_handler_accepts_string_id() {
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		$this->version_generator->generate_version( "product_{$product_id}" );
+		$version_before = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before save' );
+
+		$this->sut->handle_save_post_product( (string) $product_id );
+
+		$version_after = $this->version_generator->get_version( "product_{$product_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted with string ID' );
+	}
+
+	/**
+	 * @testdox SQL-level hook handlers accept string IDs.
+	 */
+	public function test_sql_hook_handlers_accept_string_ids() {
+		$product_id = '789';
+
+		// Test stock handler.
+		$this->version_generator->generate_version( 'product_789' );
+		$this->sut->handle_woocommerce_updated_product_stock( $product_id );
+		$this->assertNull(
+			$this->version_generator->get_version( 'product_789', false ),
+			'Stock handler should work with string ID'
+		);
+
+		// Test price handler.
+		$this->version_generator->generate_version( 'product_789' );
+		$this->sut->handle_woocommerce_updated_product_price( $product_id );
+		$this->assertNull(
+			$this->version_generator->get_version( 'product_789', false ),
+			'Price handler should work with string ID'
+		);
+
+		// Test sales handler.
+		$this->version_generator->generate_version( 'product_789' );
+		$this->sut->handle_woocommerce_updated_product_sales( $product_id );
+		$this->assertNull(
+			$this->version_generator->get_version( 'product_789', false ),
+			'Sales handler should work with string ID'
+		);
+	}
+}