Commit 9265f576a62 for woocommerce
commit 9265f576a62231c147f1ba23723739d3af6ab2b8
Author: Thilina Pituwala <thilina.hasantha@gmail.com>
Date: Fri Sep 11 14:50:25 2026 +1000
Honor server-side forced auto-updates for WooCommerce.com extensions (#68068)
* Add the ability to force updates WooCommerce.com extensions.
* Update tests and changelog.
* Add current installed version parameter to the update-check request payload.
If the extension is not installed on the store the version will be empty.
* Update php docs to explain forced auto updates further.
* Keep the installed version out of the update-check cache key.
Adding the installed version to the update-check payload moved the cache hash on every extension update, so get_updates_count()'s transient-exists guard no longer meant the cache was usable and could trigger a remote call in the admin page path. Hash the payload without the version instead; callers compare against the installed version themselves, so a version-stale response is still correct.
* Report the PHP requirement on Woo theme update items.
WP_Automatic_Updater::should_update() checks $item->requires_php, and on the
auto-update path that is the only PHP guard there is: update() calls
Theme_Upgrader::upgrade(), which registers neither check_package() nor the
requires_php check that bulk_upgrade() runs.
The plugin item copied requires_php off the update-check response and the theme
item did not, so a forced theme update could install on a PHP version the theme
cannot run on and report success. Copy the field for themes too, guarded by the
same isset() the plugin path uses.
Adds test_transient_update_themes_passes_requires_php covering both a reported
requirement and none.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVuNMrgyJksWZWQ9h1UEa2
* Drop redundant test teardown and guarantee the parent runs.
flush_cache() in set_up() already clears the plugins cache group, and
_restore_hooks() in tear_down() already reverts filters added during a test, so
the manual wp_cache_delete() and remove_all_filters() calls were no-ops.
Transient writes are inside the transaction, so clearing them on teardown was
redundant too; the setUp() reset stays.
The remaining cleanup removes the fixture theme from disk and from
$wp_theme_directories, which the parent teardown does not reset. It now runs in
a try/finally so a throw there cannot skip parent::tearDown() and the ROLLBACK
it performs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVuNMrgyJksWZWQ9h1UEa2
* Require a boolean autoupdate flag before forcing an update.
is_autoupdate_forced() tested the value from the update-check response with
empty(), so any truthy shape passed: the string "false", a non-empty array, a
JSON object. With an installable package that was enough to set
$item['autoupdate'] and trigger an install nobody opted into.
Validate with filter_var( ..., FILTER_VALIDATE_BOOLEAN ) instead, which still
accepts the representations a JSON boolean can arrive as (true, "true", "1", 1)
while rejecting the rest. wc_string_to_bool() is not usable here as it calls
strtolower() on the value, a TypeError for array input.
Adds test_transient_update_plugins_validates_autoupdate_flag covering 13 values;
three of them fail against the previous empty() check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVuNMrgyJksWZWQ9h1UEa2
* Cover forced auto-updates on the theme update item.
The theme path had no test for the autoupdate flag, only for requires_php.
Mirror the plugin matrix: forced with an installable package, forced with no
package, forced with an expired package, and not forced.
Also drops the _woocommerce_helper_subscriptions transient from the requires_php
test. get_update_data() adds local themes to the payload whether or not a
subscription exists, so it was doing nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVuNMrgyJksWZWQ9h1UEa2
* Key the update-check cache on the installed version
The server decides the autoupdate flag from the version the site reports,
and nothing local recomputes it. With the version left out of the cache key,
a response cached under one build was served for up to 12 hours after an
update or a rollback, carrying a decision made for a build that was no
longer installed.
The version is now part of the hash, so an update or a rollback asks the
server again. That costs one extra update-check per extension update, which
the PR description already accounts for.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Put the update-check test comments back on their lines
A formatter run had pushed three trailing comments onto their own lines and
broken the array alignment around them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Send the same update-check payload from both entry points
get_available_extensions_downloads_data() never scanned installed themes,
so it reported an empty version for a theme that get_update_data() reported
a real version for. The server decides the autoupdate flag from that
version, and now that the version is in the cache key, the two payloads
hashed differently and each call invalidated the other's cached response.
Both entry points now share one payload builder that covers subscriptions,
plugins, and themes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Akeda Bagus <akeda.bagus@automattic.com>
diff --git a/plugins/woocommerce/changelog/wccom-2879-forced-autoupdate-passthrough b/plugins/woocommerce/changelog/wccom-2879-forced-autoupdate-passthrough
new file mode 100644
index 00000000000..e47f38370b9
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-2879-forced-autoupdate-passthrough
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Honor the WooCommerce.com `autoupdate` flag on plugin and theme updates, so a critical extension release can be installed automatically when the package is installable. Theme update items now also report their PHP requirement, so an automatic update is skipped on an incompatible PHP version.
diff --git a/plugins/woocommerce/changelog/wccom-2879-update-check-installed-version b/plugins/woocommerce/changelog/wccom-2879-update-check-installed-version
new file mode 100644
index 00000000000..5670827fb9d
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-2879-update-check-installed-version
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Send the installed version of each WooCommerce.com extension in the update-check request.
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 18a209cb646..0117ebdc95f 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -104,6 +104,10 @@ class WC_Helper_Updater {
*/
$item = apply_filters( 'update_woo_com_subscription_details', $item, $data, $plugin['_product_id'] );
+ if ( self::is_autoupdate_forced( $data, $item ) ) {
+ $item['autoupdate'] = true;
+ }
+
if ( isset( $data['requires_php'] ) ) {
$item['requires_php'] = $data['requires_php'];
}
@@ -172,6 +176,14 @@ class WC_Helper_Updater {
*/
$item = apply_filters( 'update_woo_com_subscription_details', $item, $data, $theme['_product_id'] );
+ if ( isset( $data['requires_php'] ) ) {
+ $item['requires_php'] = $data['requires_php'];
+ }
+
+ if ( self::is_autoupdate_forced( $data, $item ) ) {
+ $item['autoupdate'] = true;
+ }
+
if ( version_compare( $theme['Version'], $data['version'], '<' ) ) {
$transient->response[ $slug ] = $item;
} else {
@@ -183,6 +195,38 @@ class WC_Helper_Updater {
return $transient;
}
+ /**
+ * Checks whether WooCommerce.com flagged this update to be installed automatically.
+ *
+ * Uses core's own `autoupdate` flag, the same one api.wordpress.org sets on WordPress.org
+ * plugins, so these updates follow the same path through WP_Automatic_Updater::should_update().
+ * The flag overrides the per-item setting and the site-wide plugins_auto_update_enabled and
+ * themes_auto_update_enabled switches. AUTOMATIC_UPDATER_DISABLED, disable_autoupdate and the
+ * auto_update_plugin and auto_update_theme filters still apply.
+ *
+ * The package checks are required: forcing an update that cannot be installed, either
+ * because no package was supplied or because the subscription expired, only produces a
+ * failed update email to every admin on the site.
+ *
+ * @since 11.2.0
+ * @param array $data Product data returned by the Helper API update check.
+ * @param array $item Update item built for the update transient.
+ * @return bool
+ */
+ private static function is_autoupdate_forced( $data, $item ) {
+ // The flag arrives from a remote response, so only a value that reads as boolean true
+ // counts. A truthy string such as "false", or an array, must not trigger an install.
+ if ( true !== filter_var( $data['autoupdate'] ?? null, FILTER_VALIDATE_BOOLEAN ) ) {
+ return false;
+ }
+
+ if ( empty( $item['package'] ) || ! is_string( $item['package'] ) ) {
+ return false;
+ }
+
+ return 0 !== strpos( $item['package'], 'woocommerce-com-expired-' );
+ }
+
/**
* Runs on load-plugins.php, adds a hook to show a custom plugin update message for WooCommerce.com hosted plugins.
*
@@ -408,38 +452,16 @@ class WC_Helper_Updater {
}
/**
- * Get update data for all plugins.
+ * Get update data for all extensions, for the WP-CLI extension command.
+ *
+ * Sends the same payload as get_update_data(), so both share one cached response and
+ * report the same installed versions.
*
* @return array Update data {product_id => data}
* @see get_update_data
*/
public static function get_available_extensions_downloads_data() {
- $payload = array();
-
- // Scan subscriptions.
- $subscriptions = WC_Helper::get_subscriptions();
-
- foreach ( $subscriptions as $subscription ) {
- $product_id = (int) $subscription['product_id'];
-
- $payload[ $product_id ] = array(
- 'product_id' => $product_id,
- 'file_id' => '',
- );
- }
-
- // Scan local plugins which may or may not have a subscription.
- foreach ( WC_Helper::get_local_woo_plugins() as $data ) {
- if ( ! isset( $payload[ $data['_product_id'] ] ) ) {
- $payload[ $data['_product_id'] ] = array(
- 'product_id' => $data['_product_id'],
- );
- }
-
- $payload[ $data['_product_id'] ]['file_id'] = $data['_file_id'];
- }
-
- return self::_update_check( $payload );
+ return self::_update_check( self::get_update_check_payload() );
}
/**
@@ -452,33 +474,37 @@ class WC_Helper_Updater {
* @return array Update data {product_id => data}
*/
public static function get_update_data() {
- $payload = array();
+ return self::_update_check( self::get_update_check_payload() );
+ }
- // Scan subscriptions.
- $subscriptions = WC_Helper::get_subscriptions();
+ /**
+ * The products to ask WooCommerce.com about, with the file ID and installed version of each.
+ *
+ * Covers every subscription plus every installed plugin and theme carrying a Woo header,
+ * whether or not it has a subscription. Every caller has to send the same payload: the
+ * server decides the autoupdate flag from the installed version, and the response is
+ * cached under a hash of the payload.
+ *
+ * @since 11.2.0
+ *
+ * @return array Payload keyed by product ID.
+ */
+ private static function get_update_check_payload(): array {
+ $payload = array();
- foreach ( $subscriptions as $subscription ) {
+ foreach ( WC_Helper::get_subscriptions() as $subscription ) {
$product_id = (int) $subscription['product_id'];
$payload[ $product_id ] = array(
'product_id' => $product_id,
'file_id' => '',
+ 'version' => '',
);
}
- // Scan local plugins which may or may not have a subscription.
- foreach ( WC_Helper::get_local_woo_plugins() as $data ) {
- if ( ! isset( $payload[ $data['_product_id'] ] ) ) {
- $payload[ $data['_product_id'] ] = array(
- 'product_id' => $data['_product_id'],
- );
- }
+ $installed = array_merge( WC_Helper::get_local_woo_plugins(), WC_Helper::get_local_woo_themes() );
- $payload[ $data['_product_id'] ]['file_id'] = $data['_file_id'];
- }
-
- // Scan local themes.
- foreach ( WC_Helper::get_local_woo_themes() as $data ) {
+ foreach ( $installed as $data ) {
if ( ! isset( $payload[ $data['_product_id'] ] ) ) {
$payload[ $data['_product_id'] ] = array(
'product_id' => $data['_product_id'],
@@ -486,9 +512,10 @@ class WC_Helper_Updater {
}
$payload[ $data['_product_id'] ]['file_id'] = $data['_file_id'];
+ $payload[ $data['_product_id'] ]['version'] = (string) ( $data['Version'] ?? '' );
}
- return self::_update_check( $payload );
+ return $payload;
}
/**
@@ -659,8 +686,11 @@ class WC_Helper_Updater {
/**
* Run an update check API call.
*
- * The call is cached based on the payload (product ids, file ids). If
- * the payload changes, the cache is going to miss.
+ * The call is cached based on the payload (product ids, file ids, installed versions).
+ * If the payload changes, the cache is going to miss. The installed version has to be
+ * part of the key: the server decides the autoupdate flag from it, so a response cached
+ * under another version, after an update or a rollback, would carry a decision made for
+ * a build that is no longer installed.
*
* @param array $payload Information about the plugin to update.
* @return array Update data for each requested product.
@@ -670,6 +700,7 @@ class WC_Helper_Updater {
return array();
}
ksort( $payload );
+
$hash = md5( wp_json_encode( $payload ) );
$cache_key = '_woocommerce_helper_updates';
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 f83ce2947f1..b7778655dd2 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
@@ -33,6 +33,13 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
*/
private $mocked_request_products;
+ /**
+ * Fixture theme root, when a Woo theme has been mocked.
+ *
+ * @var string|null
+ */
+ private $theme_root;
+
/**
* Set up before each test.
*/
@@ -46,9 +53,42 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
* Tear down after each test.
*/
public function tearDown(): void {
- $this->cleanup_transients();
+ try {
+ // The fixture theme lives on disk and in a global, neither of which the parent teardown resets.
+ $this->cleanup_theme_fixture();
+ } finally {
+ parent::tearDown();
+ }
+ }
- parent::tearDown();
+ /**
+ * Unregister and delete the fixture theme, when one was created.
+ */
+ private function cleanup_theme_fixture() {
+ if ( null === $this->theme_root ) {
+ return;
+ }
+
+ // register_theme_directory() has no counterpart, so the fixture root is removed by hand.
+ global $wp_theme_directories;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Unregistering the fixture theme root.
+ $wp_theme_directories = array_values( array_diff( (array) $wp_theme_directories, array( $this->theme_root ) ) );
+ wp_clean_themes_cache();
+
+ $theme_dir = $this->theme_root . '/woo-test-theme';
+ foreach ( array( $theme_dir . '/style.css', $theme_dir . '/index.php' ) as $file ) {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ }
+ foreach ( array( $theme_dir, $this->theme_root ) as $dir ) {
+ if ( is_dir( $dir ) ) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture in the temp directory.
+ rmdir( $dir );
+ }
+ }
+
+ $this->theme_root = null;
}
/**
@@ -575,6 +615,390 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox A forced auto-update flag reaches the plugin update item only when the package can be installed.
+ * @testWith [true, "https://woocommerce.com/package.zip", true]
+ * [true, "", false]
+ * [true, "woocommerce-com-expired-123", false]
+ * [false, "https://woocommerce.com/package.zip", false]
+ *
+ * @param bool $forced Whether the update-check response flags the product for a forced auto-update.
+ * @param string $package Package the update_woo_com_subscription_details filter supplies, as Woo Update Manager does.
+ * @param bool $expected Whether the update item should carry the autoupdate flag.
+ */
+ public function test_transient_update_plugins_forced_autoupdate( bool $forced, string $package, bool $expected ): void {
+ $filename = $this->mock_local_woo_plugin();
+ $this->mock_update_check_products( 123, $forced );
+ $this->mock_update_package( $package );
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ $transient = WC_Helper_Updater::transient_update_plugins(
+ (object) array(
+ 'response' => array(),
+ 'no_update' => array(),
+ )
+ );
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+
+ $item = (array) $transient->response[ $filename ];
+
+ $this->assertSame( $expected, ! empty( $item['autoupdate'] ), 'The plugin update item carries the wrong forced auto-update state' );
+ }
+
+ /**
+ * @testdox Only an autoupdate value that reads as boolean true forces an update.
+ *
+ * @dataProvider autoupdate_flag_values
+ *
+ * @param mixed $flag Raw value the update-check response reports for autoupdate.
+ * @param bool $expected Whether the update item should carry the autoupdate flag.
+ */
+ public function test_transient_update_plugins_validates_autoupdate_flag( $flag, bool $expected ): void {
+ $filename = $this->mock_local_woo_plugin();
+ $this->mock_update_check_autoupdate_flag( 123, $flag );
+ $this->mock_update_package( 'https://woocommerce.com/package.zip' );
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ try {
+ $transient = WC_Helper_Updater::transient_update_plugins(
+ (object) array(
+ 'response' => array(),
+ 'no_update' => array(),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+ }
+
+ $item = (array) $transient->response[ $filename ];
+
+ $this->assertSame( $expected, ! empty( $item['autoupdate'] ), 'A non-boolean autoupdate value was read as the wrong forced auto-update state' );
+ }
+
+ /**
+ * Values the update-check response could report for autoupdate, and whether each forces an update.
+ *
+ * Shapes are what survives a JSON round trip, since the response is decoded as an array.
+ *
+ * @return array
+ */
+ public function autoupdate_flag_values(): array {
+ return array(
+ 'boolean true' => array( true, true ),
+ 'string "true"' => array( 'true', true ),
+ 'string "1"' => array( '1', true ),
+ 'integer 1' => array( 1, true ),
+ 'boolean false' => array( false, false ),
+ 'string "false"' => array( 'false', false ),
+ 'string "0"' => array( '0', false ),
+ 'integer 0' => array( 0, false ),
+ 'empty string' => array( '', false ),
+ 'null' => array( null, false ),
+ 'empty array' => array( array(), false ),
+ 'list' => array( array( 'yes' ), false ),
+ 'object' => array( array( 'forced' => true ), false ),
+ );
+ }
+
+ /**
+ * @testdox The update-check request reports the installed version of each local Woo extension.
+ */
+ public function test_update_check_request_includes_installed_version(): void {
+ $this->mock_local_woo_plugin();
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ WC_Helper_Updater::get_update_data();
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+
+ $this->assertSame(
+ array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ 'version' => '1.0.0',
+ ),
+ $this->mocked_request_products[123],
+ 'The requested product should report the version installed on the site'
+ );
+ }
+
+ /**
+ * @testdox The downloads data reports an installed theme's file ID and version too.
+ */
+ public function test_downloads_data_reports_installed_theme_version(): void {
+ $this->mock_local_woo_theme();
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ try {
+ WC_Helper_Updater::get_available_extensions_downloads_data();
+ } finally {
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+ }
+
+ $this->assertSame(
+ array(
+ 'product_id' => 456,
+ 'file_id' => 'def456',
+ 'version' => '1.0.0',
+ ),
+ $this->mocked_request_products[456],
+ 'A theme must be reported the same way here as by get_update_data()'
+ );
+ }
+
+ /**
+ * @testdox Both update entry points send the same payload, so they share one cached response.
+ */
+ public function test_update_entry_points_send_the_same_payload(): void {
+ $this->mock_local_woo_plugin();
+ $this->mock_local_woo_theme();
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ try {
+ WC_Helper_Updater::get_update_data();
+ $from_update_data = $this->mocked_request_products;
+
+ // Drop the cached response so the second entry point has to ask the server too.
+ delete_transient( '_woocommerce_helper_updates' );
+ $this->mocked_request_products = null;
+
+ WC_Helper_Updater::get_available_extensions_downloads_data();
+ $from_downloads_data = $this->mocked_request_products;
+ } finally {
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+ }
+
+ $this->assertNotNull( $from_downloads_data, 'The second call should have gone to the server' );
+ $this->assertSame( $from_update_data, $from_downloads_data );
+ }
+
+ /**
+ * @testdox The installed version is part of the update-check cache key.
+ */
+ public function test_update_check_cache_key_includes_installed_version(): void {
+ // A response cached while 2.0.0 was installed, with the autoupdate decision made for it.
+ $cached_data = array(
+ 'hash' => md5(
+ wp_json_encode(
+ array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ 'version' => '2.0.0',
+ ),
+ )
+ )
+ ),
+ 'updated' => time(),
+ 'products' => array(
+ 123 => array(
+ 'version' => '2.0.0',
+ 'autoupdate' => false,
+ ),
+ ),
+ 'errors' => array(),
+ );
+
+ set_transient( '_woocommerce_helper_updates', $cached_data, HOUR_IN_SECONDS );
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ try {
+ // The site has since rolled back to 1.5.0.
+ $result = $this->call_update_check(
+ array(
+ 123 => array(
+ 'product_id' => 123,
+ 'file_id' => 'abc123',
+ 'version' => '1.5.0',
+ ),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+ }
+
+ $this->assertSame( '1.5.0', $this->mocked_request_products[123]['version'] ?? null, 'A rollback must ask the server again, since the autoupdate flag was decided for the build that was installed at the time' );
+ $this->assertNotSame( $cached_data['products'], $result, 'The stale decision must not be served for a different build' );
+ }
+
+ /**
+ * @testdox A forced auto-update flag reaches the theme update item only when the package can be installed.
+ * @testWith [true, "https://woocommerce.com/package.zip", true]
+ * [true, "", false]
+ * [true, "woocommerce-com-expired-456", false]
+ * [false, "https://woocommerce.com/package.zip", false]
+ *
+ * @param bool $forced Whether the update-check response flags the product for a forced auto-update.
+ * @param string $package Package the update_woo_com_subscription_details filter supplies, as Woo Update Manager does.
+ * @param bool $expected Whether the update item should carry the autoupdate flag.
+ */
+ public function test_transient_update_themes_forced_autoupdate( bool $forced, string $package, bool $expected ): void {
+ $stylesheet = $this->mock_local_woo_theme();
+ $this->mock_update_check_products( 456, $forced );
+ $this->mock_update_package( $package );
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ try {
+ $transient = WC_Helper_Updater::transient_update_themes(
+ (object) array(
+ 'response' => array(),
+ 'checked' => array(),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+ }
+
+ $item = $transient->response[ $stylesheet ];
+
+ $this->assertSame( $expected, ! empty( $item['autoupdate'] ), 'The theme update item carries the wrong forced auto-update state' );
+ }
+
+ /**
+ * @testdox A theme update item reports the PHP requirement the update-check response carries.
+ * @testWith ["8.2"]
+ * [null]
+ *
+ * @param string|null $requires_php PHP requirement reported for the product, or null when it reports none.
+ */
+ public function test_transient_update_themes_passes_requires_php( ?string $requires_php ): void {
+ $stylesheet = $this->mock_local_woo_theme();
+
+ $product = array(
+ 'version' => '2.0.0',
+ 'url' => 'https://woocommerce.com/products/test-theme',
+ 'package' => '',
+ 'slug' => 'woo-test-theme',
+ 'upgrade_notice' => '',
+ );
+
+ if ( null !== $requires_php ) {
+ $product['requires_php'] = $requires_php;
+ }
+
+ $this->mocked_updates = array( 456 => $product );
+
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+ try {
+ $transient = WC_Helper_Updater::transient_update_themes(
+ (object) array(
+ 'response' => array(),
+ 'checked' => array(),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+ }
+
+ $this->assertArrayHasKey( $stylesheet, $transient->response, 'The theme should have been offered an update' );
+
+ $this->assertSame(
+ $requires_php,
+ $transient->response[ $stylesheet ]['requires_php'] ?? null,
+ 'The theme update item should report the PHP requirement, so WP_Automatic_Updater::should_update() can enforce it'
+ );
+ }
+
+ /**
+ * Makes WC_Helper::get_local_woo_plugins() report a single Woo plugin, without touching the filesystem.
+ *
+ * @return string The plugin file name.
+ */
+ private function mock_local_woo_plugin(): string {
+ $filename = 'woo-test-plugin/woo-test-plugin.php';
+
+ wp_cache_set(
+ 'plugins',
+ array(
+ '' => array(
+ $filename => array(
+ 'Name' => 'Woo Test Plugin',
+ 'Version' => '1.0.0',
+ 'Woo' => '123:abc123',
+ ),
+ ),
+ ),
+ 'plugins'
+ );
+
+ return $filename;
+ }
+
+ /**
+ * Makes WC_Helper::get_local_woo_themes() report a single Woo theme, from a fixture theme root.
+ *
+ * @return string The theme stylesheet.
+ */
+ private function mock_local_woo_theme(): string {
+ $stylesheet = 'woo-test-theme';
+ $this->theme_root = untrailingslashit( get_temp_dir() ) . '/wc-helper-updater-themes';
+
+ wp_mkdir_p( $this->theme_root . '/' . $stylesheet );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp directory.
+ file_put_contents(
+ $this->theme_root . '/' . $stylesheet . '/style.css',
+ "/*\nTheme Name: Woo Test Theme\nVersion: 1.0.0\nWoo: 456:def456\n*/\n"
+ );
+
+ // WP_Theme reports a theme without an index.php as broken, and wp_get_themes() then skips it.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp directory.
+ file_put_contents( $this->theme_root . '/' . $stylesheet . '/index.php', "<?php\n" );
+
+ register_theme_directory( $this->theme_root );
+ wp_clean_themes_cache();
+
+ return $stylesheet;
+ }
+
+ /**
+ * Sets the products the mocked update-check response returns.
+ *
+ * @param int $product_id The Woo product id to return an update for.
+ * @param bool $forced Whether the product is flagged for a forced auto-update.
+ */
+ private function mock_update_check_products( int $product_id, bool $forced ): void {
+ $product = array(
+ 'version' => '2.0.0',
+ 'url' => 'https://woocommerce.com/products/test',
+ 'package' => '',
+ 'slug' => 'test-plugin',
+ 'upgrade_notice' => '',
+ );
+
+ if ( $forced ) {
+ $product['autoupdate'] = true;
+ }
+
+ $this->mocked_updates = array( $product_id => $product );
+ }
+
+ /**
+ * Sets the raw value the mocked update-check response reports for autoupdate.
+ *
+ * @param int $product_id The Woo product id to return an update for.
+ * @param mixed $flag Raw value reported for autoupdate.
+ */
+ private function mock_update_check_autoupdate_flag( int $product_id, $flag ): void {
+ $this->mock_update_check_products( $product_id, false );
+ $this->mocked_updates[ $product_id ]['autoupdate'] = $flag;
+ }
+
+ /**
+ * Sets the package on the update item, the way the Woo Update Manager plugin does.
+ *
+ * @param string $package The package to set.
+ */
+ private function mock_update_package( string $package ): void {
+ add_filter(
+ 'update_woo_com_subscription_details',
+ function ( $item ) use ( $package ) {
+ $item['package'] = $package;
+
+ return $item;
+ }
+ );
+ }
+
/**
* Mock WC_Helper_API response for testing.
*