Commit 56f8a8560b7 for woocommerce
commit 56f8a8560b734148c3d7a4229fa4f0038549b79d
Author: Thilina Pituwala <thilina.hasantha@gmail.com>
Date: Tue Sep 15 01:05:07 2026 +1000
Surface auto-update status for installed products in My Subscriptions (#68410)
* Surface plugin auto-update status in My Subscriptions
The "Installed on this store" table shows when a subscription expires and which
version is installed, but nothing about whether the product will update itself.
A merchant with auto-updates off can sit several releases behind, including on
security releases, with no signal on the screen they use to manage the product.
Report the state from WC_Helper::get_plugin_auto_update_data(), which follows
the same rules as Core's Plugins screen: AUTOMATIC_UPDATER_DISABLED and the
plugins_auto_update_enabled filter switch the feature off entirely, a non-null
auto_update_plugin filter forces a state that can't be changed, and otherwise the
auto_update_plugins site option decides. Rows with auto-updates on render nothing.
Enabling writes the same auto_update_plugins option Core's Plugins screen writes,
through a new wc/v3/marketplace/subscriptions/auto-update route, and the row
refreshes in place with the change announced to assistive technology.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Warn when auto-updates are on but the extension still won't update
The auto-update setting being on is only half the story. A product with the
setting on still won't update if automatic updates are off site-wide, if the
WooCommerce.com Update Manager is inactive, if there is no subscription for it,
if that subscription has expired, or if it is not connected to this store. The
row said nothing about any of it.
Add a second state to the same slot, "Won't auto-update", whose tooltip lists
the reasons in point form and offers no action, since none of them can be fixed
from that row.
This also splits the site-wide switch out of the per-plugin flag. auto_update
now reports only what the plugin's own setting says, so the screen can report a
setting that is on alongside the site-wide block that makes it moot;
auto_update_manageable still goes false, because there is nothing to toggle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Call the blocked auto-update state red, and name it "Auto-updates blocked"
"Won't auto-update" described the outcome without saying what kind of problem it
was. "Auto-updates blocked" reads as a sibling of "Auto-updates are off" -- same
subject, different cause -- and "blocked" says something outside the row is
stopping it. It also avoids implying manual updates are unavailable; the Update
link in the Version column still works.
Raise it to StatusLevel.Error so it renders red like "Expired". The setting being
off stays amber: that is a choice the merchant can correct from the row, while
this is a state the row cannot fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop the site-wide auto-update switch from blocking a row
Whether WordPress runs automatic updates at all is a site-wide decision, and
repeating it on every row says nothing about the product. Drop it from the
blocker list, along with the pluginAutoUpdatesEnabled setting it read and the
require_once that setting needed.
auto_update_manageable still consults the switch: that governs whether there is
a toggle to offer, not what the row says about the product.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix enabling auto-updates for products distributed through WordPress.org
Enabling returned HTTP 400 for Facebook for WooCommerce and any other
WooCommerce.com product shipped from WordPress.org. The setter resolved the
plugin file through _get_local_from_product_id(), which only sees plugins
carrying a Woo header; .org builds do not carry one, so it returned false and
the request threw "This subscription has no installed plugin to update".
The row itself is built from get_subscription_local_data(), which matches
zip_slug against every installed plugin, so the button appeared on rows the
setter could not act on. Resolve through the same data the row uses.
The test fixture now leaves the Woo header empty, which is what a WordPress.org
build looks like, so this path stays covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep auto-update labels out of the "Available to use" table
The label was rendered from nameAndStatus(), which both tables share. Filtering
on local.installed was not enough: is_subscription_installed() marks only one
subscription per product as the installed one, so a store holding several keys
for the same product lists the spares under "Available to use" with
local.installed still true. Those rows are not the copy running on this store,
so their auto-update state says nothing useful.
Pass the table through, as the status, version and action columns already do,
and render the label only for "Installed on this store".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Extend auto-update status to themes
Themes were skipped when this was first built, so a WooCommerce.com theme showed
nothing at all, even with auto-updates off or a disconnected subscription.
Read the state from auto_update_themes and the auto_update_theme filter, with
update_themes as the capability, mirroring core's Themes screen the way the
plugin path mirrors its Plugins screen. The shared logic moves into
get_auto_update_data(), with get_plugin_auto_update_data() and
get_theme_auto_update_data() as the entry points, and the filter payload switches
shape per type because core passes different keys for each.
set_subscription_plugin_auto_update() becomes set_subscription_auto_update() and
writes whichever option matches the product; themes are keyed by stylesheet, not
by a file path. It was added on this branch and has never shipped, so the rename
carries no compatibility cost.
The subscription-level blockers already applied to both, and the copy is now
type-neutral so one set of strings covers plugins and themes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Show the endpoint's reason when enabling auto-updates fails
The endpoint refuses with wp_send_json_error(), which nests the reason under
data.message, but the row read a top-level message property that is never
set. Every refusal, including the one for installed products that have no
subscription, collapsed into "Auto-updates could not be enabled."
The notice now leads with that sentence and appends the endpoint's reason,
and the no-subscription refusal reads "There is no subscription for this
product." so the two sentences make sense together.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Stop blocking auto-updates for copies installed from WordPress.org
Every reason in the "Auto-updates blocked" list is about WooCommerce.com
delivering the update: the Update Manager being inactive, or the subscription
being expired or disconnected. The updater only handles products carrying a
Woo header, though. A copy installed from WordPress.org has none, so core
updates it on its own and none of those reasons apply. The row still showed
the red label for it.
Local data now reports whether the installed copy carries a Woo header, read
from the same lists the updater iterates, and the blockers apply only then.
The "Auto-updates are off" warning is unchanged, since that is a core setting
and enabling it works for either kind of copy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Check only the matching Woo list when reading a row's update source
The Woo header check ran for every row and scanned both the plugin and the
theme lists. The theme scan reads a theme_info.txt file for each theme without
a header, so every plugin row paid for file reads it did not need. The check
now takes the row's type and scans only that list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Move auto-update controls into an "Automatic updates" column
The auto-update state sat next to the product name as a warning label, and
only offered to turn the setting on. It now has its own column in the
"Installed on this store" table, between Version and Actions.
The cell offers to enable or disable the setting. When the setting is on but
WooCommerce.com cannot deliver the update, it shows "Blocked" with the reasons
on hover instead, since none of them can be fixed here. A setting that cannot
be changed from here reads "On" or "Off", with the reason on hover.
"Available to use" keeps its columns: those rows are not the copy running on
this store, so their auto-update state says nothing useful. A new test renders
both tables and checks every row has one cell per header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update changelog entry for the automatic updates column
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Show a progress label while auto-updates are being enabled or disabled
The link gave no sign that anything was happening between the click and the
row refreshing. It now reads "Enabling…" or "Disabling…" with a spinning
refresh icon, the same asset the Refresh button uses, until the request
settles. The spin stops for users who prefer reduced motion.
The button is disabled through aria-disabled rather than the disabled
attribute, so keyboard focus stays on it for the whole round trip instead of
dropping to the page. It keeps the link colour, since the state is progress
rather than a dead control.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Report a failed refresh separately from a failed auto-update toggle
The toggle request and the list refresh that follows shared one catch. If the
toggle succeeded and only the refresh failed, the notice said auto-updates
could not be enabled, which was false, and the row kept showing the old link.
The success announcement now fires as soon as the toggle resolves, and the
refresh carries its own notice saying the setting was saved but the list could
not be refreshed. The outer catch only sees toggle failures.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Pass the real theme update offer to the auto_update_theme filter
The plugin payload already came from the update transient, but the theme
payload was always the empty defaults, so a filter deciding on new_version,
package, or the requirements saw different data here than on the Themes
screen, despite the docblock promising parity.
The theme payload now comes from the update_themes transient when the theme
has an entry, accepting the array shape core writes and the object shape
theme update checkers write. Otherwise it is built from the theme headers,
as wp_prepare_themes_for_js() does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Query the auto-update pills by role in the component tests
The Blocked, On, and Off pills are buttons, but the tests found them by
visible text, which would keep passing if the pill lost its button semantics
and its keyboard access with it. They are now queried by role and accessible
name, matching the link controls in the same file and the admin client's
query-order guideline. The exact name also proves the info icon stays
decorative.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Add per-request cache to local Woo plugin and theme scans
get_local_woo_plugins() and get_local_woo_themes() rescan every installed
plugin and theme on each call, and the My Subscriptions list calls them
once per row. Cache the result in static properties for the rest of the
request behind a new $use_cache parameter that defaults to true.
Passing false rescans and refreshes the cache, which the activated-plugin
and connect-theme handlers use since the product may have been installed
earlier in the same request. The upgrader-complete handler flushes the
cache for the same reason, and the helper tests flush it in setUp.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Fix setProductAutoUpdate JSDoc to cover themes
The endpoint it calls toggles auto-updates for the plugin or theme backing
the subscription, writing auto_update_themes for themes, but the comment
described it as plugin-only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Make the auto-update cell read-only for products without a subscription
Rows for installed products the account has no subscription for were
offered the "Enable auto-updates" link, and saving always failed because
the endpoint resolves the product through its subscription.
Nothing delivers updates for such a product, so the cell now shows "Off"
with a hint to subscribe when the setting is off, and the existing
"Blocked" pill with the no-subscription reason when it is on. The
no-subscription blocker applies regardless of where the product updates
from, so a row without a subscription never offers a toggle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.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/add-wccom-2873-auto-update-status b/plugins/woocommerce/changelog/add-wccom-2873-auto-update-status
new file mode 100644
index 00000000000..d0fafcd79b9
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wccom-2873-auto-update-status
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an "Automatic updates" column to the My Subscriptions screen that turns auto-updates on or off for installed extensions and themes, and shows "Blocked" when they are on but something else stops the product updating
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
index 190abbee563..664804c0080 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
@@ -206,6 +206,11 @@
white-space: nowrap;
font-size: 12px;
+ svg {
+ fill: currentColor;
+ margin-inline-end: $grid-unit-05;
+ }
+
&--error {
color: var(--wp-red-red-70, #8a2424);
background: var(--wp-red-red-0, #fcf0f1);
@@ -228,6 +233,24 @@
a {
text-decoration: none;
}
+
+ p {
+ margin: 0 0 $grid-unit-10;
+ }
+ }
+}
+
+.woocommerce-marketplace__my-subscriptions__auto-update-blockers {
+ list-style: disc;
+ margin: 0;
+ padding-inline-start: $grid-unit-20;
+
+ li {
+ margin-bottom: $grid-unit-05;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
}
}
@@ -238,11 +261,43 @@
.woocommerce-marketplace__my-subscriptions-version {
padding: 6px 12px;
}
-.woocommerce-marketplace__my-subscriptions__table__header--version > span {
+.woocommerce-marketplace__my-subscriptions__table__header--version > span,
+.woocommerce-marketplace__my-subscriptions__table__header--auto-updates > span {
display: inline-block;
padding: 0 12px;
}
+// Lines up with the link button, which carries its own 6px 12px padding.
+.woocommerce-marketplace__my-subscriptions__auto-updates {
+ .woocommerce-marketplace__my-subscriptions__product-status {
+ margin: 6px 12px;
+ }
+
+ // A disabled link button greys out; the saving state is progress, not a dead control.
+ .components-button.is-link:disabled,
+ .components-button.is-link[aria-disabled="true"] {
+ color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+ opacity: 1;
+ }
+}
+
+.woocommerce-marketplace__my-subscriptions__auto-updates-saving-icon {
+ width: 16px;
+ height: 16px;
+ margin-inline-end: $grid-unit-05;
+ animation: woocommerce-marketplace-auto-updates-spin 1s linear infinite;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+}
+
+@keyframes woocommerce-marketplace-auto-updates-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.woocommerce-marketplace__my-subscriptions {
.woocommerce-table__empty-item,
.woocommerce-table__header,
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/auto-update-status.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/auto-update-status.tsx
new file mode 100644
index 00000000000..48aae7c0c69
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/auto-update-status.tsx
@@ -0,0 +1,245 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { useContext, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { speak } from '@wordpress/a11y';
+import { Icon, info } from '@wordpress/icons';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import RefreshIcon from '../../../../assets/images/refresh.svg';
+import { SubscriptionsContext } from '../../../../contexts/subscriptions-context';
+import { NoticeStatus } from '../../../../contexts/types';
+import {
+ addNotice,
+ removeNotice,
+ setProductAutoUpdate,
+} from '../../../../utils/functions';
+import { StatusLevel, Subscription } from '../../types';
+import StatusPopover from './status-popover';
+import { getAdminSetting } from '../../../../../utils/admin-settings';
+
+/**
+ * Reasons a product won't auto-update even though the plugin's own auto-update setting is on.
+ *
+ * A product without a subscription is always blocked. The other reasons are about
+ * WooCommerce.com delivering the update, so none of them applies to a copy installed from
+ * WordPress.org: core updates that one on its own.
+ *
+ * Whether WordPress runs automatic updates at all is deliberately not one of them: that is a
+ * site-wide decision, and repeating it on every row says nothing about the product.
+ */
+export function getAutoUpdateBlockers( subscription: Subscription ): string[] {
+ const blockers: string[] = [];
+
+ if ( subscription.product_key === '' ) {
+ blockers.push(
+ __( 'There is no subscription for it.', 'woocommerce' )
+ );
+
+ return blockers;
+ }
+
+ if ( ! subscription.local?.updates_from_wccom ) {
+ return blockers;
+ }
+
+ const wccomSettings = getAdminSetting( 'wccomHelper', {} );
+
+ if ( ! wccomSettings?.wooUpdateManagerActive ) {
+ blockers.push(
+ __(
+ 'WooCommerce.com Update Manager is not active, and it delivers these updates.',
+ 'woocommerce'
+ )
+ );
+ }
+
+ if ( subscription.expired && ! subscription.lifetime ) {
+ blockers.push( __( 'The subscription has expired.', 'woocommerce' ) );
+ }
+
+ if ( ! subscription.active ) {
+ blockers.push(
+ __(
+ 'The subscription is not connected to this store.',
+ 'woocommerce'
+ )
+ );
+ }
+
+ return blockers;
+}
+
+/**
+ * The "Automatic updates" cell of an installed row.
+ *
+ * Offers to turn the plugin's or theme's auto-update setting on or off. When the setting is on
+ * but something else holds the update back, shows "Blocked" with the reasons instead, since none
+ * of them can be fixed here. A setting that can't be changed from here is shown as plain text,
+ * and so is a product without a subscription, since nothing would deliver its updates.
+ */
+export default function AutoUpdateStatus( props: {
+ subscription: Subscription;
+} ): React.JSX.Element | null {
+ const { subscription } = props;
+ const { loadSubscriptions } = useContext( SubscriptionsContext );
+ const [ isSaving, setIsSaving ] = useState( false );
+
+ const local = subscription.local;
+
+ if ( ! local?.installed ) {
+ return null;
+ }
+
+ function setAutoUpdate( enabled: boolean ) {
+ recordEvent(
+ enabled
+ ? 'marketplace_enable_auto_update_clicked'
+ : 'marketplace_disable_auto_update_clicked',
+ {
+ product_id: subscription.product_id,
+ product_zip_slug: subscription.zip_slug,
+ }
+ );
+
+ setIsSaving( true );
+ removeNotice( subscription.product_key );
+
+ setProductAutoUpdate( subscription, enabled )
+ .then( () => {
+ const announcement = enabled
+ ? /* translators: %s is the product name. */
+ __( 'Auto-updates enabled for %s.', 'woocommerce' )
+ : /* translators: %s is the product name. */
+ __( 'Auto-updates disabled for %s.', 'woocommerce' );
+
+ speak( sprintf( announcement, subscription.product_name ) );
+
+ // The setting is saved by now. A failed refresh only leaves the row stale, so it
+ // gets its own notice rather than the toggle failure below.
+ return loadSubscriptions( false ).catch( () =>
+ addNotice(
+ subscription.product_key,
+ __(
+ 'The setting was saved, but the list could not be refreshed. Reload the page to see the change.',
+ 'woocommerce'
+ ),
+ NoticeStatus.Error
+ )
+ );
+ } )
+ .catch( ( error: { data?: { message?: string } } ) => {
+ const failure = enabled
+ ? __( 'Auto-updates could not be enabled.', 'woocommerce' )
+ : __(
+ 'Auto-updates could not be disabled.',
+ 'woocommerce'
+ );
+
+ // The endpoint answers with wp_send_json_error(), which nests the reason under data.
+ const reason = error?.data?.message;
+
+ addNotice(
+ subscription.product_key,
+ reason ? `${ failure } ${ reason }` : failure,
+ NoticeStatus.Error
+ );
+ } )
+ .finally( () => setIsSaving( false ) );
+ }
+
+ const blockers = local.auto_update
+ ? getAutoUpdateBlockers( subscription )
+ : [];
+
+ if ( blockers.length > 0 ) {
+ return (
+ <StatusPopover
+ icon={ <Icon icon={ info } size={ 16 } /> }
+ text={ __( 'Blocked', 'woocommerce' ) }
+ level={ StatusLevel.Error }
+ explanation={
+ <>
+ <p>
+ { __(
+ 'Auto-updates are on, but it will not update because:',
+ 'woocommerce'
+ ) }
+ </p>
+ <ul className="woocommerce-marketplace__my-subscriptions__auto-update-blockers">
+ { blockers.map( ( blocker ) => (
+ <li key={ blocker }>{ blocker }</li>
+ ) ) }
+ </ul>
+ </>
+ }
+ explanationOnHover
+ />
+ );
+ }
+
+ if ( ! local.auto_update_manageable ) {
+ return (
+ <StatusPopover
+ text={
+ local.auto_update
+ ? __( 'On', 'woocommerce' )
+ : __( 'Off', 'woocommerce' )
+ }
+ level={ StatusLevel.Info }
+ explanation={ __(
+ 'Auto-updates for this product are controlled outside this screen.',
+ 'woocommerce'
+ ) }
+ explanationOnHover
+ />
+ );
+ }
+
+ // Nothing delivers updates for a product without a subscription, so there is nothing to
+ // enable. Once the setting is on, the Blocked pill above says the same thing.
+ if ( subscription.product_key === '' && ! local.auto_update ) {
+ return (
+ <StatusPopover
+ text={ __( 'Off', 'woocommerce' ) }
+ level={ StatusLevel.Info }
+ explanation={ __(
+ 'Subscribe to enable auto-updates for this product.',
+ 'woocommerce'
+ ) }
+ explanationOnHover
+ />
+ );
+ }
+
+ if ( isSaving ) {
+ return (
+ <Button variant="link" disabled accessibleWhenDisabled>
+ <img
+ src={ RefreshIcon }
+ alt=""
+ className="woocommerce-marketplace__my-subscriptions__auto-updates-saving-icon"
+ />
+ { local.auto_update
+ ? __( 'Disabling…', 'woocommerce' )
+ : __( 'Enabling…', 'woocommerce' ) }
+ </Button>
+ );
+ }
+
+ return (
+ <Button
+ variant="link"
+ onClick={ () => setAutoUpdate( ! local.auto_update ) }
+ >
+ { local.auto_update
+ ? __( 'Disable auto-updates', 'woocommerce' )
+ : __( 'Enable auto-updates', 'woocommerce' ) }
+ </Button>
+ );
+}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx
index 597d2ae10f1..93cd691693d 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx
@@ -18,6 +18,7 @@ import AutoRenewButton from '../actions/auto-renew-button';
import SubscribeButton from '../actions/subscribe-button';
import Update from '../actions/update';
import StatusPopover from './status-popover';
+import AutoUpdateStatus from './auto-update-status';
import ActionsDropdownMenu from './actions-dropdown-menu';
import Version from './version';
import {
@@ -423,6 +424,16 @@ export function version(
};
}
+export function autoUpdates( subscription: Subscription ): TableRow {
+ return {
+ display: (
+ <div className="woocommerce-marketplace__my-subscriptions__auto-updates">
+ <AutoUpdateStatus subscription={ subscription } />
+ </div>
+ ),
+ };
+}
+
export function actions( subscription: Subscription ): TableRow {
let actionButton = null;
if ( subscription.product_key === '' ) {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx
index 337c1fd4367..8eba3c9c6e5 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx
@@ -15,6 +15,7 @@ export default function StatusPopover( props: {
level: StatusLevel;
explanation: string | React.JSX.Element;
explanationOnHover?: boolean;
+ icon?: React.JSX.Element;
} ) {
const [ isHovered, setIsHovered ] = useState( false );
const [ isClicked, setIsClicked ] = useState( false );
@@ -76,6 +77,7 @@ export default function StatusPopover( props: {
`woocommerce-marketplace__my-subscriptions__product-status--${ props.level }`
) }
>
+ { props.icon }
{ props.text }
{ shouldShowExplanation() && (
<Popover
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/test/auto-update-status.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/test/auto-update-status.test.tsx
new file mode 100644
index 00000000000..3929f420612
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/test/auto-update-status.test.tsx
@@ -0,0 +1,459 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import React from 'react';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+ queueRecordEvent: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/a11y', () => ( {
+ speak: jest.fn(),
+} ) );
+
+jest.mock( '../../../../../../utils/admin-settings', () => ( {
+ getAdminSetting: jest.fn(),
+} ) );
+
+jest.mock( '../../../../../utils/functions', () => ( {
+ setProductAutoUpdate: jest.fn( () => Promise.resolve() ),
+ addNotice: jest.fn(),
+ removeNotice: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { speak } from '@wordpress/a11y';
+import {
+ setProductAutoUpdate,
+ addNotice,
+} from '../../../../../utils/functions';
+import { getAdminSetting } from '../../../../../../utils/admin-settings';
+import AutoUpdateStatus from '../auto-update-status';
+import { SubscriptionsContext } from '../../../../../contexts/subscriptions-context';
+import { SubscriptionsContextType } from '../../../../../contexts/types';
+import { Subscription, SubscriptionLocal } from '../../../types';
+
+const loadSubscriptions = jest.fn( () => Promise.resolve() );
+
+function subscriptionWith(
+ local: Partial< SubscriptionLocal >,
+ subscription: Partial< Subscription > = {}
+): Subscription {
+ return {
+ product_key: 'test-key',
+ product_id: 123,
+ product_name: 'Test Extension',
+ zip_slug: 'test-extension',
+ expired: false,
+ lifetime: false,
+ active: true,
+ ...subscription,
+ local: {
+ installed: true,
+ installable: true,
+ active: true,
+ version: '1.0.0',
+ type: 'plugin',
+ slug: 'test-extension',
+ path: 'test-extension/test-extension.php',
+ auto_update: false,
+ auto_update_manageable: true,
+ updates_from_wccom: true,
+ ...local,
+ },
+ } as Subscription;
+}
+
+function renderStatus( subscription: Subscription ) {
+ return render(
+ <SubscriptionsContext.Provider
+ value={
+ { loadSubscriptions } as unknown as SubscriptionsContextType
+ }
+ >
+ <AutoUpdateStatus subscription={ subscription } />
+ </SubscriptionsContext.Provider>
+ );
+}
+
+/**
+ * Stand in the site-level settings, healthy unless a test says otherwise.
+ */
+function setSiteSettings( settings: Record< string, boolean > = {} ) {
+ ( getAdminSetting as jest.Mock ).mockReturnValue( {
+ wooUpdateManagerActive: true,
+ ...settings,
+ } );
+}
+
+describe( 'AutoUpdateStatus', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ setSiteSettings();
+ } );
+
+ it( 'renders nothing when the product is not installed', () => {
+ const { container } = renderStatus(
+ subscriptionWith( { installed: false } )
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'offers to enable auto-updates when they are off', () => {
+ renderStatus( subscriptionWith( {} ) );
+
+ expect(
+ screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'offers to disable auto-updates when they are on and nothing blocks them', () => {
+ renderStatus( subscriptionWith( { auto_update: true } ) );
+
+ expect(
+ screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', { name: 'Blocked' } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows the setting as text when it cannot be changed from here', async () => {
+ renderStatus(
+ subscriptionWith( {
+ auto_update: true,
+ auto_update_manageable: false,
+ } )
+ );
+
+ expect(
+ screen.queryByRole( 'button', { name: /auto-updates/ } )
+ ).not.toBeInTheDocument();
+ fireEvent.click( screen.getByRole( 'button', { name: 'On' } ) );
+
+ expect(
+ await screen.findByText(
+ 'Auto-updates for this product are controlled outside this screen.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows off as text when it cannot be changed from here', () => {
+ renderStatus( subscriptionWith( { auto_update_manageable: false } ) );
+
+ expect(
+ screen.getByRole( 'button', { name: 'Off' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'does not offer to enable auto-updates without a subscription', async () => {
+ renderStatus(
+ subscriptionWith(
+ {},
+ { product_key: '', expired: true, active: false }
+ )
+ );
+
+ expect(
+ screen.queryByRole( 'button', { name: /auto-updates/ } )
+ ).not.toBeInTheDocument();
+ fireEvent.click( screen.getByRole( 'button', { name: 'Off' } ) );
+
+ expect(
+ await screen.findByText(
+ 'Subscribe to enable auto-updates for this product.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'treats a theme like a plugin', () => {
+ renderStatus(
+ subscriptionWith( { type: 'theme', auto_update: true } )
+ );
+
+ expect(
+ screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'never blocks a copy installed from WordPress.org', () => {
+ setSiteSettings( { wooUpdateManagerActive: false } );
+ renderStatus(
+ subscriptionWith(
+ { auto_update: true, updates_from_wccom: false },
+ { expired: true, active: false }
+ )
+ );
+
+ expect(
+ screen.queryByRole( 'button', { name: 'Blocked' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'blocks a product without a subscription even when it does not update from WooCommerce.com', async () => {
+ renderStatus(
+ subscriptionWith(
+ { auto_update: true, updates_from_wccom: false },
+ { product_key: '' }
+ )
+ );
+
+ expect(
+ screen.queryByRole( 'button', { name: 'Disable auto-updates' } )
+ ).not.toBeInTheDocument();
+ fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+ expect(
+ await screen.findByText( 'There is no subscription for it.' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows blocked when the Update Manager is not active', async () => {
+ setSiteSettings( { wooUpdateManagerActive: false } );
+ renderStatus( subscriptionWith( { auto_update: true } ) );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+ expect(
+ await screen.findByText(
+ 'WooCommerce.com Update Manager is not active, and it delivers these updates.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows blocked for a theme for the same reasons as a plugin', async () => {
+ setSiteSettings( { wooUpdateManagerActive: false } );
+ renderStatus(
+ subscriptionWith( { type: 'theme', auto_update: true } )
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+ expect(
+ await screen.findByText(
+ 'WooCommerce.com Update Manager is not active, and it delivers these updates.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'does not show blocked when auto-updates are off', () => {
+ setSiteSettings( { wooUpdateManagerActive: false } );
+ renderStatus( subscriptionWith( {} ) );
+
+ expect(
+ screen.queryByRole( 'button', { name: 'Blocked' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'reports a missing subscription and stops there', async () => {
+ renderStatus(
+ subscriptionWith(
+ { auto_update: true },
+ { product_key: '', expired: true, active: false }
+ )
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+ expect(
+ await screen.findByText( 'There is no subscription for it.' )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText( 'The subscription has expired.' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'lists an expired subscription and a disconnected one together', async () => {
+ renderStatus(
+ subscriptionWith(
+ { auto_update: true },
+ { expired: true, active: false }
+ )
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+ expect(
+ await screen.findByText( 'The subscription has expired.' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'The subscription is not connected to this store.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'does not treat a lifetime subscription as expired', () => {
+ renderStatus(
+ subscriptionWith(
+ { auto_update: true },
+ { expired: true, lifetime: true }
+ )
+ );
+
+ expect(
+ screen.queryByRole( 'button', { name: 'Blocked' } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'enables auto-updates, refreshes the row and announces the change', async () => {
+ const subscription = subscriptionWith( {} );
+ renderStatus( subscription );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+ );
+
+ await waitFor( () =>
+ expect( setProductAutoUpdate ).toHaveBeenCalledWith(
+ subscription,
+ true
+ )
+ );
+ await waitFor( () =>
+ expect( loadSubscriptions ).toHaveBeenCalledWith( false )
+ );
+ await waitFor( () =>
+ expect( speak ).toHaveBeenCalledWith(
+ 'Auto-updates enabled for Test Extension.'
+ )
+ );
+ } );
+
+ it( 'disables auto-updates and announces the change', async () => {
+ const subscription = subscriptionWith( { auto_update: true } );
+ renderStatus( subscription );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+ );
+
+ await waitFor( () =>
+ expect( setProductAutoUpdate ).toHaveBeenCalledWith(
+ subscription,
+ false
+ )
+ );
+ await waitFor( () =>
+ expect( speak ).toHaveBeenCalledWith(
+ 'Auto-updates disabled for Test Extension.'
+ )
+ );
+ } );
+
+ it( 'reports a failed refresh without calling the saved toggle a failure', async () => {
+ loadSubscriptions.mockRejectedValueOnce( new Error( 'offline' ) );
+ renderStatus( subscriptionWith( {} ) );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+ );
+
+ await waitFor( () =>
+ expect( addNotice ).toHaveBeenCalledWith(
+ 'test-key',
+ 'The setting was saved, but the list could not be refreshed. Reload the page to see the change.',
+ 'error'
+ )
+ );
+ expect( speak ).toHaveBeenCalledWith(
+ 'Auto-updates enabled for Test Extension.'
+ );
+ expect( addNotice ).not.toHaveBeenCalledWith(
+ 'test-key',
+ expect.stringContaining( 'could not be enabled' ),
+ 'error'
+ );
+ } );
+
+ it( 'shows a progress label while enabling', async () => {
+ let finish: () => void = () => {};
+ ( setProductAutoUpdate as jest.Mock ).mockReturnValueOnce(
+ new Promise< void >( ( resolve ) => {
+ finish = resolve;
+ } )
+ );
+ renderStatus( subscriptionWith( {} ) );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+ );
+
+ // Disabled through aria-disabled, so focus stays on the control while it works.
+ expect(
+ await screen.findByRole( 'button', { name: 'Enabling…' } )
+ ).toHaveAttribute( 'aria-disabled', 'true' );
+
+ finish();
+
+ expect(
+ await screen.findByRole( 'button', { name: 'Enable auto-updates' } )
+ ).not.toHaveAttribute( 'aria-disabled' );
+ } );
+
+ it( 'shows a progress label while disabling', async () => {
+ ( setProductAutoUpdate as jest.Mock ).mockReturnValueOnce(
+ new Promise( () => {} )
+ );
+ renderStatus( subscriptionWith( { auto_update: true } ) );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+ );
+
+ expect(
+ await screen.findByRole( 'button', { name: 'Disabling…' } )
+ ).toHaveAttribute( 'aria-disabled', 'true' );
+ } );
+
+ it( "surfaces the endpoint's reason when enabling fails", async () => {
+ // The shape wp_send_json_error() produces.
+ ( setProductAutoUpdate as jest.Mock ).mockRejectedValueOnce( {
+ success: false,
+ data: { message: 'There is no subscription for this product.' },
+ } );
+ renderStatus( subscriptionWith( {} ) );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+ );
+
+ await waitFor( () =>
+ expect( addNotice ).toHaveBeenCalledWith(
+ 'test-key',
+ 'Auto-updates could not be enabled. There is no subscription for this product.',
+ 'error'
+ )
+ );
+ } );
+
+ it( 'falls back to a generic notice when disabling fails without a reason', async () => {
+ ( setProductAutoUpdate as jest.Mock ).mockRejectedValueOnce( {
+ code: 'invalid_json',
+ } );
+ renderStatus( subscriptionWith( { auto_update: true } ) );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+ );
+
+ await waitFor( () =>
+ expect( addNotice ).toHaveBeenCalledWith(
+ 'test-key',
+ 'Auto-updates could not be disabled.',
+ 'error'
+ )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx
index a2407427ba0..bc01e325ed2 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx
@@ -8,6 +8,7 @@ import type { TableRow } from '@woocommerce/components/build-types/table/types';
import { Subscription, MySubscriptionsTable } from '../types';
import {
actions,
+ autoUpdates,
subscriptionStatus,
expiry,
nameAndStatus,
@@ -18,11 +19,20 @@ export function subscriptionRow(
item: Subscription,
table: MySubscriptionsTable
): TableRow[] {
- return [
+ const row = [
nameAndStatus( item ),
expiry( item ),
subscriptionStatus( item, table ),
version( item, table ),
- actions( item ),
];
+
+ // Rows in "Available to use" are not the copy running on this store, so their auto-update
+ // state says nothing useful.
+ if ( table === 'installed' ) {
+ row.push( autoUpdates( item ) );
+ }
+
+ row.push( actions( item ) );
+
+ return row;
}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx
index c52d1a23bf1..18eb7fd37ab 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx
@@ -72,6 +72,10 @@ export function InstalledSubscriptionsTable( props: {
} ) {
const headers = [
...tableHeadersDefault,
+ {
+ key: 'auto-updates',
+ label: __( 'Automatic updates', 'woocommerce' ),
+ },
{
key: 'actions',
label: __( 'Actions', 'woocommerce' ),
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/test/table-rows.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/test/table-rows.test.tsx
new file mode 100644
index 00000000000..11c275f3cf1
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/test/table-rows.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+ queueRecordEvent: jest.fn(),
+} ) );
+
+jest.mock( '../../../../../utils/admin-settings', () => ( {
+ getAdminSetting: jest.fn( () => ( { wooUpdateManagerActive: true } ) ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { subscriptionRow } from '../table-rows';
+import {
+ AvailableSubscriptionsTable,
+ InstalledSubscriptionsTable,
+} from '../table';
+import { Subscription } from '../../types';
+
+const subscription = {
+ product_key: 'test-key',
+ product_id: 123,
+ product_name: 'Test Extension',
+ product_url: 'https://woocommerce.com/products/test-extension/',
+ zip_slug: 'test-extension',
+ expires: 0,
+ expired: false,
+ expiring: false,
+ lifetime: false,
+ autorenew: true,
+ active: true,
+ is_shared: false,
+ version: '1.0.0',
+ local: {
+ installed: true,
+ installable: true,
+ active: true,
+ version: '1.0.0',
+ type: 'plugin',
+ slug: 'test-extension',
+ path: 'test-extension/test-extension.php',
+ auto_update: false,
+ auto_update_manageable: true,
+ updates_from_wccom: true,
+ },
+} as unknown as Subscription;
+
+describe( 'subscriptionRow', () => {
+ it( 'gives installed rows one cell per header, including automatic updates', () => {
+ render(
+ <InstalledSubscriptionsTable
+ rows={ [ subscriptionRow( subscription, 'installed' ) ] }
+ isLoading={ false }
+ />
+ );
+
+ expect( screen.getAllByRole( 'cell' ) ).toHaveLength(
+ screen.getAllByRole( 'columnheader' ).length
+ );
+ expect(
+ screen.getByRole( 'columnheader', { name: 'Automatic updates' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'gives available rows one cell per header, without automatic updates', () => {
+ render(
+ <AvailableSubscriptionsTable
+ rows={ [ subscriptionRow( subscription, 'available' ) ] }
+ isLoading={ false }
+ />
+ );
+
+ expect( screen.getAllByRole( 'cell' ) ).toHaveLength(
+ screen.getAllByRole( 'columnheader' ).length
+ );
+ expect(
+ screen.queryByRole( 'columnheader', { name: 'Automatic updates' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', { name: 'Enable auto-updates' } )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts
index 5a36c796412..1b411725f2e 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts
@@ -46,6 +46,12 @@ export interface SubscriptionLocal {
type: string;
slug: string;
path: string;
+ /** Whether WordPress will auto-update this plugin, mirroring the Plugins screen. */
+ auto_update: boolean;
+ /** Whether auto-updates can be changed from here: not forced, not disabled site-wide, and the user may update plugins. */
+ auto_update_manageable: boolean;
+ /** Whether the installed copy carries a Woo header, so WooCommerce.com delivers its updates rather than WordPress.org. */
+ updates_from_wccom: boolean;
}
export interface SubscriptionShare {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
index 622d03fa495..ddb85bb8752 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
@@ -309,6 +309,30 @@ function activateProductPlugin( subscription: Subscription ): Promise< void > {
} );
}
+/**
+ * Turn auto-updates on or off for the plugin or theme backing a subscription.
+ *
+ * Writes the same `auto_update_plugins` / `auto_update_themes` option the
+ * WordPress Plugins and Themes screens write.
+ */
+function setProductAutoUpdate(
+ subscription: Subscription,
+ enabled: boolean
+): Promise< void > {
+ const data = new URLSearchParams();
+ data.append( 'product_key', subscription.product_key );
+ data.append( 'enabled', enabled ? '1' : '0' );
+
+ return apiFetch( {
+ path: '/wc/v3/marketplace/subscriptions/auto-update',
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: data,
+ } );
+}
+
function disconnectProduct( subscription: Subscription ): Promise< void > {
if ( subscription.active === false ) {
return Promise.resolve();
@@ -602,6 +626,7 @@ export {
appendURLParams,
connectProduct,
activateProductPlugin,
+ setProductAutoUpdate,
enableAutorenewalUrl,
fetchCategories,
fetchDiscoverPageData,
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
index a73e4460848..5d3b497da3c 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
@@ -110,6 +110,26 @@ class WC_Helper_Subscriptions_API {
)
);
+ register_rest_route(
+ 'wc/v3',
+ '/marketplace/subscriptions/auto-update',
+ array(
+ 'methods' => 'POST',
+ 'callback' => array( __CLASS__, 'set_auto_update' ),
+ 'permission_callback' => array( __CLASS__, 'get_permission' ),
+ 'args' => array(
+ 'product_key' => array(
+ 'required' => true,
+ 'type' => 'string',
+ ),
+ 'enabled' => array(
+ 'required' => true,
+ 'type' => 'boolean',
+ ),
+ ),
+ )
+ );
+
register_rest_route(
'wc/v3',
'/marketplace/subscriptions/install-url',
@@ -273,6 +293,43 @@ class WC_Helper_Subscriptions_API {
}
}
+ /**
+ * Turn auto-updates on or off for a subscription's installed plugin or theme.
+ *
+ * @param WP_REST_Request<array<string, mixed>> $request Request object.
+ *
+ * @return void
+ */
+ public static function set_auto_update( $request ): void {
+ if ( ! current_user_can( 'update_plugins' ) && ! current_user_can( 'update_themes' ) ) {
+ wp_send_json_error(
+ array(
+ 'message' => __( 'Sorry, you are not allowed to modify plugins or themes.', 'woocommerce' ),
+ ),
+ 403
+ );
+ }
+
+ try {
+ WC_Helper::set_subscription_auto_update( $request->get_param( 'product_key' ), (bool) $request->get_param( 'enabled' ) );
+ } catch ( Exception $e ) {
+ wp_send_json_error(
+ array(
+ 'message' => $e->getMessage(),
+ ),
+ 400
+ );
+ }
+
+ wp_send_json_success(
+ array(
+ 'message' => $request->get_param( 'enabled' )
+ ? __( 'Auto-updates are now on for this product.', 'woocommerce' )
+ : __( 'Auto-updates are now off for this product.', 'woocommerce' ),
+ )
+ );
+ }
+
/**
* Disconnect a WooCommerce.com subscription.
*
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 0117ebdc95f..5014be6630c 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -924,6 +924,7 @@ class WC_Helper_Updater {
*/
public static function upgrader_process_complete() {
delete_transient( '_woocommerce_helper_updates_count' );
+ WC_Helper::flush_local_woo_products_cache();
}
/**
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index 45e66bfb46e..9b7dfae98d4 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -28,6 +28,20 @@ class WC_Helper {
*/
public static $log;
+ /**
+ * Per-request cache of get_local_woo_plugins(), keyed by plugin file.
+ *
+ * @var array|null
+ */
+ private static $local_woo_plugins_cache = null;
+
+ /**
+ * Per-request cache of get_local_woo_themes(), keyed by stylesheet path.
+ *
+ * @var array|null
+ */
+ private static $local_woo_themes_cache = null;
+
private const CACHE_KEY_CONNECTION_DATA = '_woocommerce_helper_connection_data';
/**
@@ -1767,9 +1781,20 @@ class WC_Helper {
/**
* Obtain a list of data about locally installed Woo extensions.
*
+ * Scanning every installed plugin is expensive, so the result is cached for the rest of the
+ * request. Pass false after installing or removing a plugin to rescan and refresh the cache.
+ *
+ * @since 11.2.0 Added the `$use_cache` parameter.
+ *
+ * @param bool $use_cache Whether to return the cached list when one exists.
+ *
* @return array
*/
- public static function get_local_woo_plugins() {
+ public static function get_local_woo_plugins( bool $use_cache = true ) {
+ if ( $use_cache && null !== self::$local_woo_plugins_cache ) {
+ return self::$local_woo_plugins_cache;
+ }
+
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
@@ -1826,15 +1851,28 @@ class WC_Helper {
$woo_plugins[ $filename ] = $data;
}
+ self::$local_woo_plugins_cache = $woo_plugins;
+
return $woo_plugins;
}
/**
* Get locally installed Woo themes.
*
+ * Scanning every installed theme is expensive, so the result is cached for the rest of the
+ * request. Pass false after installing or removing a theme to rescan and refresh the cache.
+ *
+ * @since 11.2.0 Added the `$use_cache` parameter.
+ *
+ * @param bool $use_cache Whether to return the cached list when one exists.
+ *
* @return array
*/
- public static function get_local_woo_themes() {
+ public static function get_local_woo_themes( bool $use_cache = true ) {
+ if ( $use_cache && null !== self::$local_woo_themes_cache ) {
+ return self::$local_woo_themes_cache;
+ }
+
$themes = wp_get_themes();
$woo_themes = array();
@@ -1878,6 +1916,8 @@ class WC_Helper {
$woo_themes[ $data['_filename'] ] = $data;
}
+ self::$local_woo_themes_cache = $woo_themes;
+
return $woo_themes;
}
@@ -2422,22 +2462,30 @@ class WC_Helper {
if ( empty( $installed_product ) ) {
return array(
- 'installed' => false,
- 'active' => false,
- 'version' => null,
- 'type' => null,
- 'slug' => null,
- 'path' => null,
+ 'installed' => false,
+ 'active' => false,
+ 'version' => null,
+ 'type' => null,
+ 'slug' => null,
+ 'path' => null,
+ 'auto_update' => false,
+ 'auto_update_manageable' => false,
+ 'updates_from_wccom' => false,
);
}
$local_data = array(
- 'installed' => true,
- 'active' => false,
- 'version' => $installed_product['Version'],
- 'type' => $installed_product['_type'],
- 'slug' => null,
- 'path' => $installed_product['_filename'],
+ 'installed' => true,
+ 'active' => false,
+ 'version' => $installed_product['Version'],
+ 'type' => $installed_product['_type'],
+ 'slug' => null,
+ 'path' => $installed_product['_filename'],
+ 'auto_update' => false,
+ 'auto_update_manageable' => false,
+ // The updater only handles products carrying a Woo header. Without one, this copy
+ // updates from WordPress.org, and nothing WooCommerce.com-related can hold it back.
+ 'updates_from_wccom' => self::has_woo_header( $installed_product['_type'], $installed_product['_filename'] ),
);
if ( 'plugin' === $installed_product['_type'] ) {
@@ -2447,16 +2495,249 @@ class WC_Helper {
} elseif ( is_multisite() && is_plugin_active_for_network( $installed_product['_filename'] ) ) {
$local_data['active'] = true;
}
+
+ $local_data = array_merge( $local_data, self::get_plugin_auto_update_data( $installed_product['_filename'] ) );
} elseif ( 'theme' === $installed_product['_type'] ) {
$local_data['slug'] = $installed_product['_stylesheet'];
if ( in_array( $installed_product['_stylesheet'], array( get_stylesheet(), get_template() ), true ) ) {
$local_data['active'] = true;
}
+
+ $local_data = array_merge( $local_data, self::get_theme_auto_update_data( $installed_product['_stylesheet'] ) );
}
return $local_data;
}
+ /**
+ * Whether an installed plugin or theme carries a Woo header, so the updater handles it.
+ *
+ * @since 11.2.0
+ *
+ * @param string $type 'plugin' or 'theme'.
+ * @param string $filename Plugin file, or a theme's stylesheet followed by /style.css.
+ *
+ * @return bool
+ */
+ private static function has_woo_header( string $type, string $filename ): bool {
+ $woo_products = 'theme' === $type ? self::get_local_woo_themes() : self::get_local_woo_plugins();
+
+ return isset( $woo_products[ $filename ] );
+ }
+
+ /**
+ * Turn auto-updates on or off for the plugin or theme backing a subscription.
+ *
+ * Writes the same auto_update_plugins / auto_update_themes site options WordPress core's
+ * Plugins and Themes screens write, so the change is identical to making it there.
+ *
+ * @since 11.2.0
+ *
+ * @param string $product_key Subscription product key.
+ * @param bool $enabled Whether auto-updates should be on.
+ *
+ * @throws Exception If the subscription, or the product backing it, can't be acted on.
+ *
+ * @return void
+ */
+ public static function set_subscription_auto_update( string $product_key, bool $enabled ): void {
+ $subscription = self::get_subscription( $product_key );
+ if ( ! is_array( $subscription ) ) {
+ throw new Exception( esc_html__( 'There is no subscription for this product.', 'woocommerce' ) );
+ }
+
+ // Resolved the same way the row itself is, by zip_slug against every installed product.
+ // _get_local_from_product_id() only sees products carrying a Woo header, which misses the
+ // WooCommerce.com products distributed through WordPress.org.
+ $local = self::get_subscription_local_data( $subscription );
+ if ( empty( $local['installed'] ) ) {
+ throw new Exception( esc_html__( 'This subscription has no installed product to update.', 'woocommerce' ) );
+ }
+
+ $is_theme = 'theme' === $local['type'];
+
+ // auto_update_themes is keyed by stylesheet, auto_update_plugins by plugin file.
+ $item_key = $is_theme ? (string) $local['slug'] : (string) $local['path'];
+ if ( '' === $item_key ) {
+ throw new Exception( esc_html__( 'This subscription has no installed product to update.', 'woocommerce' ) );
+ }
+
+ $auto_update = $is_theme
+ ? self::get_theme_auto_update_data( $item_key )
+ : self::get_plugin_auto_update_data( $item_key );
+
+ if ( ! $auto_update['auto_update_manageable'] ) {
+ throw new Exception( esc_html__( "Auto-updates for this product can't be changed from here.", 'woocommerce' ) );
+ }
+
+ $all_items = $is_theme ? self::get_all_theme_keys() : self::get_all_plugin_keys();
+ if ( ! in_array( $item_key, $all_items, true ) ) {
+ throw new Exception( esc_html__( 'The product for this subscription is no longer installed.', 'woocommerce' ) );
+ }
+
+ $option = $is_theme ? 'auto_update_themes' : 'auto_update_plugins';
+ $auto_updates = (array) get_site_option( $option, array() );
+
+ if ( $enabled ) {
+ $auto_updates[] = $item_key;
+ $auto_updates = array_unique( $auto_updates );
+ } else {
+ $auto_updates = array_diff( $auto_updates, array( $item_key ) );
+ }
+
+ // Drop entries for products deleted since the option was last written, as core does.
+ $auto_updates = array_intersect( $auto_updates, $all_items );
+
+ update_site_option( $option, array_values( $auto_updates ) );
+ }
+
+ /**
+ * Keys of every installed plugin, as auto_update_plugins stores them.
+ *
+ * @since 11.2.0
+ *
+ * @return string[]
+ */
+ private static function get_all_plugin_keys(): array {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ // Core's own filter, documented in wp-admin/includes/class-wp-plugins-list-table.php; applied here so this
+ // path sees the same plugin list core's auto-update toggle does.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment, WooCommerce.Commenting.CommentHooks.MissingSinceComment -- Not a WooCommerce hook.
+ return array_keys( (array) apply_filters( 'all_plugins', get_plugins() ) );
+ }
+
+ /**
+ * Stylesheets of every installed theme, as auto_update_themes stores them.
+ *
+ * @since 11.2.0
+ *
+ * @return string[]
+ */
+ private static function get_all_theme_keys(): array {
+ return array_keys( wp_get_themes() );
+ }
+
+ /**
+ * Auto-update state of an installed plugin, following the same rules as WordPress core's
+ * Plugins screen so the two screens never describe the same plugin differently.
+ *
+ * @since 11.2.0
+ *
+ * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+ *
+ * @return array{auto_update: bool, auto_update_manageable: bool}
+ */
+ public static function get_plugin_auto_update_data( string $plugin_file ): array {
+ return self::get_auto_update_data( 'plugin', $plugin_file );
+ }
+
+ /**
+ * Auto-update state of an installed theme, following the same rules as WordPress core's
+ * Themes screen.
+ *
+ * @since 11.2.0
+ *
+ * @param string $stylesheet Directory name of the theme.
+ *
+ * @return array{auto_update: bool, auto_update_manageable: bool}
+ */
+ public static function get_theme_auto_update_data( string $stylesheet ): array {
+ return self::get_auto_update_data( 'theme', $stylesheet );
+ }
+
+ /**
+ * Shared auto-update state for a plugin or a theme.
+ *
+ * @since 11.2.0
+ *
+ * @param 'plugin'|'theme' $type The product type.
+ * @param string $item_key Plugin file, or theme stylesheet.
+ *
+ * @return array{auto_update: bool, auto_update_manageable: bool}
+ */
+ private static function get_auto_update_data( string $type, string $item_key ): array {
+ require_once ABSPATH . 'wp-admin/includes/update.php';
+
+ $forced = wp_is_auto_update_forced_for_item( $type, null, self::get_auto_update_filter_payload( $type, $item_key ) );
+ if ( ! is_null( $forced ) ) {
+ return array(
+ 'auto_update' => (bool) $forced,
+ 'auto_update_manageable' => false,
+ );
+ }
+
+ $option = 'theme' === $type ? 'auto_update_themes' : 'auto_update_plugins';
+ $capability = 'theme' === $type ? 'update_themes' : 'update_plugins';
+
+ return array(
+ // The product's own setting. AUTOMATIC_UPDATER_DISABLED and the auto-update-enabled
+ // filters switch the feature off for the whole site without changing it, which the
+ // screen deliberately does not report per row.
+ 'auto_update' => in_array( $item_key, (array) get_site_option( $option, array() ), true ),
+ // These options are network options, and core only offers the toggle from network admin
+ // on multisite. My Subscriptions is a site-level screen, so it reads only there.
+ 'auto_update_manageable' => wp_is_auto_update_enabled_for_type( $type )
+ && ! is_multisite()
+ && current_user_can( $capability ),
+ );
+ }
+
+ /**
+ * Payload for the auto_update_plugin / auto_update_theme filters, matching what core's list
+ * tables pass so third-party callbacks see the same shape on both screens.
+ *
+ * @since 11.2.0
+ *
+ * @param 'plugin'|'theme' $type The product type.
+ * @param string $item_key Plugin file, or theme stylesheet.
+ *
+ * @return object
+ */
+ private static function get_auto_update_filter_payload( string $type, string $item_key ) {
+ if ( 'theme' === $type ) {
+ // Core stores theme entries as arrays; extensions that add their own sometimes use objects.
+ $updates = get_site_transient( 'update_themes' );
+ $update_item = $updates->response[ $item_key ] ?? $updates->no_update[ $item_key ] ?? null;
+ if ( ! empty( $update_item ) ) {
+ return (object) $update_item;
+ }
+
+ // Same fallback as wp_prepare_themes_for_js() when the theme is not in the transient.
+ $theme = wp_get_theme( $item_key );
+
+ return (object) array(
+ 'theme' => $item_key,
+ 'new_version' => $theme->get( 'Version' ),
+ 'url' => '',
+ 'package' => '',
+ 'requires' => $theme->get( 'RequiresWP' ),
+ 'requires_php' => $theme->get( 'RequiresPHP' ),
+ );
+ }
+
+ $defaults = array(
+ 'id' => $item_key,
+ 'slug' => '',
+ 'plugin' => $item_key,
+ 'new_version' => '',
+ 'url' => '',
+ 'package' => '',
+ 'icons' => array(),
+ 'banners' => array(),
+ 'banners_rtl' => array(),
+ 'tested' => '',
+ 'requires_php' => '',
+ 'compatibility' => new stdClass(),
+ );
+
+ $updates = get_site_transient( 'update_plugins' );
+ $update_item = $updates->response[ $item_key ] ?? $updates->no_update[ $item_key ] ?? null;
+ $plugin_data = is_object( $update_item ) ? (array) $update_item : array();
+
+ return (object) wp_parse_args( $plugin_data, $defaults );
+ }
+
/**
* Runs when any plugin is activated.
*
@@ -2469,7 +2750,8 @@ class WC_Helper {
* @return void
*/
public static function activated_plugin( $filename ) {
- $plugins = self::get_local_woo_plugins();
+ // The plugin may have been installed earlier in this request, after the list was cached.
+ $plugins = self::get_local_woo_plugins( false );
// Not a local woo plugin.
if ( empty( $plugins[ $filename ] ) ) {
@@ -2542,7 +2824,7 @@ class WC_Helper {
}
wp_clean_themes_cache( false );
- $themes = self::get_local_woo_themes();
+ $themes = self::get_local_woo_themes( false );
$themes = array_filter(
$themes,
@@ -2774,6 +3056,19 @@ class WC_Helper {
delete_transient( '_woocommerce_helper_subscriptions' );
}
+ /**
+ * Drop the per-request caches of get_local_woo_plugins() and get_local_woo_themes(),
+ * so the next call rescans the installed products.
+ *
+ * @since 11.2.0
+ *
+ * @return void
+ */
+ public static function flush_local_woo_products_cache(): void {
+ self::$local_woo_plugins_cache = null;
+ self::$local_woo_themes_cache = null;
+ }
+
/**
* 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 a8edcff698d..3beeb84f212 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
@@ -14,6 +14,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
public function setUp(): void {
parent::setUp();
$this->cleanup_helper_transients();
+ WC_Helper::flush_local_woo_products_cache();
}
/**
@@ -21,10 +22,63 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
*/
public function tearDown(): void {
$this->cleanup_helper_transients();
+ $this->cleanup_auto_update_state();
unset( $_GET['page'] );
parent::tearDown();
}
+ /**
+ * Clean up the state the auto-update tests stand in.
+ */
+ private function cleanup_auto_update_state(): void {
+ delete_site_option( 'auto_update_plugins' );
+ delete_site_option( 'auto_update_themes' );
+ delete_site_transient( 'update_themes' );
+ wp_cache_delete( 'plugins', 'plugins' );
+ wp_set_current_user( 0 );
+ remove_all_filters( 'auto_update_plugin' );
+ remove_all_filters( 'plugins_auto_update_enabled' );
+ remove_all_filters( 'themes_auto_update_enabled' );
+ remove_all_filters( 'auto_update_theme' );
+ }
+
+ /**
+ * Stand in a plugin list, which get_plugins() reads from the 'plugins' cache group, and log in
+ * a user who may update plugins.
+ *
+ * @return void
+ */
+ private function prepare_auto_update_env(): void {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ // The test suite runs with background updates switched off; these tests are about what the
+ // screen reports on a site where the feature is available.
+ add_filter( 'plugins_auto_update_enabled', '__return_true' );
+ add_filter( 'themes_auto_update_enabled', '__return_true' );
+
+ $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ $admin = new WP_User( $admin_id );
+ $admin->add_cap( 'update_plugins' );
+ wp_set_current_user( $admin_id );
+
+ wp_cache_set(
+ 'plugins',
+ array(
+ '' => array(
+ 'test-woo-extension/test-woo-extension.php' => array(
+ 'Name' => 'Test Woo Extension',
+ 'Version' => '1.0.0',
+ // Empty, as it is for a WooCommerce.com product distributed through
+ // WordPress.org. The key itself has to be present or get_local_woo_plugins()
+ // discards this stand-in and re-reads the real plugin list.
+ 'Woo' => '',
+ ),
+ ),
+ ),
+ 'plugins'
+ );
+ }
+
/**
* Clean up transients used by WC_Helper.
*/
@@ -778,6 +832,44 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
$this->assertArrayHasKey( $woocommerce_key, $woo_plugins );
}
+ /**
+ * @testdox The local Woo plugin list is cached for the request until a caller asks for a rescan.
+ */
+ public function test_get_local_woo_plugins_is_cached_until_a_rescan_is_requested(): void {
+ $first = array(
+ 'first-woo-plugin/first-woo-plugin.php' => array(
+ 'Name' => 'First',
+ 'Version' => '1.0.0',
+ 'Woo' => '111:aaa',
+ ),
+ );
+ $second = array(
+ 'second-woo-plugin/second-woo-plugin.php' => array(
+ 'Name' => 'Second',
+ 'Version' => '1.0.0',
+ 'Woo' => '222:bbb',
+ ),
+ );
+
+ wp_cache_set( 'plugins', array( '' => $first ), 'plugins' );
+ $this->assertSame( array_keys( $first ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+ // A plugin installed after the first scan is invisible to cached calls.
+ wp_cache_set( 'plugins', array( '' => $first + $second ), 'plugins' );
+ $this->assertSame( array_keys( $first ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+ // Asking for a rescan returns the new list and refreshes the cache for later callers.
+ $this->assertSame( array_keys( $first + $second ), array_keys( WC_Helper::get_local_woo_plugins( false ) ) );
+ $this->assertSame( array_keys( $first + $second ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+ // Flushing drops the cache, so the next default call scans again.
+ wp_cache_set( 'plugins', array( '' => $second ), 'plugins' );
+ WC_Helper::flush_local_woo_products_cache();
+ $this->assertSame( array_keys( $second ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+ wp_clean_plugins_cache( false );
+ }
+
/**
* Invoke the private static WC_Helper::get_subscriptions_url().
*
@@ -811,4 +903,346 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
$this->get_subscriptions_url()
);
}
+
+ /**
+ * @testdox Auto-update data reports off for a plugin absent from the auto_update_plugins option.
+ */
+ public function test_plugin_auto_update_data_reports_off_by_default(): void {
+ $this->prepare_auto_update_env();
+
+ $data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+ $this->assertFalse( $data['auto_update'], 'A plugin not listed in the option does not auto-update.' );
+ $this->assertTrue( $data['auto_update_manageable'], 'An administrator on single site can turn it on.' );
+ }
+
+ /**
+ * @testdox Auto-update data reports on for a plugin listed in the auto_update_plugins option.
+ */
+ public function test_plugin_auto_update_data_reports_on_when_listed(): void {
+ $this->prepare_auto_update_env();
+ update_site_option( 'auto_update_plugins', array( 'test-woo-extension/test-woo-extension.php' ) );
+
+ $data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+ $this->assertTrue( $data['auto_update'] );
+ }
+
+ /**
+ * @testdox A forced auto-update state is reported but not offered as an action.
+ *
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $forced The state the auto_update_plugin filter forces.
+ */
+ public function test_plugin_auto_update_data_reports_forced_state_without_an_action( bool $forced ): void {
+ $this->prepare_auto_update_env();
+ add_filter(
+ 'auto_update_plugin',
+ function () use ( $forced ) {
+ return $forced;
+ }
+ );
+
+ $data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+ $this->assertSame( $forced, $data['auto_update'], 'The forced state is what actually happens.' );
+ $this->assertFalse( $data['auto_update_manageable'], 'A forced state cannot be changed from here.' );
+ }
+
+ /**
+ * @testdox The site-wide switch leaves the plugin's own setting alone but blocks changing it.
+ */
+ public function test_plugin_auto_update_data_follows_the_site_wide_switch(): void {
+ $this->prepare_auto_update_env();
+ update_site_option( 'auto_update_plugins', array( 'test-woo-extension/test-woo-extension.php' ) );
+ add_filter( 'plugins_auto_update_enabled', '__return_false' );
+
+ $data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+ $this->assertTrue( $data['auto_update'], "The plugin's own setting is untouched; the screen reports the site-wide block separately." );
+ $this->assertFalse( $data['auto_update_manageable'], 'There is nothing to toggle while the feature is off site-wide.' );
+ }
+
+ /**
+ * @testdox Auto-updates are not manageable without the update_plugins capability.
+ */
+ public function test_plugin_auto_update_data_requires_the_update_plugins_capability(): void {
+ $this->prepare_auto_update_env();
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+ $data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+ $this->assertFalse( $data['auto_update_manageable'] );
+ }
+
+ /**
+ * @testdox Enabling auto-updates writes the plugin into the auto_update_plugins option.
+ */
+ public function test_set_subscription_auto_update_enables(): void {
+ $this->prepare_auto_update_env();
+ $this->set_subscription_for_local_plugin();
+
+ WC_Helper::set_subscription_auto_update( 'test-key', true );
+
+ $this->assertSame(
+ array( 'test-woo-extension/test-woo-extension.php' ),
+ (array) get_site_option( 'auto_update_plugins' )
+ );
+ }
+
+ /**
+ * @testdox Disabling auto-updates removes the plugin from the auto_update_plugins option.
+ */
+ public function test_set_subscription_auto_update_disables(): void {
+ $this->prepare_auto_update_env();
+ $this->set_subscription_for_local_plugin();
+ update_site_option( 'auto_update_plugins', array( 'test-woo-extension/test-woo-extension.php', 'other/other.php' ) );
+
+ WC_Helper::set_subscription_auto_update( 'test-key', false );
+
+ $this->assertSame( array(), (array) get_site_option( 'auto_update_plugins' ), 'Plugins no longer installed are dropped too.' );
+ }
+
+ /**
+ * @testdox Setting auto-updates is refused when the state cannot be changed from here.
+ */
+ public function test_set_subscription_auto_update_refuses_a_forced_state(): void {
+ $this->prepare_auto_update_env();
+ $this->set_subscription_for_local_plugin();
+ add_filter( 'auto_update_plugin', '__return_false' );
+
+ $this->expectException( Exception::class );
+
+ WC_Helper::set_subscription_auto_update( 'test-key', true );
+ }
+
+ /**
+ * @testdox Auto-updates can be set for a product distributed through WordPress.org.
+ */
+ public function test_set_subscription_auto_update_handles_a_wporg_product(): void {
+ $this->prepare_auto_update_env();
+ $this->set_subscription_for_local_plugin();
+
+ // The fixture plugin carries no Woo header, which is what a WordPress.org build looks like.
+ WC_Helper::set_subscription_auto_update( 'test-key', true );
+
+ $this->assertContains(
+ 'test-woo-extension/test-woo-extension.php',
+ (array) get_site_option( 'auto_update_plugins' ),
+ 'Resolution has to go through the same local data the row is built from.'
+ );
+ }
+
+ /**
+ * @testdox Local data reports whether the installed copy carries a Woo header.
+ */
+ public function test_local_data_reports_where_updates_come_from(): void {
+ $this->prepare_auto_update_env();
+
+ $plugins = wp_cache_get( 'plugins', 'plugins' );
+
+ $plugins['']['wccom-extension/wccom-extension.php'] = array(
+ 'Name' => 'WooCommerce.com Extension',
+ 'Version' => '1.0.0',
+ 'Woo' => '456:abcdef',
+ );
+ wp_cache_set( 'plugins', $plugins, 'plugins' );
+
+ // The fixture plugin has an empty Woo header, as a WordPress.org build does.
+ $wporg = WC_Helper::get_subscription_local_data( array( 'zip_slug' => 'test-woo-extension' ) );
+ $wccom = WC_Helper::get_subscription_local_data( array( 'zip_slug' => 'wccom-extension' ) );
+ $none = WC_Helper::get_subscription_local_data( array( 'zip_slug' => 'not-installed' ) );
+
+ $this->assertFalse( $wporg['updates_from_wccom'], 'Without a Woo header, core updates the plugin from WordPress.org.' );
+ $this->assertTrue( $wccom['updates_from_wccom'] );
+ $this->assertFalse( $none['updates_from_wccom'] );
+ }
+
+ /**
+ * @testdox Auto-update data for a theme reads the auto_update_themes option.
+ */
+ public function test_theme_auto_update_data_reads_the_theme_option(): void {
+ $this->prepare_auto_update_env();
+ $stylesheet = get_stylesheet();
+
+ $off = WC_Helper::get_theme_auto_update_data( $stylesheet );
+ update_site_option( 'auto_update_themes', array( $stylesheet ) );
+ $on = WC_Helper::get_theme_auto_update_data( $stylesheet );
+
+ $this->assertFalse( $off['auto_update'], 'A theme not listed in the option does not auto-update.' );
+ $this->assertTrue( $on['auto_update'] );
+ $this->assertTrue( $on['auto_update_manageable'], 'An administrator on single site can change it.' );
+ }
+
+ /**
+ * @testdox The auto_update_theme filter receives the installed version when the theme has no update entry.
+ */
+ public function test_theme_auto_update_filter_receives_the_installed_version(): void {
+ $this->prepare_auto_update_env();
+ $stylesheet = get_stylesheet();
+ $version = wp_get_theme( $stylesheet )->get( 'Version' );
+ $seen = null;
+
+ // Blocks only when the payload carries the installed version, as core's fallback does.
+ add_filter(
+ 'auto_update_theme',
+ function ( $update, $item ) use ( &$seen, $version ) {
+ $seen = $item;
+ return $item->new_version === $version ? false : $update;
+ },
+ 10,
+ 2
+ );
+
+ $data = WC_Helper::get_theme_auto_update_data( $stylesheet );
+
+ $this->assertSame( $version, $seen->new_version, 'The fallback carries the same fields core passes.' );
+ $this->assertFalse( $data['auto_update_manageable'], 'The filter could decide on that field, so the state is forced.' );
+ }
+
+ /**
+ * @testdox The auto_update_theme filter receives the update offer when the theme has one.
+ */
+ public function test_theme_auto_update_filter_receives_the_update_offer(): void {
+ $this->prepare_auto_update_env();
+ $stylesheet = get_stylesheet();
+ $seen = null;
+
+ set_site_transient(
+ 'update_themes',
+ (object) array(
+ 'response' => array(
+ $stylesheet => array(
+ 'theme' => $stylesheet,
+ 'new_version' => '99.0.0',
+ 'package' => 'https://example.com/theme.zip',
+ ),
+ ),
+ )
+ );
+ add_filter(
+ 'auto_update_theme',
+ function ( $update, $item ) use ( &$seen ) {
+ $seen = $item;
+ return $update;
+ },
+ 10,
+ 2
+ );
+
+ WC_Helper::get_theme_auto_update_data( $stylesheet );
+
+ $this->assertSame( '99.0.0', $seen->new_version );
+ $this->assertSame( 'https://example.com/theme.zip', $seen->package );
+ }
+
+ /**
+ * @testdox Theme auto-updates are not manageable without the update_themes capability.
+ */
+ public function test_theme_auto_update_data_requires_the_update_themes_capability(): void {
+ $this->prepare_auto_update_env();
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+ $data = WC_Helper::get_theme_auto_update_data( get_stylesheet() );
+
+ $this->assertFalse( $data['auto_update_manageable'] );
+ }
+
+ /**
+ * @testdox Enabling auto-updates for a theme writes the stylesheet into auto_update_themes.
+ */
+ public function test_set_subscription_auto_update_enables_a_theme(): void {
+ $this->prepare_auto_update_env();
+ $stylesheet = get_stylesheet();
+ $this->set_subscription_for_local_theme( $stylesheet );
+
+ WC_Helper::set_subscription_auto_update( 'test-theme-key', true );
+
+ $this->assertContains(
+ $stylesheet,
+ (array) get_site_option( 'auto_update_themes' ),
+ 'Themes are keyed by stylesheet, not by a file path.'
+ );
+ }
+
+ /**
+ * @testdox Disabling auto-updates for a theme removes the stylesheet from auto_update_themes.
+ */
+ public function test_set_subscription_auto_update_disables_a_theme(): void {
+ $this->prepare_auto_update_env();
+ $stylesheet = get_stylesheet();
+ $this->set_subscription_for_local_theme( $stylesheet );
+ update_site_option( 'auto_update_themes', array( $stylesheet ) );
+
+ WC_Helper::set_subscription_auto_update( 'test-theme-key', false );
+
+ $this->assertNotContains( $stylesheet, (array) get_site_option( 'auto_update_themes' ) );
+ }
+
+ /**
+ * Stand in a subscription whose product is the active theme.
+ *
+ * @param string $stylesheet Directory name of the theme.
+ * @return void
+ */
+ private function set_subscription_for_local_theme( string $stylesheet ): void {
+ set_transient(
+ '_woocommerce_helper_subscriptions',
+ array(
+ array(
+ 'product_key' => 'test-theme-key',
+ 'product_id' => 456,
+ 'product_name' => 'Test Theme',
+ 'zip_slug' => $stylesheet,
+ 'connections' => array(),
+ 'expired' => false,
+ 'expiring' => false,
+ 'lifetime' => false,
+ 'autorenew' => true,
+ 'expires' => time() + DAY_IN_SECONDS,
+ ),
+ ),
+ HOUR_IN_SECONDS
+ );
+ }
+
+ /**
+ * @testdox Setting auto-updates is refused for an unknown subscription.
+ */
+ public function test_set_subscription_auto_update_refuses_an_unknown_subscription(): void {
+ $this->prepare_auto_update_env();
+ $this->set_subscription_for_local_plugin();
+
+ $this->expectException( Exception::class );
+
+ WC_Helper::set_subscription_auto_update( 'no-such-key', true );
+ }
+
+ /**
+ * Stand in a subscription whose product is the plugin prepare_auto_update_env() installs.
+ *
+ * @return void
+ */
+ private function set_subscription_for_local_plugin(): void {
+ set_transient(
+ '_woocommerce_helper_subscriptions',
+ array(
+ array(
+ 'product_key' => 'test-key',
+ 'product_id' => 123,
+ 'product_name' => 'Test Woo Extension',
+ 'zip_slug' => 'test-woo-extension',
+ 'connections' => array(),
+ 'expired' => false,
+ 'expiring' => false,
+ 'lifetime' => false,
+ 'autorenew' => true,
+ 'expires' => time() + DAY_IN_SECONDS,
+ ),
+ ),
+ HOUR_IN_SECONDS
+ );
+ }
}
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 b7778655dd2..d115090056a 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
@@ -47,6 +47,7 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
parent::setUp();
$this->cleanup_transients();
+ WC_Helper::flush_local_woo_products_cache();
}
/**