Commit c0ab02812c7 for woocommerce
commit c0ab02812c7734f4f34da6f8e0f33e27b4842690
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Tue Oct 6 15:28:18 2026 +0200
Keep marketplace extension updates when a Helper update check fails (#69160)
* Keep marketplace extension updates when an update check fails
* Add changelog entry for the helper update-check fix
* Drop forced auto-update flags from cached products served after a failed check
* Keep cached updates through a manual refresh and drop them when the connection is rejected
* Back off on every failure except a rejected connection and keep forced auto-updates through a refresh
* Treat an update-check response that isn't JSON as a temporary failure
* Renew the cached updates on a temporary failure so they outlast the backoff
* Fall back to a longer-lived copy of the last successful update check instead of renewing the cache on failure
* Drop forced auto-updates from the last successful check copy
* Keep a single update cache for a week and treat it as fresh for 12 hours
---------
Co-authored-by: Thilina Pituwala <thilina.hasantha@gmail.com>
diff --git a/plugins/woocommerce/changelog/wccom-2804-keep-extension-updates-on-failed-check b/plugins/woocommerce/changelog/wccom-2804-keep-extension-updates-on-failed-check
new file mode 100644
index 00000000000..c056085fcb8
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-2804-keep-extension-updates-on-failed-check
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep WooCommerce.com extension updates visible when an update check fails, instead of hiding them for 12 hours.
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php
index 84e4b20ce7c..733324474f3 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php
@@ -16,7 +16,8 @@ if ( ! defined( 'ABSPATH' ) ) {
*
* Records and enforces a per-request-type backoff window when a WooCommerce.com
* Helper API endpoint responds with a rate-limit status (HTTP 429), so the site
- * refrains from calling that endpoint again until the limit resets.
+ * refrains from calling that endpoint again until the limit resets. Callers can
+ * also record a fixed window after other failed requests with record().
*
* The window is taken from the response's `Retry-After` header (delta seconds)
* and honored as-is, capped only at a per-type maximum. This covers both the
@@ -148,7 +149,6 @@ class WC_Helper_API_Backoff {
* @return void
*/
public static function record_from_response( string $request_type, array $response ): void {
- $now = time();
$bounds = self::get_bounds( $request_type );
$retry_after = self::get_retry_after_from_headers( $response );
@@ -162,7 +162,24 @@ class WC_Helper_API_Backoff {
$retry_after = min( $retry_after, $bounds['max'] );
}
- set_transient( self::get_transient_key( $request_type ), $now + $retry_after, $retry_after );
+ self::record( $request_type, $retry_after );
+ }
+
+ /**
+ * Record a backoff window of a fixed length for a request type, e.g. after a failed request.
+ *
+ * @since 11.3.0
+ *
+ * @param string $request_type The Helper API request type (e.g. 'update-check').
+ * @param int $seconds Length of the window, in seconds.
+ * @return void
+ */
+ public static function record( string $request_type, int $seconds ): void {
+ if ( $seconds <= 0 ) {
+ return;
+ }
+
+ set_transient( self::get_transient_key( $request_type ), time() + $seconds, $seconds );
}
/**
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
index 1e6ca483bbc..f121a51637a 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -1014,8 +1014,8 @@ class WC_Helper_Updater {
/**
* Validates cached update data and checks if it matches the expected hash.
*
- * Ensures the cached data is properly structured and corresponds to the current
- * payload to prevent fatal errors and avoid stale cache returns.
+ * Ensures the cached data is properly structured, corresponds to the current
+ * payload, and was not expired by a refresh, to avoid fatal errors and stale cache returns.
*
* @since 10.3.6
*
@@ -1024,7 +1024,7 @@ class WC_Helper_Updater {
* @return bool True if the data is valid and hash matches, false otherwise.
*/
private static function should_use_cached_update_data( $data, $hash ) {
- if ( ! is_array( $data ) ) {
+ if ( ! is_array( $data ) || ! empty( $data['expired'] ) || ! self::is_fresh_update_data( $data ) ) {
return false;
}
@@ -1039,19 +1039,60 @@ class WC_Helper_Updater {
return hash_equals( $hash, $data['hash'] );
}
+ /**
+ * Whether cached update data is recent enough to use without a new check. The cache is kept
+ * for a week so a failed check has something to fall back on, but it's only fresh for 12 hours.
+ *
+ * @param array $data The cached update data.
+ * @return bool
+ */
+ private static function is_fresh_update_data( array $data ): bool {
+ return isset( $data['updated'] ) && is_numeric( $data['updated'] ) && (int) $data['updated'] > time() - 12 * HOUR_IN_SECONDS;
+ }
+
/**
* Extract the products from a cached update-check payload.
*
- * Used on the paths that serve the previous cache rather than a fresh
- * response — while rate limited, and on the rate-limited response itself.
+ * Used while backing off and when the update check fails. The server-side `autoupdate`
+ * decisions are kept only when the cache is fresh and was made for the current payload.
*
- * @param mixed $data The data retrieved from the transient, of any shape.
+ * @param mixed $data The data retrieved from the transient, of any shape.
+ * @param string $hash The hash of the current payload.
* @return array The cached products, or an empty array when there are none.
*/
- private static function get_cached_products( $data ) {
- return ( is_array( $data ) && isset( $data['products'] ) && is_array( $data['products'] ) )
- ? $data['products']
- : array();
+ private static function get_cached_products( $data, string $hash ) {
+ if ( ! is_array( $data ) || ! isset( $data['products'] ) || ! is_array( $data['products'] ) ) {
+ return array();
+ }
+
+ $products = $data['products'];
+ if ( isset( $data['hash'] ) && is_string( $data['hash'] ) && hash_equals( $hash, $data['hash'] ) && self::is_fresh_update_data( $data ) ) {
+ return $products;
+ }
+
+ foreach ( $products as $product_id => $product ) {
+ if ( is_array( $product ) ) {
+ unset( $products[ $product_id ]['autoupdate'] );
+ }
+ }
+
+ return $products;
+ }
+
+ /**
+ * Whether a failed update check may succeed if retried. Only a rejected connection is not:
+ * a 401 or 403, or a WP_Error for missing local credentials.
+ *
+ * @param array|WP_Error $request The update-check response.
+ * @param int $response_code The HTTP status code, 0 when there is none.
+ * @return bool
+ */
+ private static function is_temporary_failure( $request, int $response_code ): bool {
+ if ( is_wp_error( $request ) ) {
+ return 'authentication' !== $request->get_error_code();
+ }
+
+ return ! in_array( $response_code, array( 401, 403 ), true );
}
/**
@@ -1081,14 +1122,14 @@ class WC_Helper_Updater {
return $data['products'];
}
- // If a previous update-check was rate limited (HTTP 429), honor the
- // server's reset window and skip the remote call until it passes. This
- // backoff is independent of the payload hash above, so a changed payload
- // (or a flushed cache) can't slip past it — but clicking the Marketplace
- // "Refresh" button bypasses and clears it. Return the last cached
- // products, if any, rather than an empty set.
+ /*
+ * After a failed or rate-limited update-check, skip the remote call until the
+ * backoff window passes. This check ignores the payload hash, so a changed
+ * payload or flushed cache can't slip past it, but the Marketplace "Refresh"
+ * button clears it. Return the last cached products, if any.
+ */
if ( WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ) ) {
- return self::get_cached_products( $data );
+ return self::get_cached_products( $data, $hash );
}
$cached_data = $data;
@@ -1126,24 +1167,25 @@ class WC_Helper_Updater {
}
$response_code = (int) wp_remote_retrieve_response_code( $request );
- if ( 200 !== $response_code ) {
+ $products = 200 === $response_code ? json_decode( wp_remote_retrieve_body( $request ), true ) : null;
+ if ( ! is_array( $products ) ) {
$data['errors'][] = 'http-error';
- // Respect server-side rate limiting: on a 429, record the reset window so
- // we hold off on further update-check calls until then, and return the
- // previously cached products without touching the cache. Caching this
- // empty result for 12 hours would outlive the reset window, and it would
- // discard the very products the backoff branch above serves while we wait.
- if ( 429 === $response_code && is_array( $request ) ) {
- WC_Helper_API_Backoff::record_from_response( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, $request );
+ // On an outage (including a 200 that isn't JSON), leave the cache untouched and back off, so a failed check doesn't hide extension updates for 12 hours.
+ if ( self::is_temporary_failure( $request, $response_code ) ) {
+ if ( 429 === $response_code && is_array( $request ) ) {
+ WC_Helper_API_Backoff::record_from_response( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, $request );
+ } else {
+ WC_Helper_API_Backoff::record( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, 15 * MINUTE_IN_SECONDS );
+ }
- return self::get_cached_products( $cached_data );
+ return self::get_cached_products( $cached_data, $hash );
}
} else {
- $data['products'] = json_decode( wp_remote_retrieve_body( $request ), true );
+ $data['products'] = $products;
}
- set_transient( $cache_key, $data, 12 * HOUR_IN_SECONDS );
+ set_transient( $cache_key, $data, WEEK_IN_SECONDS );
return $data['products'];
}
@@ -1159,7 +1201,7 @@ class WC_Helper_Updater {
return $count;
}
- // Don't fetch any new data since this function in high-frequency.
+ // This runs often, so it only counts from cached data; a cache that's stale or expired by a refresh triggers one update check here.
if ( ! get_transient( '_woocommerce_helper_subscriptions' ) ) {
return 0;
}
@@ -1285,6 +1327,21 @@ class WC_Helper_Updater {
*/
public static function flush_updates_cache() {
delete_transient( '_woocommerce_helper_updates' );
+ self::expire_updates_cache();
+ }
+
+ /**
+ * Forces a fresh update check while keeping the cached products, so they are still served if that check fails.
+ *
+ * @since 11.3.0
+ */
+ public static function expire_updates_cache(): void {
+ $data = get_transient( '_woocommerce_helper_updates' );
+ if ( is_array( $data ) && isset( $data['hash'] ) ) {
+ $data['expired'] = true;
+ set_transient( '_woocommerce_helper_updates', $data, WEEK_IN_SECONDS );
+ }
+
delete_transient( '_woocommerce_helper_updates_count' );
delete_site_transient( 'update_plugins' );
delete_site_transient( 'update_themes' );
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index c00ec613e76..20b37135173 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -1290,7 +1290,8 @@ class WC_Helper {
do_action( 'woocommerce_helper_subscriptions_refresh' );
self::_flush_authentication_cache();
self::_flush_subscriptions_cache();
- self::_flush_updates_cache();
+ // Keep the cached updates so they're still listed if the forced check fails.
+ WC_Helper_Updater::expire_updates_cache();
self::flush_product_usage_notice_rules_cache();
// A manual refresh resets any rate-limit backoff so the subsequent
@@ -3289,6 +3290,8 @@ class WC_Helper {
}
self::_flush_subscriptions_cache();
+ // A backoff from the pre-connect (public) update-check shouldn't block the first authenticated one.
+ WC_Helper_API_Backoff::clear( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
self::_flush_updates_cache();
self::flush_product_usage_notice_rules_cache();
}
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
index 3beeb84f212..14079da2c3c 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
@@ -88,6 +88,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
delete_transient( '_woocommerce_helper_notices' );
delete_transient( '_woocommerce_helper_connection_data' );
delete_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS );
+ delete_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
delete_transient( '_woocommerce_helper_subscriptions_api_error' );
}
@@ -1245,4 +1246,35 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
HOUR_IN_SECONDS
);
}
+
+ /**
+ * @testdox Connecting the site should clear an update-check backoff so the first authenticated check runs.
+ */
+ public function test_update_auth_option_clears_update_check_backoff(): void {
+ $previous_auth = WC_Helper_Options::get( 'auth', array() );
+ WC_Helper_API_Backoff::record( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, 15 * MINUTE_IN_SECONDS );
+
+ $http_mock = static function () {
+ return array(
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ 'body' => '{"name":"Test","email":"test@example.com"}',
+ );
+ };
+ add_filter( 'pre_http_request', $http_mock );
+
+ try {
+ WC_Helper::update_auth_option( 'token', 'secret', 123, home_url() );
+ } finally {
+ WC_Helper_Options::update( 'auth', $previous_auth );
+ remove_filter( 'pre_http_request', $http_mock );
+ }
+
+ $this->assertFalse(
+ WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+ 'Connecting should clear the update-check backoff'
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
index 0efbb23f02d..4f7698b4291 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
@@ -325,6 +325,269 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox A failed update check should keep the cached products, without their forced auto-update, and back off for a short window.
+ *
+ * @testWith [500]
+ * [408]
+ * [200]
+ * [0]
+ *
+ * @param int $status HTTP status of the response (with a non-JSON body), or 0 for a transport error.
+ */
+ public function test_update_check_preserves_cache_when_request_fails( int $status ): void {
+ $cached_data = array(
+ 'hash' => 'a-stale-hash',
+ 'updated' => time(),
+ 'products' => array(
+ 123 => array(
+ 'version' => '1.2.3',
+ 'slug' => 'test-plugin',
+ 'autoupdate' => true,
+ ),
+ ),
+ 'errors' => array(),
+ );
+ set_transient( '_woocommerce_helper_updates', $cached_data, HOUR_IN_SECONDS );
+
+ $requests = 0;
+ $http_mock = static function () use ( $status, &$requests ) {
+ ++$requests;
+ return 0 === $status
+ ? new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' )
+ : array(
+ 'response' => array(
+ 'code' => $status,
+ 'message' => get_status_header_desc( $status ),
+ ),
+ 'body' => '<html><body>Bad gateway</body></html>',
+ );
+ };
+ add_filter( 'pre_http_request', $http_mock );
+
+ try {
+ $result = $this->call_update_check(
+ array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ 'version' => '1.0.0',
+ ),
+ )
+ );
+ $second_check = $this->call_update_check(
+ array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ 'version' => '1.1.0',
+ ),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock );
+ }
+
+ $expected_products = array(
+ 123 => array(
+ 'version' => '1.2.3',
+ 'slug' => 'test-plugin',
+ ),
+ );
+ $backoff_until = (int) get_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
+ $this->assertSame( $expected_products, $result, 'A failed check should serve the cached products, without a forced auto-update decided for another version' );
+ $this->assertSame( $cached_data, get_transient( '_woocommerce_helper_updates' ), 'A failed check should leave the cached update data untouched' );
+ $this->assertSame( $expected_products, $second_check, 'A check inside the backoff window should serve the cached products' );
+ $this->assertSame( 1, $requests, 'A check inside the backoff window should not call the API, even after the installed version changed' );
+ $this->assertGreaterThan( time(), $backoff_until, 'A failed check should record a backoff window' );
+ $this->assertLessThanOrEqual( time() + 15 * MINUTE_IN_SECONDS, $backoff_until, 'The backoff after a failed check should be short' );
+ }
+
+ /**
+ * @testdox A failed update check with nothing cached should return no products and not cache the empty result.
+ */
+ public function test_update_check_without_cache_returns_nothing_when_request_fails(): void {
+ $http_mock = static function () {
+ return new WP_Error( 'http_request_failed', 'cURL error 6: Could not resolve host' );
+ };
+ add_filter( 'pre_http_request', $http_mock );
+
+ try {
+ $result = $this->call_update_check(
+ array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ ),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock );
+ }
+
+ $this->assertSame( array(), $result, 'With nothing cached there are no products to serve' );
+ $this->assertFalse( get_transient( '_woocommerce_helper_updates' ), 'The empty result should not be cached' );
+ }
+
+ /**
+ * @testdox A failed update check after the cache went stale should keep serving it for a week, without its forced auto-updates.
+ */
+ public function test_update_check_serves_stale_cache_when_request_fails(): void {
+ $payload = array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ ),
+ );
+ $products = array(
+ 123 => array(
+ 'version' => '1.2.3',
+ 'autoupdate' => true,
+ ),
+ );
+ $fail = false;
+
+ $http_mock = static function () use ( &$fail, $products ) {
+ return $fail
+ ? new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' )
+ : array(
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ 'body' => wp_json_encode( $products ),
+ );
+ };
+ add_filter( 'pre_http_request', $http_mock );
+
+ try {
+ $fresh = $this->call_update_check( $payload );
+ $cache_ttl = (int) get_option( '_transient_timeout__woocommerce_helper_updates' ) - time();
+ $stale = get_transient( '_woocommerce_helper_updates' );
+ $stale['updated'] -= 13 * HOUR_IN_SECONDS;
+ set_transient( '_woocommerce_helper_updates', $stale, WEEK_IN_SECONDS );
+ $fail = true;
+ $after_failure = $this->call_update_check( $payload );
+ $inside_backoff = $this->call_update_check( $payload );
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock );
+ }
+
+ $expected = array( 123 => array( 'version' => '1.2.3' ) );
+ $this->assertSame( $products, $fresh, 'A fresh check should keep the forced auto-update' );
+ $this->assertGreaterThan( DAY_IN_SECONDS, $cache_ttl, 'A successful check should be kept longer than it is fresh' );
+ $this->assertSame( $expected, $after_failure, 'A failed check should serve the stale cache, without forcing auto-updates' );
+ $this->assertSame( $expected, $inside_backoff, 'A check inside the backoff should serve the stale cache too' );
+ $this->assertSame( $stale, get_transient( '_woocommerce_helper_updates' ), 'A failed check should not write the cache' );
+ }
+
+ /**
+ * @testdox A rejected update check should cache the empty result as before, without a backoff.
+ *
+ * @testWith [401]
+ * [403]
+ * [0]
+ *
+ * @param int $status HTTP status of the response, or 0 for a WP_Error about missing local credentials.
+ */
+ public function test_update_check_caches_empty_result_when_connection_is_rejected( int $status ): void {
+ set_transient(
+ '_woocommerce_helper_updates',
+ array(
+ 'hash' => 'a-stale-hash',
+ 'updated' => time(),
+ 'products' => array( 123 => array( 'version' => '1.2.3' ) ),
+ 'errors' => array(),
+ ),
+ HOUR_IN_SECONDS
+ );
+
+ $http_mock = static function () use ( $status ) {
+ return 0 === $status
+ ? new WP_Error( 'authentication', 'Authentication failed.', 401 )
+ : array(
+ 'response' => array(
+ 'code' => $status,
+ 'message' => get_status_header_desc( $status ),
+ ),
+ 'body' => '',
+ );
+ };
+ add_filter( 'pre_http_request', $http_mock );
+
+ try {
+ $result = $this->call_update_check(
+ array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ ),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock );
+ }
+
+ $stored = get_transient( '_woocommerce_helper_updates' );
+ $this->assertSame( array(), $result, 'A rejected check should not serve updates the server refused' );
+ $this->assertSame( array(), $stored['products'], 'A rejected check should cache the empty result' );
+ $this->assertSame( array( 'http-error' ), $stored['errors'], 'A rejected check should be recorded as an error' );
+ $this->assertFalse(
+ WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+ 'A rejected check should not start the outage backoff'
+ );
+ }
+
+ /**
+ * @testdox A manual refresh should force a fresh update check but keep serving the cached products if it fails.
+ */
+ public function test_refresh_keeps_cached_products_when_the_forced_check_fails(): void {
+ $payload = array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ ),
+ );
+ ksort( $payload );
+ $hash = md5( wp_json_encode( $payload ) );
+ $products = array(
+ 123 => array(
+ 'version' => '1.2.3',
+ 'autoupdate' => true,
+ ),
+ );
+ set_transient(
+ '_woocommerce_helper_updates',
+ array(
+ 'hash' => $hash,
+ 'updated' => time(),
+ 'products' => $products,
+ 'errors' => array(),
+ ),
+ HOUR_IN_SECONDS
+ );
+
+ $update_checks = 0;
+ $http_mock = static function ( $pre, $args, $url ) use ( &$update_checks ) {
+ if ( false !== strpos( $url, 'update-check' ) ) {
+ ++$update_checks;
+ }
+ return new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' );
+ };
+ add_filter( 'pre_http_request', $http_mock, 10, 3 );
+
+ try {
+ WC_Helper::refresh_helper_subscriptions();
+ $result = $this->call_update_check( $payload );
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock, 10 );
+ }
+
+ $this->assertSame( 1, $update_checks, 'A refresh should force a fresh update check' );
+ $this->assertSame( $products, $result, 'A failed check after a refresh should serve the cached products, keeping the forced auto-update made for this payload' );
+ $this->assertSame( $hash, get_transient( '_woocommerce_helper_updates' )['hash'], 'A refresh should keep the hash of the cached payload' );
+ }
+
/**
* Test that _update_check refreshes cache when hash doesn't match.
*/