From 9f75003044548260950106f41a0cf4ab8302006e Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 13 Aug 2026 14:57:01 +0800 Subject: [PATCH 1/3] Premium Analytics: gate the Subscribers section on the subscriptions module Replicates the Calypso Stats tab check for Jetpack sites: the section is registered as always, but only offered when the module is active. --- .../wooa7s-1726-subscribers-section-gate | 4 + .../src/dashboard-sections.php | 19 +++ .../tests/php/Dashboard_Section_Test.php | 126 +++++++++++++++++- 3 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate b/projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate new file mode 100644 index 000000000000..ad11f04a3b46 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Dashboard: Show the Subscribers tab only when the Jetpack subscriptions module is active. diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index 0d183c7135f5..af56e053e8a7 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -7,6 +7,8 @@ namespace Automattic\Jetpack\PremiumAnalytics; +use Automattic\Jetpack\Modules; + require_once __DIR__ . '/dashboard-layout.php'; require_once __DIR__ . '/dashboard-grammar.php'; require_once __DIR__ . '/class-dashboard-section.php'; @@ -80,6 +82,22 @@ function is_woocommerce_dashboard_section_available_to_current_user() { return is_woocommerce_dashboard_section_available() && Capabilities::current_user_can_view_store_reports(); } +/** + * Whether the Subscribers dashboard section should be exposed. + * + * Mirrors the Calypso Stats gate on the same tab. Modules::is_active() reads the + * `jetpack_active_modules` option that Sync ships to WPCOM as `active_modules`, + * the value Calypso gates on, and returns true on WPCOM Simple, where modules + * are not a concept and the tab always shows. + * + * @since $$next-version$$ + * + * @return bool True when the subscriptions module is active. + */ +function is_subscribers_dashboard_section_available() { + return ( new Modules() )->is_active( 'subscriptions' ); +} + /** * Returns the default widget layout for the WooCommerce dashboard section. * @@ -127,6 +145,7 @@ function register_default_dashboard_sections() { 'title' => __( 'Subscribers stats', 'jetpack-premium-analytics-pkg' ), 'description' => __( 'How your subscriber list is growing, and how your emails land.', 'jetpack-premium-analytics-pkg' ), 'order' => 30, + 'is_available' => __NAMESPACE__ . '\\is_subscribers_dashboard_section_available', 'default_layout' => static function () { return get_dashboard_default_layout_for( 'analytics/subscribers' ); }, diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php index 3bc7e9898733..1fc0efd6f309 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\PremiumAnalytics; +use Jetpack_Options; use WorDBless\BaseTestCase; use WP_REST_Request; use WP_REST_Server; @@ -63,6 +64,8 @@ public function tear_down() { remove_all_filters( 'doing_it_wrong_trigger_error' ); remove_all_actions( 'doing_it_wrong_run' ); remove_all_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER ); + remove_all_filters( 'jetpack_get_available_standalone_modules' ); + Jetpack_Options::delete_option( 'active_modules' ); // Drops the package's mapping along with any per-user view_stats grant a // test added; set_up() hooks the mapping again. @@ -73,6 +76,27 @@ public function tear_down() { parent::tear_down(); } + /** + * Turn the `subscriptions` Jetpack module on, the way a standalone plugin sees it. + * + * Both levers are needed because Modules::get_active() intersects the option + * with the modules available to the site, and without the Jetpack plugin that + * list is only what standalone plugins register. + * + * @return void + */ + private function activate_subscriptions_module() { + Jetpack_Options::update_option( 'active_modules', array( 'subscriptions' ) ); + add_filter( + 'jetpack_get_available_standalone_modules', + static function ( $modules ) { + $modules[] = 'subscriptions'; + + return $modules; + } + ); + } + /** * Capture _doing_it_wrong() calls without tripping the suite's failOnWarning gate. * @@ -224,10 +248,12 @@ public function test_section_defaults_to_the_range_date_filter() { * The built-in Insights section offers the year date filter; the rest keep the range. */ public function test_built_in_sections_declare_their_date_filters() { - // Store needs both gates: the filter stands in for WooCommerce being active, - // and the admin user satisfies the capability check added in #50889. + // Every conditional section needs its gate satisfied to appear here: Store + // takes the filter standing in for WooCommerce plus the admin user for the + // capability check added in #50889, and Subscribers takes its module. $this->set_admin_user(); add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); + $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -305,10 +331,12 @@ public function test_section_defaults_to_offering_every_date_filter_option() { * Insights drops the comparison control; the rest keep it. */ public function test_built_in_sections_declare_their_date_filter_options() { - // Store needs both gates: the filter stands in for WooCommerce being active, - // and the admin user satisfies the capability check added in #50889. + // Every conditional section needs its gate satisfied to appear here: Store + // takes the filter standing in for WooCommerce plus the admin user for the + // capability check added in #50889, and Subscribers takes its module. $this->set_admin_user(); add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); + $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -336,10 +364,12 @@ static function ( Dashboard_Section $section ) { * The analytics sections carry their own heading; Store still falls back to its label. */ public function test_built_in_sections_declare_their_headings() { - // Store needs both gates: the filter stands in for WooCommerce being active, - // and the admin user satisfies the capability check added in #50889. + // Every conditional section needs its gate satisfied to appear here: Store + // takes the filter standing in for WooCommerce plus the admin user for the + // capability check added in #50889, and Subscribers takes its module. $this->set_admin_user(); add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); + $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -663,6 +693,7 @@ public function test_global_register_dashboard_section_registers_with_singleton( */ public function test_registers_built_in_dashboard_sections() { add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); + $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -719,6 +750,7 @@ static function ( Dashboard_Section $section ) { public function test_registers_woocommerce_dashboard_section_when_available() { add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); $this->set_admin_user(); + $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -748,11 +780,91 @@ static function ( Dashboard_Section $section ) { ); } + /** + * The Subscribers section is available once the subscriptions module is on. + */ + public function test_registers_subscribers_dashboard_section_when_module_is_active() { + $this->activate_subscriptions_module(); + + register_default_dashboard_sections(); + + $subscribers = get_registered_dashboard_section( DASHBOARD_NAME, 'analytics/subscribers' ); + + $this->assertInstanceOf( Dashboard_Section::class, $subscribers ); + $this->assertTrue( $subscribers->is_available() ); + $this->assertContains( + 'analytics/subscribers', + array_map( + static function ( Dashboard_Section $section ) { + return $section->id; + }, + get_available_dashboard_sections( DASHBOARD_NAME ) + ) + ); + } + + /** + * A site with the subscriptions module off has no subscriber data, so the + * section is registered but never offered — matching the Calypso Stats tab. + */ + public function test_omits_subscribers_dashboard_section_when_module_is_inactive() { + register_default_dashboard_sections(); + + $subscribers = get_registered_dashboard_section( DASHBOARD_NAME, 'analytics/subscribers' ); + + $this->assertInstanceOf( Dashboard_Section::class, $subscribers ); + $this->assertFalse( $subscribers->is_available() ); + $this->assertSame( + array( + 'analytics/traffic', + 'analytics/insights', + ), + array_map( + static function ( Dashboard_Section $section ) { + return $section->id; + }, + get_available_dashboard_sections( DASHBOARD_NAME ) + ) + ); + } + + /** + * The sections route drops the Subscribers tab with the module off. + */ + public function test_sections_route_reflects_subscribers_module_state() { + $this->set_admin_user(); + + register_default_dashboard_sections(); + + $response = rest_get_server()->dispatch( + new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . DASHBOARD_NAME . '/sections' ) + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + array( 'traffic', 'insights' ), + array_column( $response->get_data(), 'slug' ) + ); + + $this->activate_subscriptions_module(); + + $response = rest_get_server()->dispatch( + new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . DASHBOARD_NAME . '/sections' ) + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + array( 'traffic', 'insights', 'subscribers' ), + array_column( $response->get_data(), 'slug' ) + ); + } + /** * The WooCommerce section is omitted from available sections when WooCommerce is unavailable. */ public function test_omits_woocommerce_dashboard_section_when_unavailable() { add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); + $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -915,6 +1027,8 @@ public function test_sections_route_returns_available_sections_sorted_by_order() * Sections route includes the store section only when WooCommerce is detected. */ public function test_sections_route_reflects_woocommerce_availability() { + $this->activate_subscriptions_module(); + register_default_dashboard_sections(); $this->set_admin_user(); From 6f2cfdcf04177a4c8790f34d39d4c6f2dbe970b1 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 13 Aug 2026 15:57:00 +0800 Subject: [PATCH 2/3] Premium Analytics: fail open the Subscribers gate and apply it to the default-layout route Modules::is_active() intersects `jetpack_active_modules` with the modules available to the site, and without the Jetpack plugin that list is only what standalone plugins register -- nothing registers `subscriptions`. The gate could therefore never open on the standalone plugin, hiding a tab whose data comes from the WPCOM proxy and works with the module off. Sites with no module system to consult are now exempt rather than refused. Also: - Resolve availability through a filter, matching the sibling WooCommerce gate. - Gate /dashboards/{name}/default-layout on a tab map rather than a hardcoded Store check, so it stops serving a layout the sections route no longer lists. - Record why the subscriber widget types are deliberately left registered. --- .../wooa7s-1726-subscribers-section-gate | 2 +- .../src/dashboard-layout.php | 38 ++- .../src/dashboard-sections.php | 34 ++- .../src/widget-availability.php | 9 + .../tests/php/Dashboard_Layout_Test.php | 45 ++++ .../tests/php/Dashboard_Section_Test.php | 236 ++++++++++++------ .../tests/php/mocks/jetpack-plugin-mock.php | 25 ++ 7 files changed, 307 insertions(+), 82 deletions(-) create mode 100644 projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate b/projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate index ad11f04a3b46..b595fa1440e0 100644 --- a/projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate +++ b/projects/packages/premium-analytics/changelog/wooa7s-1726-subscribers-section-gate @@ -1,4 +1,4 @@ Significance: minor Type: changed -Dashboard: Show the Subscribers tab only when the Jetpack subscriptions module is active. +Dashboard: Hide the Subscribers tab on sites where the Jetpack subscriptions module is turned off. diff --git a/projects/packages/premium-analytics/src/dashboard-layout.php b/projects/packages/premium-analytics/src/dashboard-layout.php index 5ce18c22d823..f985cc35175e 100644 --- a/projects/packages/premium-analytics/src/dashboard-layout.php +++ b/projects/packages/premium-analytics/src/dashboard-layout.php @@ -72,14 +72,39 @@ function get_dashboard_default_layout_for( $dashboard_name ) { ); } +/** + * Availability gates for the tabs this route's name can resolve to. + * + * Every conditional tab needs an entry: a tab the section registry hides but + * this map omits still hands out its bundled layout. Tabs absent from the map + * are served to every dashboard reader. + * + * The gates are called directly rather than read off + * {@see Dashboard_Section::is_available()} because this route registers from + * dashboard-layout.php and has to answer without assuming the section registry + * was hydrated. Both dashboard entry points load dashboard-sections.php before + * registering this route, so the callbacks are always defined by the time one + * runs. + * + * @since $$next-version$$ + * + * @return callable[] Resolved tab ID mapped to its availability callback. + */ +function get_dashboard_default_layout_gates() { + return array( + DASHBOARD_STORE_SECTION_ID => array( Capabilities::class, 'current_user_can_view_store_reports' ), + DASHBOARD_SUBSCRIBERS_SECTION_ID => __NAMESPACE__ . '\\is_subscribers_dashboard_section_available', + ); +} + /** * REST callback returning the default layout for the requested dashboard. * * The route admits every dashboard reader, but its name also resolves to a - * single tab, so the store tab is refused here the way the section route - * refuses it through {@see Dashboard_Section::is_available()}. The check keys - * on the resolved tab rather than the string because two spellings arrive: the - * bare `store` alias in the URL, and `?name=woocommerce/store`, which WordPress + * single tab, so a gated tab is refused here the way the section route refuses + * it through {@see Dashboard_Section::is_available()}. The check keys on the + * resolved tab rather than the string because two spellings arrive: the bare + * `store` alias in the URL, and `?name=woocommerce/store`, which WordPress * reads ahead of the URL capture. * * @param \WP_REST_Request $request REST request carrying the dashboard name. @@ -87,9 +112,10 @@ function get_dashboard_default_layout_for( $dashboard_name ) { */ function get_dashboard_default_layout_response( $request ) { $dashboard_name = $request['name']; + $gates = get_dashboard_default_layout_gates(); + $section_id = get_dashboard_default_section_id_for( $dashboard_name ); - if ( DASHBOARD_STORE_SECTION_ID === get_dashboard_default_section_id_for( $dashboard_name ) - && ! Capabilities::current_user_can_view_store_reports() ) { + if ( isset( $gates[ $section_id ] ) && ! call_user_func( $gates[ $section_id ] ) ) { return new \WP_Error( 'dashboard_section_unavailable', __( 'Dashboard section is not available.', 'jetpack-premium-analytics-pkg' ), diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index af56e053e8a7..0ec59b0174b7 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -19,6 +19,11 @@ */ const WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER = 'jetpack_premium_analytics_woocommerce_dashboard_section_available'; +/** + * Filter through which Subscribers section availability is resolved. + */ +const SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER = 'jetpack_premium_analytics_subscribers_dashboard_section_available'; + /** * Registers a dashboard section. * @@ -85,17 +90,36 @@ function is_woocommerce_dashboard_section_available_to_current_user() { /** * Whether the Subscribers dashboard section should be exposed. * - * Mirrors the Calypso Stats gate on the same tab. Modules::is_active() reads the - * `jetpack_active_modules` option that Sync ships to WPCOM as `active_modules`, - * the value Calypso gates on, and returns true on WPCOM Simple, where modules - * are not a concept and the tab always shows. + * Mirrors the Calypso Stats gate on the same tab. Modules::is_active() resolves + * `jetpack_active_modules` the same way Sync does before shipping it to WPCOM as + * `active_modules` — the value Calypso gates on — and short-circuits to true on + * WPCOM Simple, where modules are not a concept and the tab always shows. + * + * Note that "resolves" is not "reads": at its default $available_only, the + * option is intersected with Modules::get_available(). That is what the guard + * below is about. * * @since $$next-version$$ * * @return bool True when the subscriptions module is active. */ function is_subscribers_dashboard_section_available() { - return ( new Modules() )->is_active( 'subscriptions' ); + // Without the Jetpack plugin, Modules::get_available() resolves to the + // standalone-module filter, and nothing registers `subscriptions` into it — + // so the intersection is empty and the gate could never open. The tab reads + // `stats/subscribers`, `subscribers/counts` and `stats/followers` off the + // WPCOM proxy, which answers whatever the local module state is, so a site + // with no module system to consult is exempt rather than refused. + $is_available = ! class_exists( 'Jetpack' ) || ( new Modules() )->is_active( 'subscriptions' ); + + /** + * Filters whether the Subscribers dashboard section is available. + * + * @since $$next-version$$ + * + * @param bool $is_available Whether the subscriptions module was detected in the current request. + */ + return (bool) apply_filters( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER, $is_available ); } /** diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index cb2cbfcb8ec7..1002954bf931 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -166,6 +166,15 @@ function filter_registrable_widget_types_by_plugin( $widget_candidates ) { add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_plugin' ); +// The Subscribers section is gated too (dashboard-sections.php) but deliberately +// has no counterpart here, so section and widgets do disagree for that one tab. +// The commerce gate above pairs with its section because Woo widgets 403 without +// the plugin — dropping them spares the reader a broken card. Subscriber widgets +// keep working when the subscriptions module is off, since their data comes from +// the WPCOM proxy rather than the module. Unregistering them would turn any the +// reader had already placed on another tab into "Widget is no longer available", +// which costs more than leaving an entry in the picker. + /** * Removes candidates the reader could not load data for anyway. * diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php index fd9347255c02..4d40717360d4 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php @@ -13,6 +13,9 @@ use WP_REST_Server; require_once __DIR__ . '/../../src/dashboard-layout.php'; +// The default-layout route's gates live in dashboard-sections.php, the way both +// dashboard entry points load it before registering the route. +require_once __DIR__ . '/../../src/dashboard-sections.php'; require_once __DIR__ . '/traits/trait-analytics-capabilities.php'; /** @@ -33,6 +36,7 @@ public function tear_down() { $wp_rest_server = null; $this->reset_analytics_capabilities(); wp_set_current_user( 0 ); + remove_all_filters( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER ); Constants::clear_constants(); parent::tear_down(); } @@ -160,6 +164,47 @@ public function test_default_layout_route_serves_the_traffic_tab_to_a_view_stats $this->assertNotContains( 'jpa/store-performance', $types ); } + /** + * A tab the section registry hides must not hand out its bundled layout here + * either — this route resolves a name straight to a tab, so it is a second + * way in. + */ + public function test_default_layout_route_refuses_an_unavailable_subscribers_tab() { + $this->register_route_with_capabilities(); + $this->grant_view_stats_to( $this->login_as( 'editor' ) ); + add_filter( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); + + list( $status ) = $this->request_default_layout( DASHBOARD_SUBSCRIBERS_SECTION_ID ); + + $this->assertSame( 404, $status ); + } + + /** + * `?name=` shadows the URL capture for subscribers the way it does for store. + */ + public function test_default_layout_route_refuses_a_name_shadowed_subscribers_tab() { + $this->register_route_with_capabilities(); + $this->grant_view_stats_to( $this->login_as( 'editor' ) ); + add_filter( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); + + list( $status ) = $this->request_default_layout( DASHBOARD_NAME, 'analytics/subscribers' ); + + $this->assertSame( 404, $status ); + } + + /** + * The refusal is the gate's: an available section is served as before. + */ + public function test_default_layout_route_serves_an_available_subscribers_tab() { + $this->register_route_with_capabilities(); + $this->grant_view_stats_to( $this->login_as( 'editor' ) ); + + list( $status, $types ) = $this->request_default_layout( DASHBOARD_SUBSCRIBERS_SECTION_ID ); + + $this->assertSame( 200, $status ); + $this->assertContains( 'jpa/subscribers-chart', $types ); + } + /** * Non-Premium-Analytics dashboards are left untouched. */ diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php index 1fc0efd6f309..3f9faa1e57df 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php @@ -8,6 +8,8 @@ namespace Automattic\Jetpack\PremiumAnalytics; use Jetpack_Options; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use WorDBless\BaseTestCase; use WP_REST_Request; use WP_REST_Server; @@ -36,6 +38,14 @@ class Dashboard_Section_Test extends BaseTestCase { */ private $doing_it_wrong = array(); + /** + * The standalone-module filter a test added, held so tear_down can remove + * that one callback rather than everything on a Jetpack-wide hook. + * + * @var callable|null + */ + private $available_modules_filter = null; + /** * Set up a fresh REST server for each test. */ @@ -64,7 +74,15 @@ public function tear_down() { remove_all_filters( 'doing_it_wrong_trigger_error' ); remove_all_actions( 'doing_it_wrong_run' ); remove_all_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER ); - remove_all_filters( 'jetpack_get_available_standalone_modules' ); + remove_all_filters( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER ); + + // Targeted, not remove_all_filters(): `jetpack_get_available_standalone_modules` + // is a Jetpack-wide hook other packages register into. + if ( null !== $this->available_modules_filter ) { + remove_filter( 'jetpack_get_available_standalone_modules', $this->available_modules_filter ); + $this->available_modules_filter = null; + } + Jetpack_Options::delete_option( 'active_modules' ); // Drops the package's mapping along with any per-user view_stats grant a @@ -77,24 +95,36 @@ public function tear_down() { } /** - * Turn the `subscriptions` Jetpack module on, the way a standalone plugin sees it. + * Turn the `subscriptions` Jetpack module on. * * Both levers are needed because Modules::get_active() intersects the option - * with the modules available to the site, and without the Jetpack plugin that - * list is only what standalone plugins register. + * with the modules available to the site, and without a real Jetpack plugin + * that list is only what standalone plugins register. * * @return void */ private function activate_subscriptions_module() { Jetpack_Options::update_option( 'active_modules', array( 'subscriptions' ) ); - add_filter( - 'jetpack_get_available_standalone_modules', - static function ( $modules ) { - $modules[] = 'subscriptions'; - return $modules; - } - ); + $this->available_modules_filter = static function ( $modules ) { + $modules[] = 'subscriptions'; + + return $modules; + }; + + add_filter( 'jetpack_get_available_standalone_modules', $this->available_modules_filter ); + } + + /** + * Make the Subscribers gate see a Jetpack plugin. + * + * Callers need their own process: a class cannot be undeclared, so loading + * the mock for the whole suite would flip the gate for every other test. + * + * @return void + */ + private function fake_jetpack_plugin() { + require_once __DIR__ . '/mocks/jetpack-plugin-mock.php'; } /** @@ -248,12 +278,10 @@ public function test_section_defaults_to_the_range_date_filter() { * The built-in Insights section offers the year date filter; the rest keep the range. */ public function test_built_in_sections_declare_their_date_filters() { - // Every conditional section needs its gate satisfied to appear here: Store - // takes the filter standing in for WooCommerce plus the admin user for the - // capability check added in #50889, and Subscribers takes its module. + // Store needs both gates: the filter stands in for WooCommerce being active, + // and the admin user satisfies the capability check added in #50889. $this->set_admin_user(); add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); - $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -331,12 +359,10 @@ public function test_section_defaults_to_offering_every_date_filter_option() { * Insights drops the comparison control; the rest keep it. */ public function test_built_in_sections_declare_their_date_filter_options() { - // Every conditional section needs its gate satisfied to appear here: Store - // takes the filter standing in for WooCommerce plus the admin user for the - // capability check added in #50889, and Subscribers takes its module. + // Store needs both gates: the filter stands in for WooCommerce being active, + // and the admin user satisfies the capability check added in #50889. $this->set_admin_user(); add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); - $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -364,12 +390,10 @@ static function ( Dashboard_Section $section ) { * The analytics sections carry their own heading; Store still falls back to its label. */ public function test_built_in_sections_declare_their_headings() { - // Every conditional section needs its gate satisfied to appear here: Store - // takes the filter standing in for WooCommerce plus the admin user for the - // capability check added in #50889, and Subscribers takes its module. + // Store needs both gates: the filter stands in for WooCommerce being active, + // and the admin user satisfies the capability check added in #50889. $this->set_admin_user(); add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); - $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -693,7 +717,6 @@ public function test_global_register_dashboard_section_registers_with_singleton( */ public function test_registers_built_in_dashboard_sections() { add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); - $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -750,7 +773,6 @@ static function ( Dashboard_Section $section ) { public function test_registers_woocommerce_dashboard_section_when_available() { add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); $this->set_admin_user(); - $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -781,9 +803,87 @@ static function ( Dashboard_Section $section ) { } /** - * The Subscribers section is available once the subscriptions module is on. + * Section IDs the registry currently offers. + * + * @return string[] */ + private function available_section_ids() { + return array_map( + static function ( Dashboard_Section $section ) { + return $section->id; + }, + get_available_dashboard_sections( DASHBOARD_NAME ) + ); + } + + /** + * Section slugs the sections route currently serves. + * + * @return string[] + */ + private function request_section_slugs() { + $response = rest_get_server()->dispatch( + new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . DASHBOARD_NAME . '/sections' ) + ); + + $this->assertSame( 200, $response->get_status() ); + + return array_column( $response->get_data(), 'slug' ); + } + + /** + * A site with no local module system keeps the tab. + * + * This is the standalone plugin's shape, and the default here because + * WorDBless runs without the Jetpack plugin: the gate has no module list to + * consult, and the tab's data comes from the WPCOM proxy either way. + */ + public function test_registers_subscribers_dashboard_section_without_a_module_system() { + register_default_dashboard_sections(); + + $subscribers = get_registered_dashboard_section( DASHBOARD_NAME, 'analytics/subscribers' ); + + $this->assertInstanceOf( Dashboard_Section::class, $subscribers ); + $this->assertTrue( $subscribers->is_available() ); + $this->assertContains( 'analytics/subscribers', $this->available_section_ids() ); + } + + /** + * With a Jetpack plugin present, the module state decides: off hides the tab. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function test_omits_subscribers_dashboard_section_when_module_is_inactive() { + $this->fake_jetpack_plugin(); + + register_default_dashboard_sections(); + + $subscribers = get_registered_dashboard_section( DASHBOARD_NAME, 'analytics/subscribers' ); + + $this->assertInstanceOf( Dashboard_Section::class, $subscribers ); + $this->assertFalse( $subscribers->is_available() ); + + $ids = $this->available_section_ids(); + + $this->assertNotContains( 'analytics/subscribers', $ids ); + // Registry is populated, so the absence above is the gate rather than an + // empty registry. + $this->assertContains( 'analytics/traffic', $ids ); + } + + /** + * The same site with the module on offers the tab again. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] public function test_registers_subscribers_dashboard_section_when_module_is_active() { + $this->fake_jetpack_plugin(); $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -792,70 +892,69 @@ public function test_registers_subscribers_dashboard_section_when_module_is_acti $this->assertInstanceOf( Dashboard_Section::class, $subscribers ); $this->assertTrue( $subscribers->is_available() ); - $this->assertContains( - 'analytics/subscribers', - array_map( - static function ( Dashboard_Section $section ) { - return $section->id; - }, - get_available_dashboard_sections( DASHBOARD_NAME ) - ) - ); + $this->assertContains( 'analytics/subscribers', $this->available_section_ids() ); } /** - * A site with the subscriptions module off has no subscriber data, so the - * section is registered but never offered — matching the Calypso Stats tab. + * WPCOM Simple always offers the tab: modules are not a concept there. + * + * Needs a real constant, hence the separate process — Modules::is_active() + * reads a raw `defined( 'IS_WPCOM' )` rather than the Constants wrapper the + * rest of this package mocks through, so Constants::set_constant() would set + * a value nothing reads and the test would assert the wrong branch. + * + * @runInSeparateProcess + * @preserveGlobalState disabled */ - public function test_omits_subscribers_dashboard_section_when_module_is_inactive() { + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function test_wpcom_simple_offers_subscribers_dashboard_section_without_the_module() { + $this->fake_jetpack_plugin(); + if ( ! defined( 'IS_WPCOM' ) ) { + define( 'IS_WPCOM', true ); + } + + register_default_dashboard_sections(); + + $subscribers = get_registered_dashboard_section( DASHBOARD_NAME, 'analytics/subscribers' ); + + $this->assertInstanceOf( Dashboard_Section::class, $subscribers ); + $this->assertTrue( $subscribers->is_available() ); + } + + /** + * Consumers can refuse the section through its availability filter. + */ + public function test_subscribers_availability_filter_overrides_the_module_state() { + add_filter( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); + register_default_dashboard_sections(); $subscribers = get_registered_dashboard_section( DASHBOARD_NAME, 'analytics/subscribers' ); $this->assertInstanceOf( Dashboard_Section::class, $subscribers ); $this->assertFalse( $subscribers->is_available() ); - $this->assertSame( - array( - 'analytics/traffic', - 'analytics/insights', - ), - array_map( - static function ( Dashboard_Section $section ) { - return $section->id; - }, - get_available_dashboard_sections( DASHBOARD_NAME ) - ) - ); + $this->assertNotContains( 'analytics/subscribers', $this->available_section_ids() ); } /** - * The sections route drops the Subscribers tab with the module off. + * The sections route drops the Subscribers tab once it is unavailable. */ - public function test_sections_route_reflects_subscribers_module_state() { + public function test_sections_route_reflects_subscribers_availability() { $this->set_admin_user(); register_default_dashboard_sections(); - $response = rest_get_server()->dispatch( - new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . DASHBOARD_NAME . '/sections' ) - ); - - $this->assertSame( 200, $response->get_status() ); $this->assertSame( - array( 'traffic', 'insights' ), - array_column( $response->get_data(), 'slug' ) + array( 'traffic', 'insights', 'subscribers' ), + $this->request_section_slugs() ); - $this->activate_subscriptions_module(); - - $response = rest_get_server()->dispatch( - new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . DASHBOARD_NAME . '/sections' ) - ); + add_filter( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); - $this->assertSame( 200, $response->get_status() ); $this->assertSame( - array( 'traffic', 'insights', 'subscribers' ), - array_column( $response->get_data(), 'slug' ) + array( 'traffic', 'insights' ), + $this->request_section_slugs() ); } @@ -864,7 +963,6 @@ public function test_sections_route_reflects_subscribers_module_state() { */ public function test_omits_woocommerce_dashboard_section_when_unavailable() { add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_false' ); - $this->activate_subscriptions_module(); register_default_dashboard_sections(); @@ -1027,8 +1125,6 @@ public function test_sections_route_returns_available_sections_sorted_by_order() * Sections route includes the store section only when WooCommerce is detected. */ public function test_sections_route_reflects_woocommerce_availability() { - $this->activate_subscriptions_module(); - register_default_dashboard_sections(); $this->set_admin_user(); diff --git a/projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php b/projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php new file mode 100644 index 000000000000..7d17bf90f970 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php @@ -0,0 +1,25 @@ + Date: Thu, 13 Aug 2026 16:59:20 +0800 Subject: [PATCH 3/3] docs: simplify Subscribers gate comments --- .../src/dashboard-layout.php | 22 +++--------- .../src/dashboard-sections.php | 16 ++------- .../src/widget-availability.php | 11 ++---- .../tests/php/Dashboard_Layout_Test.php | 10 ++---- .../tests/php/Dashboard_Section_Test.php | 35 ++++--------------- .../tests/php/mocks/jetpack-plugin-mock.php | 17 ++------- 6 files changed, 23 insertions(+), 88 deletions(-) diff --git a/projects/packages/premium-analytics/src/dashboard-layout.php b/projects/packages/premium-analytics/src/dashboard-layout.php index f985cc35175e..5fff6546c21f 100644 --- a/projects/packages/premium-analytics/src/dashboard-layout.php +++ b/projects/packages/premium-analytics/src/dashboard-layout.php @@ -73,18 +73,10 @@ function get_dashboard_default_layout_for( $dashboard_name ) { } /** - * Availability gates for the tabs this route's name can resolve to. + * Returns availability gates for conditional dashboard tabs. * - * Every conditional tab needs an entry: a tab the section registry hides but - * this map omits still hands out its bundled layout. Tabs absent from the map - * are served to every dashboard reader. - * - * The gates are called directly rather than read off - * {@see Dashboard_Section::is_available()} because this route registers from - * dashboard-layout.php and has to answer without assuming the section registry - * was hydrated. Both dashboard entry points load dashboard-sections.php before - * registering this route, so the callbacks are always defined by the time one - * runs. + * The callbacks are used directly because the section registry may not be + * initialized when this route runs. * * @since $$next-version$$ * @@ -100,12 +92,8 @@ function get_dashboard_default_layout_gates() { /** * REST callback returning the default layout for the requested dashboard. * - * The route admits every dashboard reader, but its name also resolves to a - * single tab, so a gated tab is refused here the way the section route refuses - * it through {@see Dashboard_Section::is_available()}. The check keys on the - * resolved tab rather than the string because two spellings arrive: the bare - * `store` alias in the URL, and `?name=woocommerce/store`, which WordPress - * reads ahead of the URL capture. + * Availability is checked after resolving the name because tabs can be + * requested by alias or full section ID. * * @param \WP_REST_Request $request REST request carrying the dashboard name. * @return \WP_REST_Response|\WP_Error Response wrapping the default layout array. diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index 0ec59b0174b7..23243e562544 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -90,26 +90,14 @@ function is_woocommerce_dashboard_section_available_to_current_user() { /** * Whether the Subscribers dashboard section should be exposed. * - * Mirrors the Calypso Stats gate on the same tab. Modules::is_active() resolves - * `jetpack_active_modules` the same way Sync does before shipping it to WPCOM as - * `active_modules` — the value Calypso gates on — and short-circuits to true on - * WPCOM Simple, where modules are not a concept and the tab always shows. - * - * Note that "resolves" is not "reads": at its default $available_only, the - * option is intersected with Modules::get_available(). That is what the guard - * below is about. + * Sites without Jetpack have no module state to check, so the section remains + * available. Modules::is_active() also returns true on WPCOM Simple. * * @since $$next-version$$ * * @return bool True when the subscriptions module is active. */ function is_subscribers_dashboard_section_available() { - // Without the Jetpack plugin, Modules::get_available() resolves to the - // standalone-module filter, and nothing registers `subscriptions` into it — - // so the intersection is empty and the gate could never open. The tab reads - // `stats/subscribers`, `subscribers/counts` and `stats/followers` off the - // WPCOM proxy, which answers whatever the local module state is, so a site - // with no module system to consult is exempt rather than refused. $is_available = ! class_exists( 'Jetpack' ) || ( new Modules() )->is_active( 'subscriptions' ); /** diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index 1002954bf931..439dca47b2a0 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -166,14 +166,9 @@ function filter_registrable_widget_types_by_plugin( $widget_candidates ) { add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_plugin' ); -// The Subscribers section is gated too (dashboard-sections.php) but deliberately -// has no counterpart here, so section and widgets do disagree for that one tab. -// The commerce gate above pairs with its section because Woo widgets 403 without -// the plugin — dropping them spares the reader a broken card. Subscriber widgets -// keep working when the subscriptions module is off, since their data comes from -// the WPCOM proxy rather than the module. Unregistering them would turn any the -// reader had already placed on another tab into "Widget is no longer available", -// which costs more than leaving an entry in the picker. +// Subscriber widgets remain available when their section is hidden because +// their data does not depend on the local module. Unregistering them would also +// break instances placed in other sections. /** * Removes candidates the reader could not load data for anyway. diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php index 4d40717360d4..5d4afc491684 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php @@ -13,8 +13,6 @@ use WP_REST_Server; require_once __DIR__ . '/../../src/dashboard-layout.php'; -// The default-layout route's gates live in dashboard-sections.php, the way both -// dashboard entry points load it before registering the route. require_once __DIR__ . '/../../src/dashboard-sections.php'; require_once __DIR__ . '/traits/trait-analytics-capabilities.php'; @@ -165,9 +163,7 @@ public function test_default_layout_route_serves_the_traffic_tab_to_a_view_stats } /** - * A tab the section registry hides must not hand out its bundled layout here - * either — this route resolves a name straight to a tab, so it is a second - * way in. + * An unavailable tab does not expose its default layout. */ public function test_default_layout_route_refuses_an_unavailable_subscribers_tab() { $this->register_route_with_capabilities(); @@ -180,7 +176,7 @@ public function test_default_layout_route_refuses_an_unavailable_subscribers_tab } /** - * `?name=` shadows the URL capture for subscribers the way it does for store. + * An unavailable tab is refused when `?name=` shadows the URL capture. */ public function test_default_layout_route_refuses_a_name_shadowed_subscribers_tab() { $this->register_route_with_capabilities(); @@ -193,7 +189,7 @@ public function test_default_layout_route_refuses_a_name_shadowed_subscribers_ta } /** - * The refusal is the gate's: an available section is served as before. + * An available Subscribers tab exposes its default layout. */ public function test_default_layout_route_serves_an_available_subscribers_tab() { $this->register_route_with_capabilities(); diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php index 3f9faa1e57df..c3cb5c0525a5 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php @@ -39,8 +39,7 @@ class Dashboard_Section_Test extends BaseTestCase { private $doing_it_wrong = array(); /** - * The standalone-module filter a test added, held so tear_down can remove - * that one callback rather than everything on a Jetpack-wide hook. + * Standalone-module filter callback registered by a test. * * @var callable|null */ @@ -76,8 +75,6 @@ public function tear_down() { remove_all_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER ); remove_all_filters( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER ); - // Targeted, not remove_all_filters(): `jetpack_get_available_standalone_modules` - // is a Jetpack-wide hook other packages register into. if ( null !== $this->available_modules_filter ) { remove_filter( 'jetpack_get_available_standalone_modules', $this->available_modules_filter ); $this->available_modules_filter = null; @@ -95,11 +92,7 @@ public function tear_down() { } /** - * Turn the `subscriptions` Jetpack module on. - * - * Both levers are needed because Modules::get_active() intersects the option - * with the modules available to the site, and without a real Jetpack plugin - * that list is only what standalone plugins register. + * Mark the subscriptions module as both active and available. * * @return void */ @@ -116,10 +109,7 @@ private function activate_subscriptions_module() { } /** - * Make the Subscribers gate see a Jetpack plugin. - * - * Callers need their own process: a class cannot be undeclared, so loading - * the mock for the whole suite would flip the gate for every other test. + * Load the Jetpack class mock for a separate-process test. * * @return void */ @@ -832,11 +822,7 @@ private function request_section_slugs() { } /** - * A site with no local module system keeps the tab. - * - * This is the standalone plugin's shape, and the default here because - * WorDBless runs without the Jetpack plugin: the gate has no module list to - * consult, and the tab's data comes from the WPCOM proxy either way. + * A site without a local module system keeps the tab. */ public function test_registers_subscribers_dashboard_section_without_a_module_system() { register_default_dashboard_sections(); @@ -849,7 +835,7 @@ public function test_registers_subscribers_dashboard_section_without_a_module_sy } /** - * With a Jetpack plugin present, the module state decides: off hides the tab. + * A Jetpack site with the module inactive hides the tab. * * @runInSeparateProcess * @preserveGlobalState disabled @@ -869,13 +855,11 @@ public function test_omits_subscribers_dashboard_section_when_module_is_inactive $ids = $this->available_section_ids(); $this->assertNotContains( 'analytics/subscribers', $ids ); - // Registry is populated, so the absence above is the gate rather than an - // empty registry. $this->assertContains( 'analytics/traffic', $ids ); } /** - * The same site with the module on offers the tab again. + * A Jetpack site with the module active offers the tab. * * @runInSeparateProcess * @preserveGlobalState disabled @@ -896,12 +880,7 @@ public function test_registers_subscribers_dashboard_section_when_module_is_acti } /** - * WPCOM Simple always offers the tab: modules are not a concept there. - * - * Needs a real constant, hence the separate process — Modules::is_active() - * reads a raw `defined( 'IS_WPCOM' )` rather than the Constants wrapper the - * rest of this package mocks through, so Constants::set_constant() would set - * a value nothing reads and the test would assert the wrong branch. + * WPCOM Simple offers the tab without the module. * * @runInSeparateProcess * @preserveGlobalState disabled diff --git a/projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php b/projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php index 7d17bf90f970..ff8441cfc71f 100644 --- a/projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php +++ b/projects/packages/premium-analytics/tests/php/mocks/jetpack-plugin-mock.php @@ -1,16 +1,8 @@