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'
+ );
+ }
+}