Commit 88e53ada12d for woocommerce
commit 88e53ada12dd55d1663ad68aab0199df8f920c38
Author: Thilina Pituwala <thilina.hasantha@gmail.com>
Date: Fri Oct 16 11:11:16 2026 +1100
Add/woomkt 860 surface extensions without a license in wp admin and woo (#69509)
* Add a helper to list WooCommerce.com plugins without a subscription
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show extensions without a subscription in the WooCommerce Status widget
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add changelog entry for the status widget subscription row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Update the icon for extensions without subscription.
* Count active extensions without a subscription on unconnected stores too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add an inbox note for extensions without a subscription
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add changelog entry for the extensions without a subscription inbox note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove the extensions without a subscription note when none is active
An empty list of active extensions doesn't depend on the subscriptions
API, so return it before checking for a recorded API error. Previously a
failing fetch kept an obsolete Inbox note after the last extension was
deactivated or removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add the missing @since tag to the extensions without a subscription note constructor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fetch the subscription list for the extensions without a subscription note at most once a day
Refreshing the Inbox note on every WooCommerce admin page could block the
page on a request to WooCommerce.com whenever the three-hour subscriptions
cache was cold, and every 15 minutes during an outage. Allow that fetch
once a day, or right after the store connects, and otherwise refresh only
from the cache. A store that isn't connected or has no active extension
still refreshes on every page, since that reads local data only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Label subscription requests from the dashboard widget with their own source
The status widget loads over admin-ajax, so its subscription requests
were sent to WooCommerce.com as heartbeat traffic. Send them as
dashboard-widget instead, so the load the widget adds is attributable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Fix the extensions without a subscription note tests in the full suite
The PageController singleton caches the first page it resolves for the
whole process, so the dashboard test only failed after another test had
resolved a connected page. Reset the cache in that test and on tear down.
Declare the return type of is_applicable() on the note so PHPStan does
not need a new baseline entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Record the UTM parameters of in-admin links on the marketplace view event
The dashboard widget, inbox note and Plugins screen Connect links all open
the in-app My Subscriptions page with utm_source and utm_campaign, but the
marketplace_view event dropped them, so none of those arrivals could be
attributed in core Tracks. Pass them through when present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Add changelog entry for the marketplace view event UTM parameters
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep a dismissed or actioned extensions without a subscription note once resolved
Deleting the note whenever nothing was missing a subscription brought it
back undismissed as soon as an extension became active again, for example
after deactivating every plugin to find a conflict. Only delete a note the
merchant has not dealt with yet, as delete_if_not_applicable() does; a
dismissed or actioned note is hidden anyway and keeps its state when the
extensions return.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Skip the notes table when the extensions without a subscription haven't changed
refresh_note() runs on every WooCommerce admin page. Read the cached
subscription list before consulting the fetch budget, so a store with no
WooCommerce.com extensions never touches the subscriptions transient, and
keep the plugin files of the last refresh in an autoloaded option so a
page load that changes nothing runs no queries at all.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Use patch significance for the marketplace_view UTM changelog entry
A dev entry with only a Comment has an empty body, which the changelogger
only accepts for patch significance, so Validate changelog failed. Also
shorten the comment to the recommended length.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Return false from may_fetch() when there is nothing to fetch
may_fetch() returned true for a store that isn't connected or already has a
cached subscription list, although neither fetches anything. The caller only
reaches it after the cached lookup came back null, so the result is the same;
it now skips a redundant lookup and reads the way it's named.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-woomkt-860-inbox-note-extensions-without-subscription b/plugins/woocommerce/changelog/add-woomkt-860-inbox-note-extensions-without-subscription
new file mode 100644
index 00000000000..3ab41380f5a
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-woomkt-860-inbox-note-extensions-without-subscription
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an Inbox note on WooCommerce Home when active WooCommerce.com extensions have no subscription.
diff --git a/plugins/woocommerce/changelog/add-woomkt-860-my-subscriptions-view-utm b/plugins/woocommerce/changelog/add-woomkt-860-my-subscriptions-view-utm
new file mode 100644
index 00000000000..7cb7a87dd05
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-woomkt-860-my-subscriptions-view-utm
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Add in-admin link UTM parameters to the marketplace_view Tracks event; no merchant-facing change.
+
diff --git a/plugins/woocommerce/changelog/add-woomkt-860-status-widget-extensions-without-subscription b/plugins/woocommerce/changelog/add-woomkt-860-status-widget-extensions-without-subscription
new file mode 100644
index 00000000000..f99a4136f0d
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-woomkt-860-status-widget-extensions-without-subscription
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Show the number of active WooCommerce.com extensions without a subscription in the WooCommerce Status dashboard widget.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx
index f3b58cd3649..a0a1eb63b2a 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx
@@ -384,10 +384,19 @@ export default function Content(): React.JSX.Element {
search_term: query?.term,
product_type: query?.section,
category: query?.category,
+ utm_source: query?.utm_source,
+ utm_campaign: query?.utm_campaign,
};
recordMarketplaceView( marketplaceViewProps );
recordLegacyTabView( marketplaceViewProps );
- }, [ query?.tab, query?.term, query?.section, query?.category ] );
+ }, [
+ query?.tab,
+ query?.term,
+ query?.section,
+ query?.category,
+ query?.utm_source,
+ query?.utm_campaign,
+ ] );
// Reset current page when tab, term, category or badge filter changes
useEffect( () => {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/test/tracking.test.ts b/plugins/woocommerce/client/admin/client/marketplace/utils/test/tracking.test.ts
new file mode 100644
index 00000000000..654fdd0cf77
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/test/tracking.test.ts
@@ -0,0 +1,44 @@
+/**
+ * External dependencies
+ */
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { recordMarketplaceView } from '../tracking';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+describe( 'recordMarketplaceView', () => {
+ beforeEach( () => {
+ jest.mocked( recordEvent ).mockClear();
+ } );
+
+ it( 'records the UTM parameters of a link into the marketplace', () => {
+ recordMarketplaceView( {
+ view: 'my-subscriptions',
+ utm_source: 'pu',
+ utm_campaign: 'pu_dashboard_widget_purchase',
+ } );
+
+ expect( recordEvent ).toHaveBeenCalledWith(
+ 'marketplace_view',
+ expect.objectContaining( {
+ view: 'my-subscriptions',
+ utm_source: 'pu',
+ utm_campaign: 'pu_dashboard_widget_purchase',
+ } )
+ );
+ } );
+
+ it( 'leaves the UTM parameters out when the URL has none', () => {
+ recordMarketplaceView( { view: 'my-subscriptions' } );
+
+ const [ , props ] = jest.mocked( recordEvent ).mock.calls[ 0 ];
+ expect( props ).not.toHaveProperty( 'utm_source' );
+ expect( props ).not.toHaveProperty( 'utm_campaign' );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/tracking.ts b/plugins/woocommerce/client/admin/client/marketplace/utils/tracking.ts
index fec031a7e93..4b9196d6305 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/utils/tracking.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/tracking.ts
@@ -13,6 +13,8 @@ interface MarketplaceViewProps {
search_term?: string;
product_type?: string;
category?: string;
+ utm_source?: string;
+ utm_campaign?: string;
}
function isWCComConnected(): boolean {
@@ -35,12 +37,17 @@ function recordMarketplaceView( props: MarketplaceViewProps ) {
const search_term = props.search_term || null;
const product_type = props.product_type || null;
const category = props.category || null;
+ const utm_source = props.utm_source || null;
+ const utm_campaign = props.utm_campaign || null;
const eventProps = {
...( view && { view } ),
...( search_term && { search_term } ),
...( product_type && { product_type } ),
...( category && { category } ),
+ // Links into the marketplace from elsewhere in wp-admin carry these, so the arrival can be attributed.
+ ...( utm_source && { utm_source } ),
+ ...( utm_campaign && { utm_campaign } ),
wccom_connected: isWCComConnected(),
};
diff --git a/plugins/woocommerce/client/legacy/css/dashboard.scss b/plugins/woocommerce/client/legacy/css/dashboard.scss
index 741768e8ef9..a3adf7ce607 100644
--- a/plugins/woocommerce/client/legacy/css/dashboard.scss
+++ b/plugins/woocommerce/client/legacy/css/dashboard.scss
@@ -204,6 +204,14 @@ ul.woocommerce_stats {
content: "\f158";
}
}
+
+ li.extensions-without-subscription {
+
+ a::before {
+ content: "\f106";
+ color: $red;
+ }
+ }
}
}
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php b/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php
index d4d1793a01d..2497ef7ee6e 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php
@@ -269,9 +269,50 @@ if ( ! class_exists( 'WC_Admin_Dashboard', false ) ) :
*/
$reports = apply_filters( 'woocommerce_after_dashboard_status_widget_parameter', null );
do_action( 'woocommerce_after_dashboard_status_widget', $reports );
+ $this->status_widget_extensions_without_subscription_row();
echo '</ul>';
}
+ /**
+ * Show a row for installed WooCommerce.com extensions without a subscription.
+ */
+ private function status_widget_extensions_without_subscription_row(): void {
+ if ( ! class_exists( 'WC_Helper_Updater' ) ) {
+ return;
+ }
+
+ $count = count( WC_Helper_Updater::get_plugins_without_subscription() ?? array() );
+ if ( 0 === $count ) {
+ return;
+ }
+
+ $my_subscriptions_url = add_query_arg(
+ array(
+ 'page' => 'wc-admin',
+ 'tab' => 'my-subscriptions',
+ 'path' => rawurlencode( '/extensions' ),
+ 'utm_source' => 'pu',
+ 'utm_campaign' => 'pu_dashboard_widget_purchase',
+ ),
+ admin_url( 'admin.php' )
+ );
+ ?>
+ <li class="extensions-without-subscription">
+ <a href="<?php echo esc_url( $my_subscriptions_url ); ?>">
+ <?php
+ echo wp_kses_post(
+ sprintf(
+ /* translators: %s: number of extensions */
+ _n( '<strong>%s extension</strong> without a WooCommerce.com subscription', '<strong>%s extensions</strong> without a WooCommerce.com subscription', $count, 'woocommerce' ),
+ $count
+ )
+ );
+ ?>
+ </a>
+ </li>
+ <?php
+ }
+
/**
* Show order data is status widget.
*/
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 5db885a2189..9061a75ae59 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -504,6 +504,53 @@ class WC_Helper_Updater {
return wp_list_filter( WC_Helper::get_subscriptions(), array( 'product_id' => $product_id ) );
}
+ /**
+ * Active WooCommerce.com plugins that have no subscription on this store.
+ *
+ * A connected store uses the same check as the Plugins screen "Subscribe" notice. A store that
+ * isn't connected has no subscription for any of them. Returns null when an active plugin needs
+ * checking but the subscription list is unknown: the last fetch failed (for example, rate limited),
+ * or it isn't cached and $fetch is false.
+ *
+ * @since 11.3.0
+ *
+ * @param bool $fetch Whether to fetch the subscription list from WooCommerce.com when it isn't cached.
+ * False keeps this call local; a page that can't afford to block passes it.
+ * @return array|null Plugin data from WC_Helper::get_local_woo_plugins(), keyed by plugin file.
+ */
+ public static function get_plugins_without_subscription( bool $fetch = true ): ?array {
+ $plugins = array_filter(
+ WC_Helper::get_local_woo_plugins(),
+ static function ( $plugin_file ) {
+ return WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_PLUGIN_MAIN_FILE !== $plugin_file
+ && is_plugin_active( $plugin_file );
+ },
+ ARRAY_FILTER_USE_KEY
+ );
+
+ if ( empty( $plugins ) || ! WC_Helper::is_site_connected() ) {
+ return $plugins;
+ }
+
+ if ( ! $fetch && ! WC_Helper::has_cached_subscriptions() ) {
+ return null;
+ }
+
+ $plugins = array_filter(
+ $plugins,
+ static function ( $plugin ) {
+ return empty( self::get_subscriptions_for_product( $plugin['_product_id'] ) );
+ }
+ );
+
+ // Read after the subscriptions fetch above, so a failure from it is already recorded.
+ if ( null !== WC_Helper::get_api_error() ) {
+ return null;
+ }
+
+ return $plugins;
+ }
+
/**
* WooCommerce.com product page URL for a plugin, from the update data Core already holds.
*
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index e90c6e2c310..2b976c12760 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -44,6 +44,11 @@ class WC_Helper {
private const CACHE_KEY_CONNECTION_DATA = '_woocommerce_helper_connection_data';
+ /**
+ * Transient holding the connected user's subscriptions, see get_subscriptions().
+ */
+ private const CACHE_KEY_SUBSCRIPTIONS = '_woocommerce_helper_subscriptions';
+
/**
* Transient holding the last failed Helper API subscriptions response, so the
* failure can be surfaced to the merchant instead of rendering as an empty
@@ -2100,6 +2105,17 @@ class WC_Helper {
);
}
+ /**
+ * Whether the subscription list is cached, so get_subscriptions() won't call WooCommerce.com.
+ *
+ * @since 11.3.0
+ *
+ * @return bool
+ */
+ public static function has_cached_subscriptions(): bool {
+ return is_array( get_transient( self::CACHE_KEY_SUBSCRIPTIONS ) );
+ }
+
/**
* Get the connected user's subscriptions.
*
@@ -2108,7 +2124,7 @@ class WC_Helper {
* phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing -- As we wrap the throw in a try/catch.
*/
public static function get_subscriptions() {
- $cache_key = '_woocommerce_helper_subscriptions';
+ $cache_key = self::CACHE_KEY_SUBSCRIPTIONS;
$data = get_transient( $cache_key );
if ( false !== $data ) {
if ( is_array( $data ) ) {
@@ -2127,6 +2143,9 @@ class WC_Helper {
try {
$request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $is_ajax = false !== stripos( $request_uri, 'admin-ajax.php' );
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only labels the request; the ajax handler checks its own nonce.
+ $ajax_action = $is_ajax ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ?? '' ) ) : '';
$source = '';
if ( WC_Helper_API_Backoff::is_refresh_request() ) :
$source = 'refresh-button';
@@ -2136,7 +2155,9 @@ class WC_Helper {
$source = 'plugins';
elseif ( false !== stripos( $request_uri, 'wc-admin' ) ) :
$source = 'inbox-notes';
- elseif ( false !== stripos( $request_uri, 'admin-ajax.php' ) ) :
+ elseif ( 'woocommerce_load_status_widget' === $ajax_action ) :
+ $source = 'dashboard-widget';
+ elseif ( $is_ajax ) :
$source = 'heartbeat-api';
elseif ( false !== stripos( $request_uri, 'installer' ) ) :
$source = 'wccom-site-installer';
@@ -3058,7 +3079,7 @@ class WC_Helper {
* @return void
*/
public static function _flush_subscriptions_cache() {
- delete_transient( '_woocommerce_helper_subscriptions' );
+ delete_transient( self::CACHE_KEY_SUBSCRIPTIONS );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php b/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
index 4da922b912b..2a487d427b9 100644
--- a/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
+++ b/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
@@ -10,6 +10,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
+use Automattic\WooCommerce\Internal\Admin\Notes\ExtensionsWithoutSubscription;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
@@ -187,6 +188,7 @@ class FeaturePlugin {
// Admin note providers.
// @todo These should be bundled in the features/ folder, but loading them from there currently has a load order issue.
new WooSubscriptionsNotes();
+ new ExtensionsWithoutSubscription();
new OrderMilestones();
new TrackingOptIn();
new WooCommercePayments();
diff --git a/plugins/woocommerce/src/Internal/Admin/Notes/ExtensionsWithoutSubscription.php b/plugins/woocommerce/src/Internal/Admin/Notes/ExtensionsWithoutSubscription.php
new file mode 100644
index 00000000000..93ef9a7c235
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/Notes/ExtensionsWithoutSubscription.php
@@ -0,0 +1,213 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Admin\Notes;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\Notes\NoteTraits;
+use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Admin\PageController;
+
+/**
+ * Inbox note for active WooCommerce.com extensions that have no subscription on this store.
+ */
+class ExtensionsWithoutSubscription {
+ use NoteTraits;
+
+ /**
+ * Name of the note for use in the database.
+ */
+ const NOTE_NAME = 'wc-admin-extensions-without-subscription';
+
+ /**
+ * Option holding when the note last fetched the subscription list from WooCommerce.com.
+ */
+ const LAST_FETCH_OPTION_KEY = 'woocommerce_admin-extensions-without-subscription-last-fetch';
+
+ /**
+ * Option holding the plugin files the note was last refreshed for, so unchanged page loads skip the notes table.
+ */
+ const LAST_PLUGINS_OPTION_KEY = 'woocommerce_admin-extensions-without-subscription-last-plugins';
+
+ /**
+ * Hook the note refresh.
+ *
+ * @since 11.3.0
+ */
+ public function __construct() {
+ add_action( 'admin_head', array( $this, 'refresh_note' ) );
+ }
+
+ /**
+ * Add, update or remove the note on WooCommerce admin pages, so it follows the installed extensions.
+ *
+ * Runs on every WooCommerce admin page, so it only touches the notes table when the set of
+ * extensions without a subscription differs from the last refresh.
+ *
+ * @since 11.3.0
+ */
+ public function refresh_note(): void {
+ if ( ! PageController::is_admin_or_embed_page() ) {
+ return;
+ }
+
+ // Try the cached subscription list first, so a store with nothing to check never reads the fetch budget.
+ $plugins = self::get_plugins( false );
+ if ( null === $plugins && self::may_fetch() ) {
+ $plugins = self::get_plugins( true );
+ }
+
+ // Leave the note as it is while the subscription list can't be trusted.
+ if ( null === $plugins ) {
+ return;
+ }
+
+ $plugin_files = array_keys( $plugins );
+ sort( $plugin_files );
+ if ( get_option( self::LAST_PLUGINS_OPTION_KEY ) === $plugin_files ) {
+ return;
+ }
+
+ if ( empty( $plugins ) ) {
+ self::delete_note_unless_dismissed_or_actioned();
+ } elseif ( self::note_exists() ) {
+ self::possibly_update_note();
+ } else {
+ self::possibly_add_note();
+ }
+
+ update_option( self::LAST_PLUGINS_OPTION_KEY, $plugin_files, true );
+ }
+
+ /**
+ * Get the note.
+ *
+ * @since 11.3.0
+ *
+ * @return Note|null
+ */
+ public static function get_note() {
+ $plugins = self::get_plugins();
+ if ( empty( $plugins ) ) {
+ return null;
+ }
+
+ if ( 1 === count( $plugins ) ) {
+ $title = sprintf(
+ /* translators: %s: extension name */
+ __( '%s doesn\'t have an active subscription', 'woocommerce' ),
+ wp_strip_all_tags( current( $plugins )['Name'] )
+ );
+ $content = __( 'Without one, it won\'t receive security updates, product improvements, or support. Subscribe on WooCommerce.com to keep it protected.', 'woocommerce' );
+ } else {
+ $title = __( 'Some of your extensions don\'t have an active subscription', 'woocommerce' );
+ $content = __( 'Without one, they won\'t receive security updates, product improvements, or support. Subscribe on WooCommerce.com to keep them protected.', 'woocommerce' );
+ }
+
+ $note = new Note();
+ $note->set_title( $title );
+ $note->set_content( $content );
+ $note->set_content_data( (object) array( 'plugins' => array_keys( $plugins ) ) );
+ $note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
+ $note->set_name( self::NOTE_NAME );
+ $note->set_source( 'woocommerce-admin' );
+ $note->add_action(
+ 'subscribe',
+ __( 'Subscribe', 'woocommerce' ),
+ add_query_arg(
+ array(
+ 'page' => 'wc-admin',
+ 'tab' => 'my-subscriptions',
+ 'path' => rawurlencode( '/extensions' ),
+ 'utm_source' => 'inbox_notification',
+ 'utm_campaign' => 'pu_inbox_purchase',
+ ),
+ admin_url( 'admin.php' )
+ )
+ );
+
+ return $note;
+ }
+
+ /**
+ * Delete the note now that nothing is missing a subscription, unless the merchant already dealt with it.
+ *
+ * A dismissed or actioned note is hidden anyway, and keeping it means it stays that way if the
+ * extensions come back, for example after deactivating every plugin to find a conflict.
+ *
+ * @return void
+ */
+ private static function delete_note_unless_dismissed_or_actioned(): void {
+ $note = Notes::get_note_by_name( self::NOTE_NAME );
+ if ( ! $note instanceof Note ) {
+ return;
+ }
+
+ if ( $note->get_is_deleted() || Note::E_WC_ADMIN_NOTE_ACTIONED === $note->get_status() ) {
+ return;
+ }
+
+ self::possibly_delete_note();
+ }
+
+ /**
+ * Whether the note should exist.
+ *
+ * The note follows the installed extensions through refresh_note(), so it's always
+ * applicable once added.
+ *
+ * @return bool
+ */
+ public static function is_applicable(): bool {
+ return true;
+ }
+
+ /**
+ * Whether this page load may fetch the subscription list from WooCommerce.com.
+ *
+ * The fetch blocks the page, so only a connected store without a cached list fetches: once a day,
+ * or right after it connects.
+ *
+ * @return bool
+ */
+ private static function may_fetch(): bool {
+ if ( ! class_exists( 'WC_Helper' ) ) {
+ return false;
+ }
+
+ // Nothing to fetch: a store that isn't connected has no list, and a cached one is already in use.
+ if ( ! \WC_Helper::is_site_connected() || \WC_Helper::has_cached_subscriptions() ) {
+ return false;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read only, the same signal WooSubscriptionsNotes uses.
+ if ( isset( $_GET['wc-helper-status'] ) ) {
+ return true;
+ }
+
+ $now = time();
+ if ( (int) get_option( self::LAST_FETCH_OPTION_KEY, 0 ) + DAY_IN_SECONDS > $now ) {
+ return false;
+ }
+
+ update_option( self::LAST_FETCH_OPTION_KEY, $now, false );
+
+ return true;
+ }
+
+ /**
+ * Active WooCommerce.com extensions without a subscription, or null when that can't be known.
+ *
+ * @param bool $fetch Whether the subscription list may be fetched from WooCommerce.com when it isn't cached.
+ * @return array|null
+ */
+ private static function get_plugins( bool $fetch = true ): ?array {
+ if ( ! class_exists( 'WC_Helper_Updater' ) ) {
+ return null;
+ }
+
+ return \WC_Helper_Updater::get_plugins_without_subscription( $fetch );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php
index 243adf5ef96..97a792908aa 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php
@@ -82,6 +82,8 @@ class WC_Admin_Dashboard_Test extends WC_Unit_Test_Case {
delete_option( 'woocommerce_enable_reviews' );
delete_transient( 'wc_low_stock_count' );
delete_transient( 'wc_outofstock_count' );
+ // A static cache in WC_Helper, which no base class resets.
+ WC_Helper::flush_local_woo_products_cache();
parent::tearDown();
}
@@ -481,4 +483,85 @@ class WC_Admin_Dashboard_Test extends WC_Unit_Test_Case {
'Widget should not display for users without proper capabilities'
);
}
+
+ /**
+ * @testdox Status widget shows a row linking to My Subscriptions for extensions without a subscription.
+ */
+ public function test_status_widget_shows_extensions_without_subscription_row(): void {
+ $this->mock_woo_extension_with_subscriptions( array() );
+
+ $html = $this->capture_extensions_without_subscription_row();
+
+ $this->assertStringContainsString( 'class="extensions-without-subscription"', $html, 'The row should render when an extension has no subscription.' );
+ $this->assertStringContainsString( '<strong>1 extension</strong> without a WooCommerce.com subscription', $html, 'The row should show the extension count.' );
+ $this->assertStringContainsString( 'tab=my-subscriptions', $html, 'The row should link to My Subscriptions.' );
+ $this->assertStringContainsString( 'utm_campaign=pu_dashboard_widget_purchase', $html, 'The link should carry the dashboard widget campaign.' );
+ }
+
+ /**
+ * @testdox Status widget hides the extensions row when every extension has a subscription.
+ */
+ public function test_status_widget_hides_extensions_row_when_all_are_subscribed(): void {
+ $this->mock_woo_extension_with_subscriptions(
+ array(
+ array(
+ 'product_id' => 123,
+ 'connections' => array(),
+ ),
+ )
+ );
+
+ $this->assertSame( '', $this->capture_extensions_without_subscription_row(), 'No row should render when nothing is missing a subscription.' );
+ }
+
+ /**
+ * @testdox Status widget does not count inactive extensions without a subscription.
+ */
+ public function test_status_widget_does_not_count_inactive_extensions(): void {
+ $this->mock_woo_extension_with_subscriptions( array(), false );
+
+ $this->assertSame( '', $this->capture_extensions_without_subscription_row(), 'An inactive extension should not be counted.' );
+ }
+
+ /**
+ * Makes the store connected, with one installed Woo extension (product 123) and the given subscriptions.
+ *
+ * @param array $subscriptions Subscription records.
+ * @param bool $active Whether the extension is active.
+ * @return void
+ */
+ private function mock_woo_extension_with_subscriptions( array $subscriptions, bool $active = true ): void {
+ wp_cache_set(
+ 'plugins',
+ array(
+ '' => array(
+ 'woo-test-plugin/woo-test-plugin.php' => array(
+ 'Name' => 'Woo Test Plugin',
+ 'Version' => '1.0.0',
+ 'Woo' => '123:abc123',
+ ),
+ ),
+ ),
+ 'plugins'
+ );
+ WC_Helper::flush_local_woo_products_cache();
+ update_option( 'active_plugins', $active ? array( 'woo-test-plugin/woo-test-plugin.php' ) : array() );
+ WC_Helper_Options::update( 'auth', array( 'access_token' => 'token' ) );
+ set_transient( '_woocommerce_helper_subscriptions', $subscriptions, HOUR_IN_SECONDS );
+ }
+
+ /**
+ * Invoke the private status_widget_extensions_without_subscription_row method and return its output.
+ *
+ * @return string
+ */
+ private function capture_extensions_without_subscription_row(): string {
+ $method = new ReflectionMethod( WC_Admin_Dashboard::class, 'status_widget_extensions_without_subscription_row' );
+ $method->setAccessible( true );
+
+ ob_start();
+ $method->invoke( $this->sut );
+
+ return (string) ob_get_clean();
+ }
}
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 6268a91d128..fb941cd8027 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
@@ -169,6 +169,19 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
$this->assertIsArray( $transient_value, 'Transient should now be an array' );
}
+ /**
+ * @testdox has_cached_subscriptions is true only while a subscription list is cached.
+ */
+ public function test_has_cached_subscriptions_reflects_the_transient(): void {
+ $this->assertFalse( WC_Helper::has_cached_subscriptions(), 'No transient means no cached list.' );
+
+ set_transient( '_woocommerce_helper_subscriptions', 'corrupted_string_data', HOUR_IN_SECONDS );
+ $this->assertFalse( WC_Helper::has_cached_subscriptions(), 'A corrupted transient is not a usable list.' );
+
+ set_transient( '_woocommerce_helper_subscriptions', array(), HOUR_IN_SECONDS );
+ $this->assertTrue( WC_Helper::has_cached_subscriptions(), 'An empty list is still a cached list.' );
+ }
+
/**
* @testdox get_subscriptions should return valid cached array without modification.
*/
@@ -349,6 +362,51 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox get_subscriptions labels the request with the screen it comes from.
+ * @testWith ["/wp-admin/admin-ajax.php", "woocommerce_load_status_widget", "dashboard-widget"]
+ * ["/wp-admin/admin-ajax.php", "heartbeat", "heartbeat-api"]
+ * ["/wp-admin/admin.php?page=wc-admin", "", "inbox-notes"]
+ *
+ * @param string $request_uri The request URI.
+ * @param string $action The ajax action, if any.
+ * @param string $expected The source label the request should carry.
+ */
+ public function test_get_subscriptions_labels_the_request_source( string $request_uri, string $action, string $expected ): void {
+ $previous_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : null;
+ $_SERVER['REQUEST_URI'] = $request_uri;
+ if ( '' !== $action ) {
+ $_REQUEST['action'] = $action;
+ }
+ $requested_url = '';
+ $capture = static function ( $preempt, $args, $url ) use ( &$requested_url ) {
+ $requested_url = $url;
+ return $preempt;
+ };
+ add_filter( 'pre_http_request', $capture, 9, 3 );
+
+ try {
+ $this->fetch_subscriptions_with_response(
+ array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => '[]',
+ )
+ );
+ } finally {
+ remove_filter( 'pre_http_request', $capture, 9 );
+ unset( $_REQUEST['action'] );
+ if ( null === $previous_uri ) {
+ unset( $_SERVER['REQUEST_URI'] );
+ } else {
+ $_SERVER['REQUEST_URI'] = $previous_uri;
+ }
+ }
+
+ parse_str( (string) wp_parse_url( $requested_url, PHP_URL_QUERY ), $query );
+
+ $this->assertSame( $expected, $query['source'] ?? '', 'The source label is what WooCommerce.com attributes the request to.' );
+ }
+
/**
* Run get_subscriptions() against a mocked Helper API response.
*
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 92145b3e24c..4cc8a0e8e1e 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
@@ -2332,4 +2332,185 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
'body' => wp_json_encode( $this->mocked_updates ),
);
}
+
+ /**
+ * @testdox Plugins without a subscription lists an active Woo plugin the account holds no subscription for.
+ */
+ public function test_plugins_without_subscription_lists_unsubscribed_plugin(): void {
+ $plugin_file = $this->mock_active_woo_plugin();
+ $this->set_connected_subscriptions( array( $this->subscription( array( 'product_id' => 999 ) ) ) );
+
+ $plugins = WC_Helper_Updater::get_plugins_without_subscription();
+
+ $this->assertSame( array( $plugin_file ), array_keys( $plugins ), 'A plugin with no subscription for its product should be listed.' );
+ }
+
+ /**
+ * @testdox Plugins without a subscription skips a plugin the account holds any subscription for, even an expired one.
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $expired Whether the subscription has expired.
+ */
+ public function test_plugins_without_subscription_skips_subscribed_plugin( bool $expired ): void {
+ $this->mock_active_woo_plugin();
+ $this->set_connected_subscriptions( array( $this->subscription( array( 'expired' => $expired ) ) ) );
+
+ $this->assertSame( array(), WC_Helper_Updater::get_plugins_without_subscription(), 'A plugin with a subscription should not be listed.' );
+ }
+
+ /**
+ * @testdox Plugins without a subscription skips inactive plugins.
+ */
+ public function test_plugins_without_subscription_skips_inactive_plugin(): void {
+ $this->mock_local_woo_plugin();
+ update_option( 'active_plugins', array() );
+ $this->set_connected_subscriptions( array() );
+
+ $this->assertSame( array(), WC_Helper_Updater::get_plugins_without_subscription(), 'An inactive plugin should not be listed.' );
+ }
+
+ /**
+ * @testdox Plugins without a subscription skips the Woo Update Manager.
+ */
+ public function test_plugins_without_subscription_skips_update_manager(): void {
+ $plugin_file = WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_PLUGIN_MAIN_FILE;
+ wp_cache_set(
+ 'plugins',
+ array(
+ '' => array(
+ $plugin_file => array(
+ 'Name' => 'WooCommerce.com Update Manager',
+ 'Version' => '1.0.0',
+ 'Woo' => '18734002369816:abc123',
+ ),
+ ),
+ ),
+ 'plugins'
+ );
+ update_option( 'active_plugins', array( $plugin_file ) );
+ $this->set_connected_subscriptions( array() );
+
+ $this->assertSame( array(), WC_Helper_Updater::get_plugins_without_subscription(), 'The Update Manager delivers updates and should never be listed.' );
+ }
+
+ /**
+ * @testdox Plugins without a subscription lists every active Woo plugin when the store is not connected.
+ */
+ public function test_plugins_without_subscription_lists_all_when_disconnected(): void {
+ $plugin_file = $this->mock_active_woo_plugin();
+ $this->set_subscriptions( array( $this->subscription() ) );
+
+ $plugins = WC_Helper_Updater::get_plugins_without_subscription();
+
+ $this->assertSame( array( $plugin_file ), array_keys( $plugins ), 'A store that is not connected has no subscription for any extension.' );
+ }
+
+ /**
+ * @testdox Plugins without a subscription is null while a connected store's last subscriptions fetch is failing.
+ * @testWith [429]
+ * [500]
+ *
+ * @param int $code HTTP status of the failed fetch.
+ */
+ public function test_plugins_without_subscription_is_null_while_the_api_is_failing( int $code ): void {
+ $this->mock_active_woo_plugin();
+ $this->set_connected_subscriptions( array() );
+ set_transient(
+ '_woocommerce_helper_subscriptions_api_error',
+ array(
+ 'code' => $code,
+ 'message' => 'Error',
+ ),
+ HOUR_IN_SECONDS
+ );
+
+ $this->assertNull( WC_Helper_Updater::get_plugins_without_subscription(), 'An empty list after a failed fetch is not proof that no subscription exists.' );
+ }
+
+ /**
+ * @testdox Plugins without a subscription is empty when no Woo plugin is active, even while the API is failing.
+ */
+ public function test_plugins_without_subscription_is_empty_without_active_plugins_while_the_api_is_failing(): void {
+ $this->mock_local_woo_plugin();
+ update_option( 'active_plugins', array() );
+ $this->set_connected_subscriptions( array() );
+ set_transient(
+ '_woocommerce_helper_subscriptions_api_error',
+ array(
+ 'code' => 429,
+ 'message' => 'Error',
+ ),
+ HOUR_IN_SECONDS
+ );
+
+ $this->assertSame( array(), WC_Helper_Updater::get_plugins_without_subscription(), 'With no active plugin to check, the empty list does not depend on the API.' );
+ }
+
+ /**
+ * @testdox Plugins without a subscription is null when fetching is not allowed and the list isn't cached.
+ */
+ public function test_plugins_without_subscription_is_null_without_cache_when_fetching_is_not_allowed(): void {
+ $this->mock_active_woo_plugin();
+ WC_Helper_Options::update(
+ 'auth',
+ array(
+ 'site_id' => 45,
+ 'access_token' => 'token',
+ 'access_token_secret' => 'secret',
+ )
+ );
+ delete_transient( '_woocommerce_helper_subscriptions' );
+ $fail = function () {
+ $this->fail( 'No request should be made when fetching is not allowed.' );
+ };
+ add_filter( 'pre_http_request', $fail );
+
+ try {
+ $this->assertNull( WC_Helper_Updater::get_plugins_without_subscription( false ), 'Without the list the answer is unknown.' );
+ } finally {
+ remove_filter( 'pre_http_request', $fail );
+ }
+ }
+
+ /**
+ * @testdox Plugins without a subscription reads the cached list when fetching is not allowed.
+ */
+ public function test_plugins_without_subscription_reads_the_cache_when_fetching_is_not_allowed(): void {
+ $plugin_file = $this->mock_active_woo_plugin();
+ $this->set_connected_subscriptions( array() );
+
+ $plugins = WC_Helper_Updater::get_plugins_without_subscription( false );
+
+ $this->assertSame( array( $plugin_file ), array_keys( $plugins ), 'A cached list answers without a request.' );
+ }
+
+ /**
+ * Makes WC_Helper::get_local_woo_plugins() report a single active Woo plugin.
+ *
+ * @return string The plugin file name.
+ */
+ private function mock_active_woo_plugin(): string {
+ $plugin_file = $this->mock_local_woo_plugin();
+ update_option( 'active_plugins', array( $plugin_file ) );
+
+ return $plugin_file;
+ }
+
+ /**
+ * Stand in a subscriptions payload for a store connected to WooCommerce.com.
+ *
+ * @param array $subscriptions Subscription records.
+ * @return void
+ */
+ private function set_connected_subscriptions( array $subscriptions ): void {
+ $this->set_subscriptions( $subscriptions );
+ WC_Helper_Options::update(
+ 'auth',
+ array(
+ 'site_id' => 45,
+ 'access_token' => 'token',
+ )
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Notes/ExtensionsWithoutSubscriptionTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Notes/ExtensionsWithoutSubscriptionTest.php
new file mode 100644
index 00000000000..993b47a8783
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Notes/ExtensionsWithoutSubscriptionTest.php
@@ -0,0 +1,419 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Notes;
+
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Admin\PageController;
+use Automattic\WooCommerce\Internal\Admin\Notes\ExtensionsWithoutSubscription;
+use WC_Helper;
+use WC_Helper_Options;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ExtensionsWithoutSubscription class.
+ */
+class ExtensionsWithoutSubscriptionTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var ExtensionsWithoutSubscription
+ */
+ private $sut;
+
+ /**
+ * Filter standing in for WooCommerce.com, removed on tear down.
+ *
+ * @var callable|null
+ */
+ private $http_filter;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $_GET['page'] = 'wc-admin';
+ WC_Helper::flush_local_woo_products_cache();
+ $this->sut = new ExtensionsWithoutSubscription();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ try {
+ // A request global and a static cache in WC_Helper, which no base class resets.
+ unset( $_GET['page'], $_GET['wc-helper-status'] );
+ WC_Helper::flush_local_woo_products_cache();
+ $this->reset_current_page();
+ if ( $this->http_filter ) {
+ remove_filter( 'pre_http_request', $this->http_filter );
+ }
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should name the extension when a single extension has no subscription.
+ */
+ public function test_single_extension_note_names_the_extension(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+
+ $this->sut->refresh_note();
+
+ $note = $this->get_saved_note();
+ $this->assertSame( "Product Add-Ons doesn't have an active subscription", $note->get_title(), 'The title should name the extension.' );
+ $this->assertStringContainsString( 'keep it protected', $note->get_content(), 'The content should use the singular copy.' );
+ }
+
+ /**
+ * @testdox Should use the multiple extensions copy when more than one extension has no subscription.
+ */
+ public function test_multiple_extensions_note_uses_plural_copy(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons', 'Store Credit' ) );
+
+ $this->sut->refresh_note();
+
+ $note = $this->get_saved_note();
+ $this->assertSame( "Some of your extensions don't have an active subscription", $note->get_title(), 'The title should not name a single extension.' );
+ $this->assertStringContainsString( 'keep them protected', $note->get_content(), 'The content should use the plural copy.' );
+ }
+
+ /**
+ * @testdox Should link the Subscribe action to the in-app My Subscriptions page.
+ */
+ public function test_subscribe_action_links_to_my_subscriptions(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+
+ $this->sut->refresh_note();
+
+ $actions = $this->get_saved_note()->get_actions();
+ $this->assertCount( 1, $actions, 'The note should have one action.' );
+ $this->assertSame( 'subscribe', $actions[0]->name, 'The action should be Subscribe.' );
+ $this->assertStringStartsWith( admin_url( 'admin.php?page=wc-admin&tab=my-subscriptions' ), $actions[0]->query, 'The action should open My Subscriptions in wp-admin.' );
+ $this->assertStringContainsString( 'utm_source=inbox_notification&utm_campaign=pu_inbox_purchase', $actions[0]->query, 'The action should carry the inbox UTM params.' );
+ }
+
+ /**
+ * @testdox Should update an existing note when the set of extensions changes, keeping it dismissed.
+ */
+ public function test_existing_note_is_updated_and_stays_dismissed(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+ $note = $this->get_saved_note();
+ $note->set_is_deleted( true );
+ $note->save();
+
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons', 'Store Credit' ) );
+ $this->sut->refresh_note();
+
+ $note = $this->get_saved_note();
+ $this->assertSame( "Some of your extensions don't have an active subscription", $note->get_title(), 'The note should follow the new set of extensions.' );
+ $this->assertTrue( $note->get_is_deleted(), 'A dismissed note should stay dismissed.' );
+ }
+
+ /**
+ * @testdox Should delete the note once every extension has a subscription.
+ */
+ public function test_note_is_deleted_when_resolved(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+
+ $this->mock_active_woo_plugins( array() );
+ $this->sut->refresh_note();
+
+ $this->assertFalse( ExtensionsWithoutSubscription::note_exists(), 'The note should be removed once nothing is missing a subscription.' );
+ }
+
+ /**
+ * @testdox Should keep a dismissed note once resolved, so it stays dismissed if the extension comes back.
+ */
+ public function test_dismissed_note_stays_dismissed_after_resolving_and_returning(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+ $note = $this->get_saved_note();
+ $note->set_is_deleted( true );
+ $note->save();
+
+ $this->mock_active_woo_plugins( array() );
+ $this->sut->refresh_note();
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+
+ $this->assertTrue( $this->get_saved_note()->get_is_deleted(), 'Deactivating and reactivating the extension should not bring back a dismissed note.' );
+ }
+
+ /**
+ * @testdox Should keep an actioned note once resolved, so it stays actioned if the extension comes back.
+ */
+ public function test_actioned_note_stays_actioned_after_resolving_and_returning(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+ $note = $this->get_saved_note();
+ $note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
+ $note->save();
+
+ $this->mock_active_woo_plugins( array() );
+ $this->sut->refresh_note();
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_ACTIONED, $this->get_saved_note()->get_status(), 'Deactivating and reactivating the extension should not bring back an actioned note.' );
+ }
+
+ /**
+ * @testdox Should keep the note while a connected store's subscriptions fetch is failing.
+ */
+ public function test_note_is_kept_while_the_api_is_failing(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+
+ WC_Helper_Options::update( 'auth', array( 'access_token' => 'token' ) );
+ set_transient( '_woocommerce_helper_subscriptions', array(), HOUR_IN_SECONDS );
+ set_transient(
+ '_woocommerce_helper_subscriptions_api_error',
+ array(
+ 'code' => 429,
+ 'message' => 'Rate limited',
+ ),
+ HOUR_IN_SECONDS
+ );
+ $this->sut->refresh_note();
+
+ $this->assertTrue( ExtensionsWithoutSubscription::note_exists(), 'An unknown subscription state should not remove the note.' );
+ }
+
+ /**
+ * @testdox Should delete the note once no extension is active, even while the subscriptions fetch is failing.
+ */
+ public function test_note_is_deleted_when_no_extension_is_active_while_the_api_is_failing(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+
+ $this->mock_active_woo_plugins( array() );
+ WC_Helper_Options::update( 'auth', array( 'access_token' => 'token' ) );
+ set_transient( '_woocommerce_helper_subscriptions', array(), HOUR_IN_SECONDS );
+ set_transient(
+ '_woocommerce_helper_subscriptions_api_error',
+ array(
+ 'code' => 429,
+ 'message' => 'Rate limited',
+ ),
+ HOUR_IN_SECONDS
+ );
+ $this->sut->refresh_note();
+
+ $this->assertFalse( ExtensionsWithoutSubscription::note_exists(), 'With no active extension, the note is obsolete whatever the subscription state.' );
+ }
+
+ /**
+ * @testdox Should fetch the subscription list of a connected store at most once a day.
+ */
+ public function test_connected_store_fetches_subscriptions_once_a_day(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->connect_store();
+ $requests = $this->mock_subscription_requests();
+
+ $this->sut->refresh_note();
+
+ $this->assertSame( 1, $requests->count, 'The first refresh of the day may fetch the list.' );
+ $this->assertTrue( ExtensionsWithoutSubscription::note_exists(), 'A fetched empty list means the extension has no subscription.' );
+
+ WC_Helper::_flush_subscriptions_cache();
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons', 'Bookings' ) );
+ $this->sut->refresh_note();
+
+ $this->assertSame( 1, $requests->count, 'A second cold-cache refresh the same day must not block the page on a fetch.' );
+ $this->assertSame( array( 'woo-test-plugin-0/woo-test-plugin-0.php' ), $this->get_saved_note()->get_content_data()->plugins, 'The note is left alone until the list can be read cheaply.' );
+ }
+
+ /**
+ * @testdox Should follow the cached subscription list without fetching once the daily fetch is spent.
+ */
+ public function test_note_follows_the_cached_subscription_list_without_fetching(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->connect_store();
+ update_option( ExtensionsWithoutSubscription::LAST_FETCH_OPTION_KEY, time() );
+ set_transient( '_woocommerce_helper_subscriptions', array(), HOUR_IN_SECONDS );
+ $requests = $this->mock_subscription_requests();
+
+ $this->sut->refresh_note();
+
+ $this->assertSame( 0, $requests->count, 'A cached list needs no fetch.' );
+ $this->assertTrue( ExtensionsWithoutSubscription::note_exists(), 'The cached list shows the extension has no subscription.' );
+ }
+
+ /**
+ * @testdox Should remove the note once no extension is active, without a fetch.
+ */
+ public function test_note_is_deleted_when_no_extension_is_active_without_fetching(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+
+ $this->mock_active_woo_plugins( array() );
+ $this->connect_store();
+ update_option( ExtensionsWithoutSubscription::LAST_FETCH_OPTION_KEY, time() );
+ $requests = $this->mock_subscription_requests();
+ $this->sut->refresh_note();
+
+ $this->assertSame( 0, $requests->count, 'With nothing to check there is nothing to fetch.' );
+ $this->assertFalse( ExtensionsWithoutSubscription::note_exists(), 'An empty result is definitive whatever the cache state.' );
+ }
+
+ /**
+ * @testdox Should fetch right after the store connects, even when the daily fetch is spent.
+ */
+ public function test_note_fetches_right_after_connecting(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->connect_store();
+ update_option( ExtensionsWithoutSubscription::LAST_FETCH_OPTION_KEY, time() );
+ $_GET['wc-helper-status'] = 'helper-connected';
+ $requests = $this->mock_subscription_requests();
+
+ $this->sut->refresh_note();
+
+ $this->assertSame( 1, $requests->count, 'Connecting changes the answer, so the list is fetched.' );
+ $this->assertTrue( ExtensionsWithoutSubscription::note_exists(), 'The fresh list shows the extension has no subscription.' );
+ }
+
+ /**
+ * @testdox Should run no database queries while the extensions without a subscription haven't changed.
+ */
+ public function test_unchanged_refresh_runs_no_queries(): void {
+ global $wpdb;
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ $this->sut->refresh_note();
+
+ $queries_before = $wpdb->num_queries;
+ $this->sut->refresh_note();
+
+ $this->assertSame( 0, $wpdb->num_queries - $queries_before, 'A refresh that changes nothing should not hit the database.' );
+ $this->assertTrue( ExtensionsWithoutSubscription::note_exists(), 'The note should still be there.' );
+ }
+
+ /**
+ * @testdox Should run no database queries on a connected store without WooCommerce.com extensions.
+ */
+ public function test_connected_store_without_extensions_runs_no_queries(): void {
+ global $wpdb;
+ $this->mock_active_woo_plugins( array() );
+ $this->connect_store();
+ $requests = $this->mock_subscription_requests();
+ $this->sut->refresh_note();
+
+ $queries_before = $wpdb->num_queries;
+ $this->sut->refresh_note();
+
+ $this->assertSame( 0, $wpdb->num_queries - $queries_before, 'With nothing to check there is nothing to read.' );
+ $this->assertSame( 0, $requests->count, 'With nothing to check there is nothing to fetch.' );
+ }
+
+ /**
+ * @testdox Should do nothing outside WooCommerce admin pages.
+ */
+ public function test_note_is_not_added_outside_woocommerce_pages(): void {
+ $this->mock_active_woo_plugins( array( 'Product Add-Ons' ) );
+ unset( $_GET['page'] );
+ set_current_screen( 'dashboard' );
+ $this->reset_current_page();
+
+ $this->sut->refresh_note();
+
+ $this->assertFalse( ExtensionsWithoutSubscription::note_exists(), 'The note should only be refreshed on WooCommerce admin pages.' );
+ }
+
+ /**
+ * Makes the given Woo extensions installed and active on a store that isn't connected to WooCommerce.com.
+ *
+ * @param string[] $names Extension names.
+ * @return void
+ */
+ private function mock_active_woo_plugins( array $names ): void {
+ $plugins = array();
+ foreach ( $names as $index => $name ) {
+ $plugin_file = 'woo-test-plugin-' . $index . '/woo-test-plugin-' . $index . '.php';
+ $plugins[ $plugin_file ] = array(
+ 'Name' => $name,
+ 'Version' => '1.0.0',
+ 'Woo' => ( 100 + $index ) . ':abc' . $index,
+ );
+ }
+
+ wp_cache_set( 'plugins', array( '' => $plugins ), 'plugins' );
+ update_option( 'active_plugins', array_keys( $plugins ) );
+ WC_Helper::flush_local_woo_products_cache();
+ }
+
+ /**
+ * Connects the store to WooCommerce.com with credentials WC_Helper_API can sign requests with.
+ *
+ * @return void
+ */
+ private function connect_store(): void {
+ WC_Helper_Options::update(
+ 'auth',
+ array(
+ 'site_id' => 45,
+ 'access_token' => 'token',
+ 'access_token_secret' => 'secret',
+ )
+ );
+ }
+
+ /**
+ * Answers WooCommerce.com subscription requests with an empty list and counts them.
+ *
+ * @return object Holds the request count.
+ */
+ private function mock_subscription_requests(): object {
+ $requests = (object) array( 'count' => 0 );
+
+ $this->http_filter = static function ( $preempt, $args, $url ) use ( $requests ) {
+ if ( false === strpos( $url, '/subscriptions' ) ) {
+ return $preempt;
+ }
+ ++$requests->count;
+
+ return array(
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ 'body' => '[]',
+ );
+ };
+ add_filter( 'pre_http_request', $this->http_filter, 10, 3 );
+
+ return $requests;
+ }
+
+ /**
+ * Makes the page controller resolve the current page again from the current screen.
+ *
+ * The PageController singleton caches the page it first resolved for the whole process, so
+ * whichever test resolved it first would otherwise decide the answer for every later test.
+ *
+ * @return void
+ */
+ private function reset_current_page(): void {
+ $current_page = new \ReflectionProperty( PageController::class, 'current_page' );
+ $current_page->setAccessible( true );
+ $current_page->setValue( PageController::get_instance(), null );
+ }
+
+ /**
+ * Loads the saved note.
+ *
+ * @return Note
+ */
+ private function get_saved_note(): Note {
+ $note = Notes::get_note_by_name( ExtensionsWithoutSubscription::NOTE_NAME );
+ $this->assertInstanceOf( Note::class, $note, 'The note should have been saved.' );
+
+ return $note;
+ }
+}