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