Commit 3049d08e0b8 for woocommerce
commit 3049d08e0b8c73cb57e6e45092ceed8828c60d04
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date: Wed Sep 16 20:23:27 2026 +0300
Invalidate the coupon code lookup cache when a coupon is unpublished outside the CRUD (#66900)
diff --git a/plugins/woocommerce/changelog/35520-fix-stale-coupon-code-cache-on-unpublish b/plugins/woocommerce/changelog/35520-fix-stale-coupon-code-cache-on-unpublish
new file mode 100644
index 00000000000..86af093c9b1
--- /dev/null
+++ b/plugins/woocommerce/changelog/35520-fix-stale-coupon-code-cache-on-unpublish
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Invalidate the coupon code lookup cache when a coupon is unpublished or deleted outside the WooCommerce CRUD (bulk edit, WP-CLI, direct wp_update_post or wp_delete_post calls), so draft, pending, private or trashed coupons can no longer be applied from a stale cache entry on sites with a persistent object cache.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 5d7d7dad12a..1571f8fbbd7 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -43,6 +43,7 @@ use Automattic\WooCommerce\Internal\Logging\OrderLogsCleanupHelper;
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
use Automattic\WooCommerce\Caches\OrderCountCacheService;
use Automattic\WooCommerce\Caches\ProductCountCacheService;
+use Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator;
use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
@@ -403,6 +404,7 @@ final class WooCommerce {
$container->get( AddressProviderController::class );
$container->get( AbilitiesRegistry::class );
$container->get( MCPAdapterProvider::class );
+ $container->get( CouponCodeLookupInvalidator::class );
$container->get( ProductVersionStringInvalidator::class );
$container->get( OrdersVersionStringInvalidator::class );
$container->get( TaxRateVersionStringInvalidator::class );
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
index f891c2771b5..3c7a3eb42b8 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
@@ -5,6 +5,8 @@
* @package WooCommerce\DataStores
*/
+use Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator;
+
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
@@ -217,10 +219,22 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
$coupon->apply_changes();
delete_transient( 'rest_api_coupons_type_count' );
- // The `coupon_id_from_code` entry in the object cache must not exist when the coupon is not published, otherwise the coupon will remain available for use.
+ /*
+ * The `coupon_id_from_code` entry in the object cache must not exist when the coupon is not
+ * published, otherwise the coupon will remain available for use.
+ *
+ * This is not made redundant by CouponCodeLookupInvalidator's `transition_post_status`
+ * listener. The `doing_action( 'save_post' )` branch above writes the status with $wpdb
+ * directly, so that write fires no transition. Core already transitioned the post to the
+ * status it saved just before `save_post`, but the CRUD can write a different one here (a
+ * `save_post` callback setting the coupon to draft, say), and this covers the difference.
+ *
+ * The edit context is the code that was written to `post_title` above, and the one the
+ * hooks invalidate from. The view context would run it through the
+ * `woocommerce_coupon_get_code` filter and could point the delete at another key.
+ */
if ( 'publish' !== $coupon->get_status() ) {
- $hashed_code = md5( wc_strtolower( $coupon->get_code() ) );
- wp_cache_delete( WC_Cache_Helper::get_cache_prefix( 'coupons' ) . 'coupon_id_from_code_' . $hashed_code, 'coupons' );
+ wc_get_container()->get( CouponCodeLookupInvalidator::class )->invalidate( (string) $coupon->get_code( 'edit' ) );
}
do_action( 'woocommerce_update_coupon', $coupon->get_id(), $coupon );
@@ -251,8 +265,17 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
if ( $args['force_delete'] ) {
wp_delete_post( $id );
- $hashed_code = md5( wc_strtolower( $coupon->get_code() ) );
- wp_cache_delete( WC_Cache_Helper::get_cache_prefix( 'coupons' ) . 'coupon_id_from_code_' . $hashed_code, 'coupons' );
+ /*
+ * The `deleted_post` listener of CouponCodeLookupInvalidator covers this for the core
+ * data store, but it only invalidates published coupons, the only status that store
+ * caches. A custom data store that resolves further statuses caches those too and skips
+ * the read-time check, so this delete is what keeps its force deletes covered.
+ *
+ * The edit context is the code that was written to `post_title`, and the one the hooks
+ * invalidate from. The view context would run it through the
+ * `woocommerce_coupon_get_code` filter and could point the delete at another key.
+ */
+ wc_get_container()->get( CouponCodeLookupInvalidator::class )->invalidate( (string) $coupon->get_code( 'edit' ) );
$coupon->set_id( 0 );
do_action( 'woocommerce_delete_coupon', $id );
diff --git a/plugins/woocommerce/includes/wc-coupon-functions.php b/plugins/woocommerce/includes/wc-coupon-functions.php
index 0b6e3e36540..d0e57cfc845 100644
--- a/plugins/woocommerce/includes/wc-coupon-functions.php
+++ b/plugins/woocommerce/includes/wc-coupon-functions.php
@@ -10,6 +10,7 @@
defined( 'ABSPATH' ) || exit;
+use Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
@@ -112,17 +113,30 @@ function wc_get_coupon_id_by_code( $code, $exclude = 0 ) {
return 0;
}
- $data_store = WC_Data_Store::load( 'coupon' );
- // Coupon code allows spaces, which doesn't work well with some cache engines (e.g. memcached).
- $hashed_code = md5( wc_strtolower( $code ) );
- $cache_key = WC_Cache_Helper::get_cache_prefix( 'coupons' ) . 'coupon_id_from_code_' . $hashed_code;
-
- $ids = wp_cache_get( $cache_key, 'coupons' );
-
- if ( false === $ids ) {
+ $data_store = WC_Data_Store::load( 'coupon' );
+ $invalidator = wc_get_container()->get( CouponCodeLookupInvalidator::class );
+ $cache_key = $invalidator->get_cache_key( $code );
+ $cache_group = $invalidator->get_cache_group();
+
+ $ids = wp_cache_get( $cache_key, $cache_group );
+
+ /*
+ * A cached entry is only trusted while all of its coupons are still published, whichever key it
+ * was cached under. The check describes what the core data store resolves, so any other coupon
+ * data store, subclasses included, skips it and keeps the write-side invalidation as its only
+ * layer. Running it there would reject every entry such a store wrote, disabling the lookup
+ * cache for that site.
+ */
+ $is_stale = false !== $ids
+ && 'WC_Coupon_Data_Store_CPT' === $data_store->get_current_class_name()
+ && $invalidator->is_lookup_entry_stale( (array) $ids );
+
+ if ( false === $ids || $is_stale ) {
$ids = $data_store->get_ids_by_code( $code );
if ( $ids ) {
- wp_cache_set( $cache_key, $ids, 'coupons' );
+ wp_cache_set( $cache_key, $ids, $cache_group );
+ } elseif ( $is_stale ) {
+ $invalidator->invalidate( $code );
}
}
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index ab59f831f1d..e1c84d16af1 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -28,6 +28,7 @@ use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
+use Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
@@ -2262,10 +2263,7 @@ function wc_update_450_sanitize_coupons_code() {
ARRAY_A
);
- if ( empty( $coupons ) ) {
- delete_option( 'woocommerce_update_450_last_coupon_id' );
- return false;
- }
+ $codes_changed = false;
foreach ( $coupons as $key => $data ) {
$coupon_id = intval( $data['ID'] );
@@ -2288,18 +2286,36 @@ function wc_update_450_sanitize_coupons_code() {
)
);
- // Clean cache.
+ // Clean post cache.
clean_post_cache( $coupon_id );
- wp_cache_delete( WC_Cache_Helper::get_cache_prefix( 'coupons' ) . 'coupon_id_from_code_' . $data['post_title'], 'coupons' );
+ $codes_changed = true;
}
}
+ // Remember the rewrite for the last batch, which is where the lookup cache is cleaned.
+ if ( $codes_changed ) {
+ update_option( 'woocommerce_update_450_codes_changed', 'yes' );
+ }
+
// Start the run again.
if ( $coupon_id ) {
return update_option( 'woocommerce_update_450_last_coupon_id', $coupon_id );
}
delete_option( 'woocommerce_update_450_last_coupon_id' );
+
+ /*
+ * A rewritten code leaves its lookup entry behind under the old spelling, and those keys
+ * cannot be deleted one by one: wc_get_coupon_id_by_code() hashes the caller's raw input, so
+ * an entry can be keyed on a representation this function never sees. Rotating the group is
+ * what reaches all of them, and it runs here, once per migration, rather than in every batch
+ * that happened to rewrite something.
+ */
+ if ( 'yes' === get_option( 'woocommerce_update_450_codes_changed' ) ) {
+ delete_option( 'woocommerce_update_450_codes_changed' );
+ wc_get_container()->get( CouponCodeLookupInvalidator::class )->invalidate_all();
+ }
+
return false;
}
diff --git a/plugins/woocommerce/src/Internal/Caches/CouponCodeLookupInvalidator.php b/plugins/woocommerce/src/Internal/Caches/CouponCodeLookupInvalidator.php
new file mode 100644
index 00000000000..6666c7e1312
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/CouponCodeLookupInvalidator.php
@@ -0,0 +1,220 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+/**
+ * Invalidation handler for the coupon code to coupon id lookup cache.
+ *
+ * The wc_get_coupon_id_by_code() function caches the ids of the published coupons matching a
+ * given code. The entries live in the 'coupons' object cache group, under the key format this
+ * class owns, so WC_Cache_Helper::invalidate_cache_group( 'coupons' ) still reaches them.
+ *
+ * Invalidation is per coupon and happens in two layers:
+ *
+ * - On write, the hooks delete the lookup entry of the coupon's stored code whenever the coupon
+ * crosses the `publish` boundary or is deleted. Every other coupon's entry stays warm.
+ * - On read, wc_get_coupon_id_by_code() only trusts a cached entry while every coupon id in it
+ * still belongs to a published coupon (see is_lookup_entry_stale()). The check reads the core
+ * post cache, which WordPress cleans on every post write, so it also catches what deleting one
+ * key cannot reach: the same code cached under another key (raw vs. sanitized form, surrounding
+ * whitespace, or a spelling the accent-insensitive database collation treats as equal), and a
+ * lookup that started before the delete and wrote the old id back after it.
+ *
+ * Known limitations:
+ *
+ * - The read layer describes what WC_Coupon_Data_Store_CPT resolves, so wc_get_coupon_id_by_code()
+ * only runs it while that exact data store is in use. A custom coupon data store registered
+ * through the `woocommerce_data_stores` filter may resolve further statuses, or coupons that are
+ * not posts at all, and keeps the write layer as its only coverage (what it had before this class
+ * existed). Rejecting its entries instead would disable the lookup cache for those sites, since
+ * every read would write an entry the next read throws away. The check is on the class name, so
+ * a store that extends WC_Coupon_Data_Store_CPT is treated the same way, even one that inherits
+ * get_ids_by_code() unchanged and would pass the check.
+ * - Renaming the code of a coupon that stays published crosses no boundary, so the old code
+ * keeps resolving to the coupon until the cache entry expires or the coupon is unpublished.
+ * This is pre-existing behaviour, not something this class introduced. The read layer does not
+ * close it either: is_lookup_entry_stale() checks that the ids still belong to published
+ * coupons, not that they still carry the code the entry was cached under. Comparing the two in
+ * PHP would reject the aliases the database collation resolves (see the third limitation), and
+ * turn every read of such an entry into a re-resolve.
+ * - wc_get_coupon_id_by_code() hashes the caller's raw input while `post_title` holds the
+ * sanitized code, so for codes with kses-escapable characters the two are different keys.
+ * Publishing a newer coupon under a code that is already cached under such a raw alias does not
+ * reach the alias, so the older coupon keeps winning that lookup until its entry is invalidated.
+ * - The read-side check is only as fresh as the core post cache. A post cache entry primed by a
+ * read that raced the unpublishing write vouches for the coupon until the next post write, the
+ * same way it would for any other post type.
+ *
+ * @since 11.2.0
+ */
+class CouponCodeLookupInvalidator {
+
+ /**
+ * The object cache group the lookup entries are stored in.
+ *
+ * @var string
+ */
+ private const CACHE_GROUP = 'coupons';
+
+ /**
+ * Register the WordPress hooks that cover coupon changes made outside the WC_Coupon CRUD.
+ *
+ * @return void
+ *
+ * @since 11.2.0
+ *
+ * @internal
+ */
+ final public function init(): void {
+ add_action( 'transition_post_status', array( $this, 'handle_transition_post_status' ), 10, 3 );
+ add_action( 'deleted_post', array( $this, 'handle_deleted_post' ), 10, 2 );
+ }
+
+ /**
+ * Get the object cache group the lookup entries are stored in.
+ *
+ * @return string The cache group.
+ *
+ * @since 11.2.0
+ */
+ public function get_cache_group(): string {
+ return self::CACHE_GROUP;
+ }
+
+ /**
+ * Get the object cache key holding the ids of the published coupons with the given code.
+ *
+ * The key must be used with the cache group returned by get_cache_group().
+ *
+ * @param string $code Coupon code.
+ * @return string The cache key.
+ *
+ * @since 11.2.0
+ */
+ public function get_cache_key( string $code ): string {
+ // Coupon code allows spaces, which doesn't work well with some cache engines (e.g. memcached), hence the hashing.
+ $hashed_code = md5( wc_strtolower( $code ) );
+
+ return \WC_Cache_Helper::get_cache_prefix( self::CACHE_GROUP ) . 'coupon_id_from_code_' . $hashed_code;
+ }
+
+ /**
+ * Delete the cached coupon ids for the given code.
+ *
+ * @param string $code Coupon code.
+ * @return void
+ *
+ * @since 11.2.0
+ */
+ public function invalidate( string $code ): void {
+ if ( '' === $code ) {
+ return;
+ }
+
+ wp_cache_delete( $this->get_cache_key( $code ), self::CACHE_GROUP );
+ }
+
+ /**
+ * Invalidate every code to coupon id lookup entry at once.
+ *
+ * Rotates the 'coupons' group prefix, which strands all previously cached lookup keys
+ * regardless of the code representation they were primed under, and the meta cache of every
+ * WC_Coupon along with them. Meant for bulk changes such as migrations that rewrite many
+ * codes; single coupon changes use invalidate().
+ *
+ * @return void
+ *
+ * @since 11.2.0
+ */
+ public function invalidate_all(): void {
+ \WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP );
+ }
+
+ /**
+ * Check whether a cached lookup entry can no longer be trusted.
+ *
+ * An entry is stale as soon as one of its ids no longer belongs to a published coupon, i.e.
+ * the coupon was unpublished, trashed or deleted after the entry was written. The posts are
+ * read from the core post cache, which every post write cleans, so the check does not depend
+ * on which key the entry was cached under. A cold post cache costs one query for the whole
+ * entry, a warm one none.
+ *
+ * "Published coupon" is what WC_Coupon_Data_Store_CPT::get_ids_by_code() resolves, so callers
+ * must only apply this to entries that data store wrote. See the known limitations above.
+ *
+ * @param array $ids The coupon ids stored in the lookup entry.
+ * @return bool True if the entry must not be used.
+ *
+ * @since 11.2.0
+ */
+ public function is_lookup_entry_stale( array $ids ): bool {
+ $ids = array_filter( array_map( 'absint', $ids ) );
+
+ if ( empty( $ids ) ) {
+ return true;
+ }
+
+ // A code can be cached with more than one id. Fetch the missing posts in one query rather than one each.
+ _prime_post_caches( $ids, false, false );
+
+ foreach ( $ids as $id ) {
+ $post = get_post( $id );
+
+ if ( ! $post instanceof \WP_Post || 'shop_coupon' !== $post->post_type || 'publish' !== $post->post_status ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Delete the lookup entry of a coupon that crosses the publish boundary.
+ *
+ * Only transitions into or out of `publish` can change which ids a code resolves to, so
+ * other status changes (e.g. draft to pending) are ignored. wp_insert_post() reports `new`
+ * as the old status, so a brand-new published coupon takes this path too, which is what lets
+ * a newer coupon published under an already cached code win the lookup.
+ *
+ * @param string $new_status New post status.
+ * @param string $old_status Old post status.
+ * @param \WP_Post $post Post object.
+ * @return void
+ *
+ * @since 11.2.0
+ *
+ * @internal
+ */
+ public function handle_transition_post_status( $new_status, $old_status, $post ): void {
+ if ( ! $post instanceof \WP_Post || 'shop_coupon' !== $post->post_type || $new_status === $old_status ) {
+ return;
+ }
+
+ if ( 'publish' === $old_status || 'publish' === $new_status ) {
+ $this->invalidate( $post->post_title );
+ }
+ }
+
+ /**
+ * Delete the lookup entry of a published coupon post that is deleted.
+ *
+ * The core data store only caches published coupons, so deleting a coupon in any other status
+ * cannot strand one of its entries. A custom data store can cache other statuses too, which is
+ * why WC_Coupon_Data_Store_CPT::delete() also invalidates on force delete.
+ *
+ * @param int $post_id Post id.
+ * @param \WP_Post $post Post object.
+ * @return void
+ *
+ * @since 11.2.0
+ *
+ * @internal
+ */
+ public function handle_deleted_post( $post_id, $post ): void {
+ if ( $post instanceof \WP_Post && 'shop_coupon' === $post->post_type && 'publish' === $post->post_status ) {
+ $this->invalidate( $post->post_title );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/coupon/data-store.php b/plugins/woocommerce/tests/legacy/unit-tests/coupon/data-store.php
index a9a4be6579d..544911382fe 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/coupon/data-store.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/coupon/data-store.php
@@ -55,22 +55,57 @@ class WC_Tests_Coupon_Data_Store extends WC_Unit_Test_Case {
* Test coupon accurately cleans up object cache upon deletion.
*/
public function test_coupon_cache_deletion() {
- $coupon = WC_Helper_Coupon::create_coupon( 'test' );
+ $coupon = WC_Helper_Coupon::create_coupon( 'test' );
+ $code = $coupon->get_code();
+ $invalidator = wc_get_container()->get( \Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator::class );
// Prime the cache.
- $hashed_code = md5( wc_strtolower( $coupon->get_code() ) );
- $cache_name = WC_Cache_Helper::get_cache_prefix( 'coupons' ) . 'coupon_id_from_code_' . $hashed_code;
- wc_get_coupon_id_by_code( $coupon->get_code() );
+ wc_get_coupon_id_by_code( $code );
- $ids = wp_cache_get( $cache_name, 'coupons' );
+ $cache_name = $invalidator->get_cache_key( $code );
+ $ids = wp_cache_get( $cache_name, 'coupons' );
- $this->assertNotEquals( false, $ids, sprintf( 'Object cache for %s was not primed correctly.', $cache_name ) );
+ $this->assertNotFalse( $ids, sprintf( 'Object cache for %s was not primed correctly.', $cache_name ) );
$coupon->delete( true );
- $ids = wp_cache_get( $cache_name, 'coupons' );
+ // Deleting a published coupon removes its lookup entry, so the code no longer resolves from the object cache.
+ $ids = wp_cache_get( $invalidator->get_cache_key( $code ), 'coupons' );
- $this->assertEquals( false, $ids, sprintf( 'Object cache for %s was not removed upon deletion of coupon.', $cache_name ) );
+ $this->assertFalse( $ids, 'Object cache should not resolve the coupon code after deletion.' );
+ }
+
+ /**
+ * Test the data store cleans up the code lookup cache when it unpublishes a coupon during `save_post`.
+ *
+ * On that path the status is written with $wpdb directly, so no `transition_post_status` fires
+ * for CouponCodeLookupInvalidator to listen to and the data store is what invalidates.
+ */
+ public function test_coupon_cache_deletion_when_unpublished_during_save_post() {
+ $coupon = WC_Helper_Coupon::create_coupon( 'save-post-draft' );
+ $code = $coupon->get_code();
+ $invalidator = wc_get_container()->get( \Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator::class );
+
+ // Prime the cache.
+ wc_get_coupon_id_by_code( $code );
+ $cache_name = $invalidator->get_cache_key( $code );
+ $this->assertNotFalse( wp_cache_get( $cache_name, 'coupons' ), sprintf( 'Object cache for %s was not primed correctly.', $cache_name ) );
+
+ // Unpublish through the CRUD while `save_post` is running, the way a third party callback would.
+ $unpublish = function () use ( $coupon ) {
+ $coupon->set_status( 'draft' );
+ $coupon->save();
+ };
+ add_action( 'save_post', $unpublish );
+ wp_update_post(
+ array(
+ 'ID' => $coupon->get_id(),
+ 'post_excerpt' => 'Updated description',
+ )
+ );
+
+ $this->assertEquals( 'draft', get_post_status( $coupon->get_id() ), 'The coupon should have been unpublished during save_post.' );
+ $this->assertFalse( wp_cache_get( $cache_name, 'coupons' ), 'Object cache should not resolve the coupon code after it is unpublished during save_post.' );
}
/**
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index b1309535183..b9e25ef4e5c 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -462,6 +462,91 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
$this->assertTrue( $changes['customer_stock_notifications'] );
}
+ /**
+ * @testdox Migration sanitizes dirty coupon codes and invalidates every coupon code lookup entry once, on the last batch.
+ */
+ public function test_wc_update_450_sanitize_coupons_code_invalidates_the_lookup_cache(): void {
+ global $wpdb;
+
+ $coupon_id = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => 'dirty-code',
+ 'post_status' => 'publish',
+ )
+ );
+
+ // The migration exists for titles WordPress would not store today, so write the raw one directly.
+ $wpdb->update( $wpdb->posts, array( 'post_title' => ' dirty-code ' ), array( 'ID' => $coupon_id ) );
+ clean_post_cache( $coupon_id );
+
+ // Start the batch at this coupon, so the assertions do not depend on what else is in the database.
+ update_option( 'woocommerce_update_450_last_coupon_id', $coupon_id - 1 );
+
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ $prefix_before = WC_Cache_Helper::get_cache_prefix( 'coupons' );
+
+ $this->assertTrue( (bool) wc_update_450_sanitize_coupons_code(), 'The migration should ask for another batch while coupons remain.' );
+
+ $this->assertSame( 'dirty-code', get_post( $coupon_id )->post_title, 'The migration should have sanitized the code.' );
+ $this->assertSame( 'yes', get_option( 'woocommerce_update_450_codes_changed' ), 'The rewrite should be recorded for the last batch.' );
+ $this->assertSame(
+ $prefix_before,
+ WC_Cache_Helper::get_cache_prefix( 'coupons' ),
+ 'A batch that rewrites a code should not rotate the coupons group on its own.'
+ );
+
+ $this->run_wc_update_450_sanitize_coupons_code_to_completion();
+
+ $this->assertNotSame(
+ $prefix_before,
+ WC_Cache_Helper::get_cache_prefix( 'coupons' ),
+ 'The last batch should strand the lookup entries the rewritten codes were cached under.'
+ );
+ $this->assertFalse( get_option( 'woocommerce_update_450_codes_changed' ), 'The migration should clean up the flag it persisted.' );
+ }
+
+ /**
+ * @testdox Migration keeps the coupon code lookup cache when there is nothing to sanitize.
+ */
+ public function test_wc_update_450_sanitize_coupons_code_keeps_the_lookup_cache_when_no_code_changes(): void {
+ $coupon_id = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => 'clean-code',
+ 'post_status' => 'publish',
+ )
+ );
+
+ update_option( 'woocommerce_update_450_last_coupon_id', $coupon_id - 1 );
+
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ $prefix_before = WC_Cache_Helper::get_cache_prefix( 'coupons' );
+
+ $this->run_wc_update_450_sanitize_coupons_code_to_completion();
+
+ $this->assertSame(
+ $prefix_before,
+ WC_Cache_Helper::get_cache_prefix( 'coupons' ),
+ 'A run that rewrites no code should leave the warm lookup entries alone.'
+ );
+ }
+
+ /**
+ * Run the 4.5.0 coupon code migration until it reports there is nothing left to process.
+ *
+ * @return void
+ */
+ private function run_wc_update_450_sanitize_coupons_code_to_completion(): void {
+ $batches = 0;
+
+ while ( wc_update_450_sanitize_coupons_code() ) {
+ $this->assertLessThan( 100, ++$batches, 'The coupon code migration should reach its last batch.' );
+ }
+ }
+
/**
* @testdox Migration deletes the retired Surface Cart and Checkout note.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/CouponCodeLookupInvalidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/CouponCodeLookupInvalidatorTest.php
new file mode 100644
index 00000000000..0b4df150358
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/CouponCodeLookupInvalidatorTest.php
@@ -0,0 +1,617 @@
+<?php
+/**
+ * CouponCodeLookupInvalidatorTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator;
+use WC_Helper_Coupon;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the CouponCodeLookupInvalidator class.
+ */
+class CouponCodeLookupInvalidatorTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var CouponCodeLookupInvalidator
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = wc_get_container()->get( CouponCodeLookupInvalidator::class );
+ }
+
+ /**
+ * @testdox Should invalidate the coupon code lookup cache when a coupon is unpublished outside the CRUD.
+ * @dataProvider unpublished_coupon_status_data
+ *
+ * @param string $new_status The status the coupon is updated to.
+ */
+ public function test_unpublishing_a_coupon_outside_the_crud_busts_the_lookup_cache( string $new_status ): void {
+ $code = 'cache-bust-' . $new_status;
+ $coupon = WC_Helper_Coupon::create_coupon( $code );
+
+ wc_get_coupon_id_by_code( $code );
+ $this->assertNotFalse( wp_cache_get( $this->sut->get_cache_key( $code ), 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ wp_update_post(
+ array(
+ 'ID' => $coupon->get_id(),
+ 'post_status' => $new_status,
+ )
+ );
+
+ $this->assertFalse( wp_cache_get( $this->sut->get_cache_key( $code ), 'coupons' ), "The coupon code lookup cache should be invalidated when the coupon transitions to {$new_status}" );
+ $this->assertSame( 0, wc_get_coupon_id_by_code( $code ), "A {$new_status} coupon should not be resolvable by code" );
+ }
+
+ /**
+ * Data provider for unpublished coupon status tests.
+ *
+ * @return array
+ */
+ public function unpublished_coupon_status_data() {
+ return array(
+ 'draft' => array( 'draft' ),
+ 'pending' => array( 'pending' ),
+ 'private' => array( 'private' ),
+ 'trash' => array( 'trash' ),
+ );
+ }
+
+ /**
+ * @testdox Should invalidate the coupon code lookup cache when a coupon is deleted outside the CRUD.
+ */
+ public function test_deleting_a_coupon_outside_the_crud_busts_the_lookup_cache(): void {
+ $code = 'cache-bust-delete';
+ $coupon = WC_Helper_Coupon::create_coupon( $code );
+
+ wc_get_coupon_id_by_code( $code );
+ $this->assertNotFalse( wp_cache_get( $this->sut->get_cache_key( $code ), 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ wp_delete_post( $coupon->get_id(), true );
+
+ $this->assertFalse( wp_cache_get( $this->sut->get_cache_key( $code ), 'coupons' ), 'The coupon code lookup cache should be invalidated when the coupon post is deleted' );
+ $this->assertSame( 0, wc_get_coupon_id_by_code( $code ), 'A deleted coupon should not be resolvable by code' );
+ }
+
+ /**
+ * @testdox Should keep the coupon code lookup cache of other coupons when a coupon is unpublished.
+ */
+ public function test_unpublishing_a_coupon_keeps_the_lookup_cache_of_other_coupons(): void {
+ $unpublished = WC_Helper_Coupon::create_coupon( 'cache-keep-unpublished' );
+ $kept = WC_Helper_Coupon::create_coupon( 'cache-keep-kept' );
+
+ wc_get_coupon_id_by_code( 'cache-keep-unpublished' );
+ wc_get_coupon_id_by_code( 'cache-keep-kept' );
+ $kept_key = $this->sut->get_cache_key( 'cache-keep-kept' );
+ $this->assertNotFalse( wp_cache_get( $kept_key, 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ wp_update_post(
+ array(
+ 'ID' => $unpublished->get_id(),
+ 'post_status' => 'draft',
+ )
+ );
+
+ $this->assertSame( $kept_key, $this->sut->get_cache_key( 'cache-keep-kept' ), 'Unpublishing a coupon should not rotate the coupons group prefix' );
+ $this->assertNotFalse( wp_cache_get( $kept_key, 'coupons' ), "Unpublishing a coupon should not flush another coupon's lookup entry" );
+ $this->assertSame( 0, wc_get_coupon_id_by_code( 'cache-keep-unpublished' ), 'The unpublished coupon should not be resolvable by code' );
+ $this->assertSame( $kept->get_id(), wc_get_coupon_id_by_code( 'cache-keep-kept' ), 'The other coupon should still resolve by code' );
+ }
+
+ /**
+ * Data provider for the statuses a deleted coupon can be in without ever having been cached.
+ *
+ * @return array
+ */
+ public function never_cacheable_coupon_status_data() {
+ return array(
+ 'draft' => array( 'draft' ),
+ 'auto-draft' => array( 'auto-draft' ),
+ 'pending' => array( 'pending' ),
+ 'private' => array( 'private' ),
+ 'trash' => array( 'trash' ),
+ );
+ }
+
+ /**
+ * @testdox Should keep the coupon code lookup cache when a coupon in another status is deleted.
+ * @dataProvider never_cacheable_coupon_status_data
+ *
+ * @param string $status The status of the coupon being deleted.
+ */
+ public function test_deleting_a_non_published_coupon_keeps_the_lookup_cache( string $status ): void {
+ $code = 'cache-keep-delete-' . $status;
+ WC_Helper_Coupon::create_coupon( $code );
+
+ wc_get_coupon_id_by_code( $code );
+ $cache_key = $this->sut->get_cache_key( $code );
+ $this->assertNotFalse( wp_cache_get( $cache_key, 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ $doomed_id = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => 'cache-keep-doomed-' . $status,
+ 'post_status' => $status,
+ )
+ );
+ wp_delete_post( $doomed_id, true );
+
+ $this->assertSame( $cache_key, $this->sut->get_cache_key( $code ), "Deleting a {$status} coupon should not rotate the coupons group prefix" );
+ $this->assertNotFalse( wp_cache_get( $cache_key, 'coupons' ), "Deleting a {$status} coupon should not flush an unrelated lookup entry" );
+ }
+
+ /**
+ * @testdox Should keep the coupon code lookup cache of other coupons when a new coupon is published.
+ */
+ public function test_publishing_a_coupon_keeps_the_lookup_cache_of_other_coupons(): void {
+ $code = 'cache-keep-on-create';
+ WC_Helper_Coupon::create_coupon( $code );
+
+ wc_get_coupon_id_by_code( $code );
+ $cache_key = $this->sut->get_cache_key( $code );
+ $this->assertNotFalse( wp_cache_get( $cache_key, 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ WC_Helper_Coupon::create_coupon( 'cache-keep-on-create-other' );
+
+ $this->assertSame( $cache_key, $this->sut->get_cache_key( $code ), 'Publishing a coupon should not rotate the coupons group prefix' );
+ $this->assertNotFalse( wp_cache_get( $cache_key, 'coupons' ), "Publishing a coupon should not flush another coupon's lookup entry" );
+ }
+
+ /**
+ * @testdox Should invalidate the lookup cache of a code when a newer coupon is published under it.
+ */
+ public function test_publishing_a_duplicate_code_busts_the_lookup_cache_of_that_code(): void {
+ $code = 'cache-bust-duplicate';
+
+ // Backdated so the "newest wins" ordering of get_ids_by_code() is deterministic.
+ $older_id = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => $code,
+ 'post_status' => 'publish',
+ 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( current_time( 'mysql' ) ) - HOUR_IN_SECONDS ),
+ 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ),
+ )
+ );
+
+ $this->assertSame( $older_id, wc_get_coupon_id_by_code( $code ), 'The older coupon should resolve while it is the only one' );
+
+ $newer_id = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => $code,
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertSame( $newer_id, wc_get_coupon_id_by_code( $code ), 'The newest coupon published under a duplicated code should win the lookup' );
+
+ wp_update_post(
+ array(
+ 'ID' => $newer_id,
+ 'post_status' => 'draft',
+ )
+ );
+
+ $this->assertSame( $older_id, wc_get_coupon_id_by_code( $code ), 'The older coupon should win the lookup again once the newer one is unpublished' );
+ }
+
+ /**
+ * @testdox Invalidating the 'coupons' cache group should still reach the lookup entries.
+ */
+ public function test_invalidating_the_coupons_cache_group_busts_the_lookup_cache(): void {
+ $code = 'cache-bust-group';
+ WC_Helper_Coupon::create_coupon( $code );
+
+ wc_get_coupon_id_by_code( $code );
+ $cache_key = $this->sut->get_cache_key( $code );
+ $this->assertNotFalse( wp_cache_get( $cache_key, $this->sut->get_cache_group() ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ \WC_Cache_Helper::invalidate_cache_group( 'coupons' );
+
+ $this->assertNotSame( $cache_key, $this->sut->get_cache_key( $code ), "Rotating the 'coupons' group prefix should strand the lookup keys built under it" );
+ }
+
+ /**
+ * @testdox Invalidating every lookup entry at once should strand every previously built lookup key.
+ */
+ public function test_invalidate_all_strands_previous_keys(): void {
+ $raw_key_before = $this->sut->get_cache_key( 'probe&test' );
+ $sanitized_key_before = $this->sut->get_cache_key( 'probe&test' );
+
+ $this->sut->invalidate_all();
+
+ $this->assertNotSame( $raw_key_before, $this->sut->get_cache_key( 'probe&test' ), 'The raw-alias lookup key should be unreachable afterwards' );
+ $this->assertNotSame( $sanitized_key_before, $this->sut->get_cache_key( 'probe&test' ), 'The sanitized-alias lookup key should be unreachable afterwards' );
+ }
+
+ /**
+ * @testdox Should keep the coupon code lookup cache when a published coupon is updated and stays published.
+ */
+ public function test_updating_a_published_coupon_keeps_the_lookup_cache(): void {
+ $code = 'cache-keep-published';
+ $coupon = WC_Helper_Coupon::create_coupon( $code );
+
+ wc_get_coupon_id_by_code( $code );
+ $cache_key = $this->sut->get_cache_key( $code );
+ $this->assertNotFalse( wp_cache_get( $cache_key, 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ wp_update_post(
+ array(
+ 'ID' => $coupon->get_id(),
+ 'post_excerpt' => 'Updated description',
+ )
+ );
+
+ $this->assertSame( $cache_key, $this->sut->get_cache_key( $code ), 'The coupons group prefix should not rotate when the coupon stays published' );
+ $this->assertNotFalse( wp_cache_get( $cache_key, 'coupons' ), 'The coupon code lookup cache should be kept when the coupon stays published' );
+ $this->assertSame( $coupon->get_id(), wc_get_coupon_id_by_code( $code ), 'A published coupon should remain resolvable by code' );
+ }
+
+ /**
+ * @testdox A primed lookup entry for a published coupon should be served without querying the database.
+ */
+ public function test_a_fresh_lookup_entry_is_served_without_a_query(): void {
+ global $wpdb;
+
+ $code = 'cache-hit';
+ $coupon = WC_Helper_Coupon::create_coupon( $code );
+
+ wc_get_coupon_id_by_code( $code );
+ // Make sure the post cache is primed too, like it is after the coupon is read.
+ get_post( $coupon->get_id() );
+
+ $queries_before = $wpdb->num_queries;
+ $this->assertSame( $coupon->get_id(), wc_get_coupon_id_by_code( $code ), 'The published coupon should resolve by code' );
+ $this->assertSame( $queries_before, $wpdb->num_queries, 'A fresh lookup entry should be served from the object cache without a query' );
+ }
+
+ /**
+ * @testdox A lookup entry holding several coupons should be validated in a single query when the post cache is cold.
+ */
+ public function test_a_lookup_entry_of_several_coupons_is_validated_in_one_query(): void {
+ global $wpdb;
+
+ $code = 'cache-hit-batch';
+ $ids = array();
+ for ( $i = 0; $i < 5; $i++ ) {
+ $ids[] = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => $code,
+ 'post_status' => 'publish',
+ )
+ );
+ }
+
+ wc_get_coupon_id_by_code( $code );
+ $cached_ids = array_map( 'absint', (array) wp_cache_get( $this->sut->get_cache_key( $code ), 'coupons' ) );
+ sort( $cached_ids );
+ $this->assertSame( $ids, $cached_ids, 'Every published coupon sharing the code should be cached under it' );
+
+ // Cold post cache with a warm lookup entry, what a persistent object cache leaves behind between requests.
+ foreach ( $ids as $id ) {
+ wp_cache_delete( $id, 'posts' );
+ }
+
+ $queries_before = $wpdb->num_queries;
+ $this->assertContains( wc_get_coupon_id_by_code( $code ), $ids, 'One of the published coupons should resolve by code' );
+ $this->assertSame( $queries_before + 1, $wpdb->num_queries, 'Validating a cached entry should fetch the posts it holds in one query, not one query per coupon' );
+ }
+
+ /**
+ * Make a coupon data store that resolves more than the published coupons the read-time check describes.
+ */
+ private function use_private_resolving_coupon_data_store(): void {
+ $store = new class() extends \WC_Coupon_Data_Store_CPT {
+ /**
+ * Resolve private coupons alongside the published ones.
+ *
+ * @param string $code Coupon code.
+ * @return array Array of ids.
+ */
+ public function get_ids_by_code( $code ) {
+ global $wpdb;
+ return $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM $wpdb->posts WHERE LOWER(post_title) = LOWER(%s) AND post_type = 'shop_coupon' AND post_status IN ( 'publish', 'private' ) ORDER BY post_date DESC",
+ wc_sanitize_coupon_code( $code )
+ )
+ );
+ }
+ };
+
+ add_filter(
+ 'woocommerce_coupon_data_store',
+ function () use ( $store ) {
+ return $store;
+ }
+ );
+ }
+
+ /**
+ * @testdox A custom coupon data store should keep serving its lookup entries instead of having them rejected on every read.
+ */
+ public function test_a_custom_coupon_data_store_keeps_its_lookup_entries(): void {
+ global $wpdb;
+
+ $code = 'custom-store-code';
+ $coupon = WC_Helper_Coupon::create_coupon( $code );
+ wp_update_post(
+ array(
+ 'ID' => $coupon->get_id(),
+ 'post_status' => 'private',
+ )
+ );
+
+ $this->use_private_resolving_coupon_data_store();
+
+ // Prime the lookup entry and the post cache the read-time check would have consulted.
+ $this->assertSame( $coupon->get_id(), wc_get_coupon_id_by_code( $code ), 'The custom data store should resolve the private coupon' );
+ get_post( $coupon->get_id() );
+
+ $queries_before = $wpdb->num_queries;
+ $this->assertSame( $coupon->get_id(), wc_get_coupon_id_by_code( $code ), 'The custom data store should resolve the private coupon' );
+ $this->assertSame( $queries_before, $wpdb->num_queries, "A custom data store's lookup entry should be served from the object cache, not thrown away and re-queried on every read" );
+ }
+
+ /**
+ * @testdox Force deleting a coupon through the CRUD should invalidate its lookup entry even when the coupon was never published.
+ */
+ public function test_force_deleting_a_coupon_of_a_custom_data_store_busts_the_lookup_cache(): void {
+ $code = 'custom-store-force-delete';
+ $id = WC_Helper_Coupon::create_coupon( $code )->get_id();
+ wp_update_post(
+ array(
+ 'ID' => $id,
+ 'post_status' => 'private',
+ )
+ );
+
+ $this->use_private_resolving_coupon_data_store();
+
+ $coupon = new \WC_Coupon( $id );
+ $this->assertSame( $id, wc_get_coupon_id_by_code( $code ), 'The custom data store should resolve the private coupon' );
+
+ $coupon->delete( true );
+
+ $this->assertFalse( wp_cache_get( $this->sut->get_cache_key( $code ), 'coupons' ), 'Force deleting a coupon should invalidate its lookup entry whatever status it was in' );
+ $this->assertSame( 0, wc_get_coupon_id_by_code( $code ), 'A force deleted coupon should not be resolvable by code' );
+ }
+
+ /**
+ * Data provider for the ways a cached coupon can stop being published.
+ *
+ * @return array
+ */
+ public function coupon_removal_data() {
+ return array(
+ 'unpublished' => array(
+ function ( int $id ) {
+ wp_update_post(
+ array(
+ 'ID' => $id,
+ 'post_status' => 'draft',
+ )
+ );
+ },
+ ),
+ 'trashed' => array(
+ function ( int $id ) {
+ wp_trash_post( $id );
+ },
+ ),
+ 'deleted' => array(
+ function ( int $id ) {
+ wp_delete_post( $id, true );
+ },
+ ),
+ );
+ }
+
+ /**
+ * @testdox A lookup entry written after the coupon stopped being published is rejected at read time, and unrelated coupon meta survives.
+ * @dataProvider coupon_removal_data
+ *
+ * @param callable $remove Removes the coupon from the published set.
+ */
+ public function test_a_stale_lookup_entry_is_rejected_at_read_time( callable $remove ): void {
+ $code = 'race-code';
+ $coupon = WC_Helper_Coupon::create_coupon( $code );
+
+ // A second, unrelated published coupon whose meta cache must survive the invalidation.
+ $unrelated_coupon = WC_Helper_Coupon::create_coupon( 'unrelated-code' );
+ $unrelated_coupon->read_meta_data( true );
+ $unrelated_meta_key = $unrelated_coupon->get_meta_cache_key();
+
+ wc_get_coupon_id_by_code( $code );
+ $lookup_key = $this->sut->get_cache_key( $code );
+ $this->assertNotFalse( wp_cache_get( $lookup_key, 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+ $this->assertNotFalse( wp_cache_get( $unrelated_meta_key, 'coupons' ), 'The unrelated coupon meta cache should be primed' );
+
+ $remove( $coupon->get_id() );
+
+ // Simulate an in-flight lookup completing and writing the stale id back after the invalidation.
+ wp_cache_set( $lookup_key, array( $coupon->get_id() ), 'coupons' );
+
+ $this->assertSame( 0, wc_get_coupon_id_by_code( $code ), 'A late write must not resurrect a coupon that is no longer published' );
+ $this->assertFalse( wp_cache_get( $lookup_key, 'coupons' ), 'The rejected lookup entry should be removed from the object cache' );
+ $this->assertNotFalse( wp_cache_get( $unrelated_meta_key, 'coupons' ), 'Invalidating a lookup entry must not flush unrelated coupon meta' );
+ }
+
+ /**
+ * @testdox A stale lookup entry whose code still resolves is overwritten with the ids it resolves to now.
+ */
+ public function test_a_stale_lookup_entry_that_still_resolves_is_overwritten(): void {
+ $code = 'stale-overwrite';
+
+ // Backdated so the "newest wins" ordering of get_ids_by_code() is deterministic.
+ $older_id = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => $code,
+ 'post_status' => 'publish',
+ 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( current_time( 'mysql' ) ) - HOUR_IN_SECONDS ),
+ 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ),
+ )
+ );
+ $newer_id = wp_insert_post(
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_title' => $code,
+ 'post_status' => 'publish',
+ )
+ );
+
+ $cache_key = $this->sut->get_cache_key( $code );
+
+ $this->assertSame( $newer_id, wc_get_coupon_id_by_code( $code ), 'The newest coupon should win the lookup while both are published' );
+ $this->assertSame(
+ array( $newer_id, $older_id ),
+ array_map( 'absint', (array) wp_cache_get( $cache_key, 'coupons' ) ),
+ 'Both published coupons should be cached under the code'
+ );
+
+ wp_update_post(
+ array(
+ 'ID' => $older_id,
+ 'post_status' => 'draft',
+ )
+ );
+
+ // Simulate an in-flight lookup writing the pre-unpublish list back after the invalidation.
+ wp_cache_set( $cache_key, array( $newer_id, $older_id ), 'coupons' );
+
+ $this->assertSame( $newer_id, wc_get_coupon_id_by_code( $code ), 'The still published coupon should resolve once the stale entry is rejected' );
+ $this->assertSame(
+ array( $newer_id ),
+ array_map( 'absint', (array) wp_cache_get( $cache_key, 'coupons' ) ),
+ 'A rejected entry whose code still resolves should be overwritten with the remaining ids, not deleted'
+ );
+ }
+
+ /**
+ * Data provider for the representations of a code that resolve the same coupon but are cached under another key.
+ *
+ * Each case gives the stored (sanitized) code and the alias a caller could pass to wc_get_coupon_id_by_code().
+ *
+ * @return array
+ */
+ public function code_alias_data() {
+ return array(
+ 'raw entity' => array( 'alias&test', 'alias&test' ),
+ 'trailing whitespace' => array( 'alias-space', 'alias-space ' ),
+ 'leading whitespace' => array( 'alias-space', ' alias-space' ),
+ 'accent' => array( 'alias-cafe', 'alias-café' ),
+ 'decomposed accent' => array( 'alias-cafe', "alias-cafe\u{0301}" ),
+ );
+ }
+
+ /**
+ * @testdox Unpublishing a coupon should invalidate every cached representation of its code, not just the key of its stored title.
+ * @dataProvider code_alias_data
+ *
+ * @param string $stored_code The code as it is stored in the coupon's post title.
+ * @param string $alias Another representation of the code that resolves the same coupon.
+ */
+ public function test_unpublishing_a_coupon_invalidates_every_cached_representation_of_its_code( string $stored_code, string $alias ): void {
+ $coupon = WC_Helper_Coupon::create_coupon( $stored_code );
+
+ $alias_key = $this->sut->get_cache_key( $alias );
+ $this->assertNotSame( $this->sut->get_cache_key( $stored_code ), $alias_key, 'The alias should be cached under a different key than the stored code' );
+
+ if ( $coupon->get_id() !== wc_get_coupon_id_by_code( $alias ) ) {
+ $this->markTestSkipped( 'The database collation of this test environment does not resolve the alias, so there is nothing to invalidate.' );
+ }
+ $this->assertNotFalse( wp_cache_get( $alias_key, 'coupons' ), 'The alias lookup should be primed while the coupon is published' );
+
+ wp_update_post(
+ array(
+ 'ID' => $coupon->get_id(),
+ 'post_status' => 'draft',
+ )
+ );
+
+ $this->assertSame( 0, wc_get_coupon_id_by_code( $alias ), 'The alias must not resolve the coupon once it is unpublished' );
+ $this->assertFalse( wp_cache_get( $alias_key, 'coupons' ), 'The stale alias lookup entry should be removed from the object cache' );
+ }
+
+ /**
+ * @testdox Renaming and unpublishing a coupon in one update should stop its old code from resolving.
+ */
+ public function test_renaming_and_unpublishing_a_coupon_in_one_update_rejects_the_old_code_entry(): void {
+ $old_code = 'rename-unpublish-old';
+ $coupon = WC_Helper_Coupon::create_coupon( $old_code );
+
+ wc_get_coupon_id_by_code( $old_code );
+ $old_key = $this->sut->get_cache_key( $old_code );
+ $this->assertNotFalse( wp_cache_get( $old_key, 'coupons' ), 'The coupon code lookup cache should be primed while the coupon is published' );
+
+ wp_update_post(
+ array(
+ 'ID' => $coupon->get_id(),
+ 'post_title' => 'rename-unpublish-new',
+ 'post_status' => 'draft',
+ )
+ );
+
+ $this->assertNotFalse( wp_cache_get( $old_key, 'coupons' ), 'The transition hook only sees the new title, so the entry under the old code should be left for the read-time check' );
+ $this->assertSame( 0, wc_get_coupon_id_by_code( $old_code ), 'The old code must not resolve the coupon once it is renamed and unpublished' );
+ $this->assertFalse( wp_cache_get( $old_key, 'coupons' ), 'The stale entry under the old code should be removed from the object cache' );
+ }
+
+ /**
+ * @testdox is_lookup_entry_stale() should only trust entries whose ids all belong to published coupons.
+ */
+ public function test_is_lookup_entry_stale(): void {
+ $published = WC_Helper_Coupon::create_coupon( 'stale-check-published' );
+ $draft = WC_Helper_Coupon::create_coupon( 'stale-check-draft' );
+ wp_update_post(
+ array(
+ 'ID' => $draft->get_id(),
+ 'post_status' => 'draft',
+ )
+ );
+ $deleted_id = WC_Helper_Coupon::create_coupon( 'stale-check-deleted' )->get_id();
+ wp_delete_post( $deleted_id, true );
+ $page_id = wp_insert_post(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'stale-check-page',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertFalse( $this->sut->is_lookup_entry_stale( array( $published->get_id() ) ), 'An entry of a published coupon should be fresh' );
+ $this->assertFalse( $this->sut->is_lookup_entry_stale( array( (string) $published->get_id() ) ), 'Ids stored as strings should be accepted' );
+ $this->assertTrue( $this->sut->is_lookup_entry_stale( array( $draft->get_id() ) ), 'An entry of a draft coupon should be stale' );
+ $this->assertTrue( $this->sut->is_lookup_entry_stale( array( $deleted_id ) ), 'An entry of a deleted coupon should be stale' );
+ $this->assertTrue( $this->sut->is_lookup_entry_stale( array( $published->get_id(), $draft->get_id() ) ), 'An entry is stale as soon as one of its coupons is not published' );
+ $this->assertTrue( $this->sut->is_lookup_entry_stale( array( $page_id ) ), 'An entry pointing at a post that is not a coupon should be stale' );
+ $this->assertTrue( $this->sut->is_lookup_entry_stale( array() ), 'An empty entry should be stale' );
+ $this->assertTrue( $this->sut->is_lookup_entry_stale( array( 0 ) ), 'An entry with an invalid id should be stale' );
+ }
+
+ /**
+ * @testdox Invalidate should do nothing for an empty code.
+ */
+ public function test_invalidate_ignores_empty_codes(): void {
+ wp_cache_set( $this->sut->get_cache_key( '' ), array( 123 ), 'coupons' );
+
+ $this->sut->invalidate( '' );
+
+ $this->assertSame( array( 123 ), wp_cache_get( $this->sut->get_cache_key( '' ), 'coupons' ), 'Invalidating an empty code should not touch the cache' );
+ }
+}