From c1c00529bd3e8aea5c57f3626452dcfa137a8a38 Mon Sep 17 00:00:00 2001 From: WordPress Fan <146129302+wordpressfan@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:14:46 +0200 Subject: [PATCH 1/6] RI Recommendations - calculate avg scores and add them into global score transient (#8117) --- .../Database/Queries/RocketInsights.php | 14 ++ .../GlobalMetrics/Calculator.php | 131 ++++++++++++++++++ .../GlobalMetrics/Subscriber.php | 53 +++++++ .../Admin/RocketInsights/GlobalScore.php | 18 +++ .../Admin/RocketInsights/ServiceProvider.php | 12 ++ .../Admin/RocketInsights/Subscriber.php | 19 +++ inc/Plugin.php | 1 + .../Calculator/addAverageMetrics.php | 86 ++++++++++++ .../Calculator/calculateAverageMetrics.php | 94 +++++++++++++ .../Calculator/getCompletedMetrics.php | 130 +++++++++++++++++ .../Calculator/hasCompletedTests.php | 33 +++++ .../GlobalScore/GetGlobalScoreDataTest.php | 12 ++ .../Calculator/getCompletedMetrics.php | 82 +++++++++++ .../Calculator/addAverageMetrics.php | 64 +++++++++ .../Calculator/calculateAverageMetrics.php | 59 ++++++++ .../Calculator/hasCompletedTests.php | 56 ++++++++ 16 files changed, 864 insertions(+) create mode 100644 inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator.php create mode 100644 inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php create mode 100644 tests/Integration/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php diff --git a/inc/Engine/Admin/RocketInsights/Database/Queries/RocketInsights.php b/inc/Engine/Admin/RocketInsights/Database/Queries/RocketInsights.php index 723e6af084..6bcf500c4b 100644 --- a/inc/Engine/Admin/RocketInsights/Database/Queries/RocketInsights.php +++ b/inc/Engine/Admin/RocketInsights/Database/Queries/RocketInsights.php @@ -192,4 +192,18 @@ public function unblur_rows() { return $db->query( "UPDATE `$prefixed_table_name` SET is_blurred = '0' WHERE status = 'completed' AND is_blurred = '1'" ); } + + /** + * Get metrics_data for completed tests. + * + * @return array + */ + public function get_completed_metrics(): array { + return $this->query( + [ + 'fields' => 'metric_data', + 'status' => 'completed', + ] + ); + } } diff --git a/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator.php b/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator.php new file mode 100644 index 0000000000..7b9d562c34 --- /dev/null +++ b/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator.php @@ -0,0 +1,131 @@ +query = $query; + } + + /** + * Calculate average metrics from all completed tests. + * + * Metrics are returned in the format expected by the Recommendations API: + * - LCP, TTFB: seconds (converted from milliseconds) + * - CLS: unitless decimal + * - TBT: milliseconds (kept as-is) + * + * @return array { + * Average performance metrics. + * + * @type float|null $lcp Largest Contentful Paint in seconds. + * @type float|null $ttfb Time to First Byte in seconds. + * @type float|null $cls Cumulative Layout Shift. + * @type int|null $tbt Total Blocking Time in milliseconds. + * } + */ + public function calculate_average_metrics(): array { + // Get all completed tests with metric_data. + $tests = $this->query->get_completed_metrics(); + + // No completed tests - return null for all metrics. + if ( empty( $tests ) ) { + return [ + 'lcp' => null, + 'ttfb' => null, + 'cls' => null, + 'tbt' => null, + ]; + } + + // Initialize accumulators. + $totals = [ + 'largest_contentful_paint' => 0, + 'total_blocking_time' => 0, + 'cumulative_layout_shift' => 0, + 'time_to_first_byte' => 0, + ]; + + $test_count = 0; + + // Sum up all metrics. + foreach ( $tests as $test ) { + $metric_data = json_decode( $test, true ); + + // Skip if metric_data is empty or not an array. + if ( empty( $metric_data ) || ! is_array( $metric_data ) ) { + continue; + } + + // Sum all metric values. + foreach ( self::METRIC_KEYS as $key ) { + if ( isset( $metric_data[ $key ] ) ) { + $totals[ $key ] += (float) $metric_data[ $key ]; + } + } + + ++$test_count; + } + + // No valid tests found. + if ( 0 === $test_count ) { + return [ + 'lcp' => null, + 'ttfb' => null, + 'cls' => null, + 'tbt' => null, + ]; + } + + // Calculate averages and format for Recommendations API. + return [ + // LCP: milliseconds → seconds. + 'lcp' => round( ( $totals['largest_contentful_paint'] / $test_count ) / 1000, 3 ), + // TTFB: milliseconds → seconds. + 'ttfb' => round( ( $totals['time_to_first_byte'] / $test_count ) / 1000, 3 ), + // CLS: unitless decimal. + 'cls' => round( $totals['cumulative_layout_shift'] / $test_count, 3 ), + // TBT: keep in milliseconds (integer). + 'tbt' => (int) round( $totals['total_blocking_time'] / $test_count ), + ]; + } + + /** + * Check if there are any completed tests available. + * + * @return bool True if at least one completed test exists. + */ + public function has_completed_tests(): bool { + return 0 < (int) $this->query->get_completed_count(); + } +} diff --git a/inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php b/inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php new file mode 100644 index 0000000000..679637b7b0 --- /dev/null +++ b/inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php @@ -0,0 +1,53 @@ +calculator = $calculator; + } + + /** + * {@inheritdoc} + */ + public static function get_subscribed_events(): array { + return [ + 'rocket_insights_global_score_data' => 'add_average_metrics', + ]; + } + + /** + * Add average metrics to global score data. + * + * @param array $data Existing global score data. + * @return array Modified data with average_metrics added. + */ + public function add_average_metrics( array $data ): array { + // Only add metrics if there are completed tests. + if ( ! $this->calculator->has_completed_tests() ) { + return $data; + } + + $data['average_metrics'] = $this->calculator->calculate_average_metrics(); + + return $data; + } +} diff --git a/inc/Engine/Admin/RocketInsights/GlobalScore.php b/inc/Engine/Admin/RocketInsights/GlobalScore.php index ff7c8ddf11..bbb0466979 100644 --- a/inc/Engine/Admin/RocketInsights/GlobalScore.php +++ b/inc/Engine/Admin/RocketInsights/GlobalScore.php @@ -86,6 +86,24 @@ private function calculate_and_cache_data(): array { 'is_running' => $this->calculate_current_status() === 'in-progress', ]; + /** + * Filters the global score data before caching. + * + * Allows other features (e.g., recommendations) to inject additional data + * into the global score transient without tight coupling. + * + * @param array $data { + * Global score data. + * + * @type int $score Global score (0-100). + * @type int $pages_num Number of monitored pages. + * @type string $status Current system status. + * @type bool $is_running Whether tests are in progress. + * } + * @return array Filtered global score data. + */ + $data = wpm_apply_filters_typed( 'array', 'rocket_insights_global_score_data', $data ); + set_transient( self::TRANSIENT_NAME, $data, self::CACHE_EXPIRATION ); return $data; diff --git a/inc/Engine/Admin/RocketInsights/ServiceProvider.php b/inc/Engine/Admin/RocketInsights/ServiceProvider.php index 8e0e84aa49..98110299d2 100644 --- a/inc/Engine/Admin/RocketInsights/ServiceProvider.php +++ b/inc/Engine/Admin/RocketInsights/ServiceProvider.php @@ -11,6 +11,7 @@ APIHandler\APIClient as RIAPIClient, Context\Context, Context\SaasContext, + GlobalMetrics\Calculator, Jobs\Factory as RIFactory, Jobs\Manager as RIManager, Managers\Plan, @@ -19,6 +20,7 @@ Settings\Controller as SettingsController, Settings\Subscriber as SettingsSubscriber, PostListing\Subscriber as PostListingSubscriber, + GlobalMetrics\Subscriber as GlobalMetricsSubscriber }; use WP_Rocket\Engine\Common\JobManager\Queue\Queue as JobManagerQueue; @@ -54,6 +56,8 @@ class ServiceProvider extends AbstractServiceProvider { 'ri_post_listing_subscriber', 'ri_metric_formatter', 'job_manager_queue', + 'ri_global_metrics_calculator', + 'ri_global_metrics_subscriber', ]; /** @@ -130,6 +134,14 @@ public function register(): void { ] ); + // Global Metrics Calculator. + $this->getContainer()->add( 'ri_global_metrics_calculator', Calculator::class ) + ->addArgument( 'ri_query' ); + + // Global Metrics Subscriber. + $this->getContainer()->addShared( 'ri_global_metrics_subscriber', GlobalMetricsSubscriber::class ) + ->addArgument( 'ri_global_metrics_calculator' ); + // Global Score layer. $this->getContainer()->add( 'ri_global_score', GlobalScore::class ) ->addArguments( diff --git a/inc/Engine/Admin/RocketInsights/Subscriber.php b/inc/Engine/Admin/RocketInsights/Subscriber.php index ff32a289e5..6536c6b782 100644 --- a/inc/Engine/Admin/RocketInsights/Subscriber.php +++ b/inc/Engine/Admin/RocketInsights/Subscriber.php @@ -178,6 +178,7 @@ public static function get_subscribed_events(): array { [ 'on_update_reset_credit', 10, 2 ], [ 'on_update_cancel_old_as_jobs', 10, 2 ], [ 'on_update_refresh_metric_data', 10, 2 ], + [ 'on_update_clear_global_score', 10, 2 ], ], 'admin_notices' => 'maybe_display_rocket_insights_promotion_notice', 'rocket_rocket_insights_enabled' => 'maybe_disable_for_reseller_or_non_live', @@ -762,4 +763,22 @@ public function on_update_refresh_metric_data( $new_version, $old_version ) { public function track_metric_actions(): void { $this->controller->track_metric_actions(); } + + /** + * Callback for the wp_rocket_upgrade action to clear global score cache when updating from a version before 3.20.6. + * + * @param string $new_version New plugin version. + * @param string $old_version Previous plugin version. + * + * @return void + */ + public function on_update_clear_global_score( $new_version, $old_version ) { + if ( version_compare( $old_version, '3.20.6', '>=' ) ) { + return; + } + + $this->logger::info( 'Rocket Insights: Clear global score to insert average metrics when updating from a WP Rocket version less than 3.20.6' ); + + $this->global_score->reset(); + } } diff --git a/inc/Plugin.php b/inc/Plugin.php index 671ac631fe..a0d709eedf 100644 --- a/inc/Plugin.php +++ b/inc/Plugin.php @@ -413,6 +413,7 @@ private function init_common_subscribers() { 'ri_subscriber', 'ri_url_limit_subscriber', 'ri_post_listing_subscriber', + 'ri_global_metrics_subscriber', 'post_subscriber', 'tracking_subscriber', 'logger_subscriber', diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php new file mode 100644 index 0000000000..ae929669c1 --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php @@ -0,0 +1,86 @@ + [ + 'shouldAddMetricsWhenTestsExist' => [ + 'config' => [ + 'has_tests' => true, + 'metrics' => [ + 'lcp' => 2.5, + 'ttfb' => 0.8, + 'cls' => 0.1, + 'tbt' => 300, + ], + 'input_data' => [ + 'score' => 85, + 'pages_num' => 5, + 'status' => 'complete', + 'is_running' => false, + ], + ], + 'expected' => [ + 'score' => 85, + 'pages_num' => 5, + 'status' => 'complete', + 'is_running' => false, + 'average_metrics' => [ + 'lcp' => 2.5, + 'ttfb' => 0.8, + 'cls' => 0.1, + 'tbt' => 300, + ], + ], + ], + + 'shouldNotAddMetricsWhenNoTests' => [ + 'config' => [ + 'has_tests' => false, + 'metrics' => [], + 'input_data' => [ + 'score' => 0, + 'pages_num' => 0, + 'status' => 'pending', + 'is_running' => false, + ], + ], + 'expected' => [ + 'score' => 0, + 'pages_num' => 0, + 'status' => 'pending', + 'is_running' => false, + ], + ], + + 'shouldPreserveExistingData' => [ + 'config' => [ + 'has_tests' => true, + 'metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 1.0, + 'cls' => 0.15, + 'tbt' => 450, + ], + 'input_data' => [ + 'score' => 75, + 'pages_num' => 10, + 'status' => 'complete', + 'is_running' => false, + 'custom_key' => 'custom_value', + ], + ], + 'expected' => [ + 'score' => 75, + 'pages_num' => 10, + 'status' => 'complete', + 'is_running' => false, + 'custom_key' => 'custom_value', + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 1.0, + 'cls' => 0.15, + 'tbt' => 450, + ], + ], + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php new file mode 100644 index 0000000000..993d1647b8 --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php @@ -0,0 +1,94 @@ + [ + 'shouldReturnNullWhenNoCompletedTests' => [ + 'config' => [ + 'tests' => [], + ], + 'expected' => [ + 'lcp' => null, + 'ttfb' => null, + 'cls' => null, + 'tbt' => null, + ], + ], + + 'shouldCalculateAverageForSingleTest' => [ + 'config' => [ + 'tests' => [ + '{"largest_contentful_paint":2500,"time_to_first_byte":800,"cumulative_layout_shift":0.1,"total_blocking_time":300}', + ], + ], + 'expected' => [ + 'lcp' => 2.5, // 2500ms / 1000 = 2.5s + 'ttfb' => 0.8, // 800ms / 1000 = 0.8s + 'cls' => 0.1, + 'tbt' => 300, + ], + ], + + 'shouldCalculateAverageForMultipleTests' => [ + 'config' => [ + 'tests' => [ + '{"largest_contentful_paint":2000,"time_to_first_byte":500,"cumulative_layout_shift":0.05,"total_blocking_time":200}', + '{"largest_contentful_paint":3000,"time_to_first_byte":1000,"cumulative_layout_shift":0.15,"total_blocking_time":400}', + '{"largest_contentful_paint":2500,"time_to_first_byte":700,"cumulative_layout_shift":0.1,"total_blocking_time":300}', + ], + ], + 'expected' => [ + 'lcp' => 2.5, // (2000 + 3000 + 2500) / 3 / 1000 = 2.5s + 'ttfb' => 0.733, // (500 + 1000 + 700) / 3 / 1000 = 0.733s + 'cls' => 0.1, // (0.05 + 0.15 + 0.1) / 3 = 0.1 + 'tbt' => 300, // round((200 + 400 + 300) / 3) = 300ms + ], + ], + + 'shouldHandleInvalidJsonGracefully' => [ + 'config' => [ + 'tests' => [ + '{"largest_contentful_paint":2000,"time_to_first_byte":500,"cumulative_layout_shift":0.05,"total_blocking_time":200}', + 'invalid json{', + '{"largest_contentful_paint":3000,"time_to_first_byte":1000,"cumulative_layout_shift":0.15,"total_blocking_time":400}', + ], + ], + 'expected' => [ + 'lcp' => 2.5, // (2000 + 3000) / 2 / 1000 = 2.5s + 'ttfb' => 0.75, // (500 + 1000) / 2 / 1000 = 0.75s + 'cls' => 0.1, // (0.05 + 0.15) / 2 = 0.1 + 'tbt' => 300, // round((200 + 400) / 2) = 300ms + ], + ], + + 'shouldHandleMissingMetricsGracefully' => [ + 'config' => [ + 'tests' => [ + '{"largest_contentful_paint":2000,"cumulative_layout_shift":0.05}', + '{"time_to_first_byte":1000,"total_blocking_time":400}', + ], + ], + 'expected' => [ + 'lcp' => 1.0, // 2000 / 2 / 1000 = 1.0s (second test has 0) + 'ttfb' => 0.5, // 1000 / 2 / 1000 = 0.5s (first test has 0) + 'cls' => 0.025, // 0.05 / 2 = 0.025 + 'tbt' => 200, // round(400 / 2) = 200ms + ], + ], + + 'shouldReturnNullWhenAllTestsHaveInvalidData' => [ + 'config' => [ + 'tests' => [ + 'invalid json{', + '{}', + 'null', + ], + ], + 'expected' => [ + 'lcp' => null, + 'ttfb' => null, + 'cls' => null, + 'tbt' => null, + ], + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php new file mode 100644 index 0000000000..763e309639 --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php @@ -0,0 +1,130 @@ + [ + 'shouldReturnOnlyCompletedTests' => [ + 'config' => [ + 'rows' => [ + [ + 'url' => 'https://example.com/page1', + 'status' => 'completed', + 'metric_data' => '{"largest_contentful_paint":2500,"time_to_first_byte":800,"cumulative_layout_shift":0.1,"total_blocking_time":300}', + 'score' => 85, + ], + [ + 'url' => 'https://example.com/page2', + 'status' => 'pending', + 'metric_data' => '{"largest_contentful_paint":3000,"time_to_first_byte":1000,"cumulative_layout_shift":0.15,"total_blocking_time":400}', + 'score' => 0, + ], + [ + 'url' => 'https://example.com/page3', + 'status' => 'completed', + 'metric_data' => '{"largest_contentful_paint":2000,"time_to_first_byte":600,"cumulative_layout_shift":0.05,"total_blocking_time":200}', + 'score' => 90, + ], + [ + 'url' => 'https://example.com/page4', + 'status' => 'failed', + 'metric_data' => '{}', + 'score' => 0, + ], + ], + ], + 'expected' => [ + 'count' => 2, // Only completed tests + 'expected_keys' => [ + 'largest_contentful_paint', + 'time_to_first_byte', + 'cumulative_layout_shift', + 'total_blocking_time', + ], + ], + ], + + 'shouldReturnEmptyWhenNoCompletedTests' => [ + 'config' => [ + 'rows' => [ + [ + 'url' => 'https://example.com/page1', + 'status' => 'pending', + 'metric_data' => '{}', + 'score' => 0, + ], + [ + 'url' => 'https://example.com/page2', + 'status' => 'failed', + 'metric_data' => '{}', + 'score' => 0, + ], + ], + ], + 'expected' => [ + 'count' => 0, + 'expected_keys' => [], + ], + ], + + 'shouldReturnAllCompletedTests' => [ + 'config' => [ + 'rows' => [ + [ + 'url' => 'https://example.com/page1', + 'status' => 'completed', + 'metric_data' => '{"largest_contentful_paint":2500,"time_to_first_byte":800,"cumulative_layout_shift":0.1,"total_blocking_time":300}', + 'score' => 85, + ], + [ + 'url' => 'https://example.com/page2', + 'status' => 'completed', + 'metric_data' => '{"largest_contentful_paint":3000,"time_to_first_byte":1000,"cumulative_layout_shift":0.15,"total_blocking_time":400}', + 'score' => 75, + ], + [ + 'url' => 'https://example.com/page3', + 'status' => 'completed', + 'metric_data' => '{"largest_contentful_paint":2000,"time_to_first_byte":600,"cumulative_layout_shift":0.05,"total_blocking_time":200}', + 'score' => 95, + ], + ], + ], + 'expected' => [ + 'count' => 3, + 'expected_keys' => [ + 'largest_contentful_paint', + 'time_to_first_byte', + 'cumulative_layout_shift', + 'total_blocking_time', + ], + ], + ], + + 'shouldHandleBlurredCompletedTests' => [ + 'config' => [ + 'rows' => [ + [ + 'url' => 'https://example.com/page1', + 'status' => 'completed', + 'metric_data' => '{"largest_contentful_paint":2500,"time_to_first_byte":800,"cumulative_layout_shift":0.1,"total_blocking_time":300}', + 'score' => 85, + ], + [ + 'url' => 'https://example.com/page2', + 'status' => 'blurred', // Blurred tests should NOT be included + 'metric_data' => '{"largest_contentful_paint":3000,"time_to_first_byte":1000,"cumulative_layout_shift":0.15,"total_blocking_time":400}', + 'score' => 75, + ], + ], + ], + 'expected' => [ + 'count' => 1, // Only 'completed', not 'blurred' + 'expected_keys' => [ + 'largest_contentful_paint', + 'time_to_first_byte', + 'cumulative_layout_shift', + 'total_blocking_time', + ], + ], + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php new file mode 100644 index 0000000000..d7fb68d4f6 --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php @@ -0,0 +1,33 @@ + [ + 'shouldReturnTrueWhenTestsExist' => [ + 'config' => [ + 'count' => 5, + ], + 'expected' => true, + ], + + 'shouldReturnTrueWhenOneTestExists' => [ + 'config' => [ + 'count' => 1, + ], + 'expected' => true, + ], + + 'shouldReturnFalseWhenNoTests' => [ + 'config' => [ + 'count' => 0, + ], + 'expected' => false, + ], + + 'shouldHandleStringCount' => [ + 'config' => [ + 'count' => '3', + ], + 'expected' => true, + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalScore/GetGlobalScoreDataTest.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalScore/GetGlobalScoreDataTest.php index 30dc46d58d..decbf651cb 100644 --- a/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalScore/GetGlobalScoreDataTest.php +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/GlobalScore/GetGlobalScoreDataTest.php @@ -37,6 +37,12 @@ 'pages_num' => 3, 'status' => 'complete', 'is_running' => false, + 'average_metrics' => [ + 'lcp' => null, + 'ttfb' => null, + 'cls' => null, + 'tbt' => null, + ], ] ] ], @@ -68,6 +74,12 @@ 'pages_num' => 2, 'status' => 'in-progress', 'is_running' => true, + 'average_metrics' => [ + 'lcp' => null, + 'ttfb' => null, + 'cls' => null, + 'tbt' => null, + ], ] ] ], diff --git a/tests/Integration/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php b/tests/Integration/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php new file mode 100644 index 0000000000..7d8f59c5bb --- /dev/null +++ b/tests/Integration/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/getCompletedMetrics.php @@ -0,0 +1,82 @@ +query = $container->get( 'ri_query' ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + // Insert test data using Query::add_item() + foreach ( $config['rows'] as $row ) { + $this->query->add_item( $row ); + } + + // Execute query + $result = $this->query->get_completed_metrics(); + + // Assert count + $this->assertCount( $expected['count'], $result ); + + // If we expect results, validate structure + if ( $expected['count'] > 0 ) { + foreach ( $result as $metric_data ) { + // Each result should be a JSON string (the metric_data column) + $this->assertIsString( $metric_data ); + + // Verify it's valid JSON + $decoded = json_decode( $metric_data, true ); + $this->assertIsArray( $decoded ); + + // Verify expected metric keys exist + foreach ( $expected['expected_keys'] as $key ) { + $this->assertArrayHasKey( $key, $decoded ); + } + } + } + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php b/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php new file mode 100644 index 0000000000..a10d0a736f --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/addAverageMetrics.php @@ -0,0 +1,64 @@ +calculator_mock = Mockery::mock( Calculator::class ); + $this->subscriber = new Subscriber( $this->calculator_mock ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + // Mock has_completed_tests + $this->calculator_mock->shouldReceive( 'has_completed_tests' ) + ->once() + ->andReturn( $config['has_tests'] ); + + // Mock calculate_average_metrics (only if tests exist) + if ( $config['has_tests'] ) { + $this->calculator_mock->shouldReceive( 'calculate_average_metrics' ) + ->once() + ->andReturn( $config['metrics'] ); + } else { + $this->calculator_mock->shouldNotReceive( 'calculate_average_metrics' ); + } + + $result = $this->subscriber->add_average_metrics( $config['input_data'] ); + + $this->assertSame( $expected, $result ); + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php b/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php new file mode 100644 index 0000000000..58ec45b0ed --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/calculateAverageMetrics.php @@ -0,0 +1,59 @@ +query_mock = $this->createMock(RocketInsights::class); + $this->calculator = new Calculator( $this->query_mock ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + // Mock get_completed_metrics + $this->query_mock->expects( $this->once() ) + ->method( 'get_completed_metrics' ) + ->willReturn( $config['tests'] ); + + $result = $this->calculator->calculate_average_metrics(); + + $this->assertSame( $expected['lcp'], $result['lcp'] ); + $this->assertSame( $expected['ttfb'], $result['ttfb'] ); + $this->assertSame( $expected['cls'], $result['cls'] ); + $this->assertSame( $expected['tbt'], $result['tbt'] ); + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php b/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php new file mode 100644 index 0000000000..efac2ce806 --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/GlobalMetrics/Calculator/hasCompletedTests.php @@ -0,0 +1,56 @@ +query_mock = $this->createMock(RocketInsights::class); + $this->calculator = new Calculator( $this->query_mock ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + // Mock get_completed_count + $this->query_mock->expects( $this->once() ) + ->method( 'get_completed_count' ) + ->willReturn( $config['count'] ); + + $result = $this->calculator->has_completed_tests(); + + $this->assertSame( $expected, $result ); + } +} From 51f9f146aea1cf7b6db61b3cc0e13cfb43fe0929 Mon Sep 17 00:00:00 2001 From: WordPress Fan <146129302+wordpressfan@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:31:15 +0200 Subject: [PATCH 2/6] RI Recommendations - APIClient (#8121) --- .../Recommendations/APIClient.php | 196 ++++++++++++++++ .../Admin/RocketInsights/ServiceProvider.php | 7 + .../APIClient/getRecommendations.php | 218 ++++++++++++++++++ .../APIClient/getRecommendations.php | 108 +++++++++ 4 files changed, 529 insertions(+) create mode 100644 inc/Engine/Admin/RocketInsights/Recommendations/APIClient.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php diff --git a/inc/Engine/Admin/RocketInsights/Recommendations/APIClient.php b/inc/Engine/Admin/RocketInsights/Recommendations/APIClient.php new file mode 100644 index 0000000000..51d0de337c --- /dev/null +++ b/inc/Engine/Admin/RocketInsights/Recommendations/APIClient.php @@ -0,0 +1,196 @@ +logger::error( + 'Recommendations API: Missing required email parameter' + ); + + return new WP_Error( + 'missing_email', + 'Email parameter is required for recommendations API.' + ); + } + + // Build query parameters (remove null/empty values). + $query_params = $this->build_query_params( $params ); + + // Merge custom args with defaults. + $args = array_merge( + [ + 'body' => $query_params, + 'timeout' => 15, + ], + $args + ); + + $this->logger::debug( + 'Recommendations API: Fetching recommendations', + [ + 'params' => $query_params, + ] + ); + + // Use AbstractAPIClient's handle_get method. + $sent = $this->handle_get( $args ); + + if ( ! $sent ) { + $this->logger::error( + 'Recommendations API: Request failed', + [ + 'code' => $this->response_code, + 'message' => $this->error_message, + ] + ); + + return new WP_Error( + 'api_request_failed', + $this->error_message, + [ 'status' => $this->response_code ] + ); + } + + // Decode JSON response. + $response_data = json_decode( $this->response_body, true ); + + // Check for JSON decode error. + if ( JSON_ERROR_NONE !== json_last_error() || null === $response_data ) { + $this->logger::error( 'Recommendations API: Invalid JSON response' ); + return new WP_Error( + 'invalid_json', + 'Invalid API response - malformed JSON' + ); + } + + // Validate response structure. + if ( ! $this->validate_response( $response_data ) ) { + $this->logger::error( + 'Recommendations API: Invalid response structure', + [ 'response' => $response_data ] + ); + + return new WP_Error( + 'invalid_structure', + 'Invalid API response structure' + ); + } + + $this->logger::info( + 'Recommendations API: Recommendations fetched successfully', + [ + 'total_recommendations' => count( $response_data['recommendations'] ?? [] ), + 'language' => $response_data['metadata']['language'] ?? 'unknown', + ] + ); + + return [ + 'code' => $this->response_code, + 'data' => $response_data, + ]; + } + + /** + * Build query parameters array, removing null and empty values. + * + * @param array $params Raw parameters. + * @return array Filtered parameters. + */ + private function build_query_params( array $params ): array { + // Map of allowed parameters. + $allowed_params = [ + 'email', + 'lcp', + 'ttfb', + 'cls', + 'tbt', + 'global_score', + 'enabled_options', + 'language', + 'limit', + 'version', + ]; + + $query_params = []; + + foreach ( $allowed_params as $key ) { + if ( isset( $params[ $key ] ) && '' !== $params[ $key ] ) { + $query_params[ $key ] = $params[ $key ]; + } + } + + return $query_params; + } + + /** + * Validate response structure. + * + * @param array $response Response data. + * @return bool True if valid, false otherwise. + */ + public function validate_response( array $response ): bool { + if ( ! isset( $response['recommendations'] ) || ! is_array( $response['recommendations'] ) ) { + return false; + } + + // Must have 'metadata' key (array). + if ( ! isset( $response['metadata'] ) || ! is_array( $response['metadata'] ) ) { + return false; + } + + return true; + } + + /** + * Force this value to be true for the Job Manager to consider the request successful because this APIClient won't be used in queue. + * + * @param array $response Response data. + * @return bool + */ + public function validate_add_to_queue_response( array $response ): bool { + return true; + } +} diff --git a/inc/Engine/Admin/RocketInsights/ServiceProvider.php b/inc/Engine/Admin/RocketInsights/ServiceProvider.php index 98110299d2..3f8a340ff3 100644 --- a/inc/Engine/Admin/RocketInsights/ServiceProvider.php +++ b/inc/Engine/Admin/RocketInsights/ServiceProvider.php @@ -16,6 +16,7 @@ Jobs\Manager as RIManager, Managers\Plan, Queue\Queue as RIQueue, + Recommendations\APIClient as RecommendationsAPIClient, URLLimit\Subscriber as URLLimitSubscriber, Settings\Controller as SettingsController, Settings\Subscriber as SettingsSubscriber, @@ -56,6 +57,7 @@ class ServiceProvider extends AbstractServiceProvider { 'ri_post_listing_subscriber', 'ri_metric_formatter', 'job_manager_queue', + 'ri_recommendations_api_client', 'ri_global_metrics_calculator', 'ri_global_metrics_subscriber', ]; @@ -189,6 +191,11 @@ public function register(): void { 'job_manager_queue', ] ); + + // Recommendations API Client. + $this->getContainer()->add( 'ri_recommendations_api_client', RecommendationsAPIClient::class ) + ->addArgument( 'options' ); + // Subscriber. $this->getContainer()->addShared( 'ri_subscriber', Subscriber::class ) ->addArguments( diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php new file mode 100644 index 0000000000..97b0e1fb5d --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php @@ -0,0 +1,218 @@ + [ + 'shouldReturnRecommendationsSuccessfully' => [ + 'config' => [ + 'params' => [ + 'email' => 'user@example.com', + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + 'global_score' => 65, + 'language' => 'en', + 'limit' => 5, + ], + 'custom_args' => [], + 'api_url' => 'https://saas.wp-rocket.me/', + 'request_uri' => 'https://saas.wp-rocket.me/recommendations/', + 'request_args' => [ + 'method' => 'GET', + 'body' => [ + 'email' => 'user@example.com', + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + 'global_score' => 65, + 'language' => 'en', + 'limit' => 5, + 'credentials' => [ + 'wpr_email' => 'test@example.org', + 'wpr_key' => 'xxxxxx', + ], + ], + 'timeout' => 15, + ], + 'response' => [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => '{"recommendations":[{"option_slug":"delay_js","priority":10,"title":"Enable Delay JavaScript Execution"}],"metadata":{"language":"en","total_recommendations":1}}', + ], + 'is_wp_error' => false, + ], + 'expected' => [ + 'is_error' => false, + 'code' => 200, + ], + ], + + 'shouldReturnWPErrorWhenEmailMissing' => [ + 'config' => [ + 'params' => [ + 'lcp' => 3.2, + ], + 'custom_args' => [], + 'api_url' => '', + 'request_uri' => '', + 'request_args' => [ + 'body' => [ + 'credentials' => [ + 'wpr_email' => 'test@example.org', + 'wpr_key' => 'xxxxxx', + ], + ], + ], + 'response' => null, + 'is_wp_error' => false, + ], + 'expected' => [ + 'is_error' => true, + 'error_code' => 'missing_email', + ], + ], + + 'shouldReturnWPErrorOnAPIFailure' => [ + 'config' => [ + 'params' => [ + 'email' => 'user@example.com', + ], + 'custom_args' => [], + 'api_url' => 'https://saas.wp-rocket.me/', + 'request_uri' => 'https://saas.wp-rocket.me/recommendations/', + 'request_args' => [ + 'method' => 'GET', + 'body' => [ + 'email' => 'user@example.com', + 'credentials' => [ + 'wpr_email' => 'test@example.org', + 'wpr_key' => 'xxxxxx', + ], + ], + 'timeout' => 15, + ], + 'response' => [ + 'response' => [ + 'code' => 500, + 'message' => 'Internal Server Error', + ], + 'body' => 'Internal Server Error', + ], + 'is_wp_error' => false, + ], + 'expected' => [ + 'is_error' => true, + 'error_code' => 'api_request_failed', + ], + ], + + 'shouldReturnWPErrorOnInvalidJSON' => [ + 'config' => [ + 'params' => [ + 'email' => 'user@example.com', + ], + 'custom_args' => [], + 'api_url' => 'https://saas.wp-rocket.me/', + 'request_uri' => 'https://saas.wp-rocket.me/recommendations/', + 'request_args' => [ + 'method' => 'GET', + 'body' => [ + 'email' => 'user@example.com', + 'credentials' => [ + 'wpr_email' => 'test@example.org', + 'wpr_key' => 'xxxxxx', + ], + ], + 'timeout' => 15, + ], + 'response' => [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => 'not valid json{', + ], + 'is_wp_error' => false, + ], + 'expected' => [ + 'is_error' => true, + 'error_code' => 'invalid_json', + ], + ], + + 'shouldReturnWPErrorOnInvalidStructure' => [ + 'config' => [ + 'params' => [ + 'email' => 'user@example.com', + ], + 'custom_args' => [], + 'api_url' => 'https://saas.wp-rocket.me/', + 'request_uri' => 'https://saas.wp-rocket.me/recommendations/', + 'request_args' => [ + 'method' => 'GET', + 'body' => [ + 'email' => 'user@example.com', + 'credentials' => [ + 'wpr_email' => 'test@example.org', + 'wpr_key' => 'xxxxxx', + ], + ], + 'timeout' => 15, + ], + 'response' => [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => '{"some_key":"some_value"}', + ], + 'is_wp_error' => false, + ], + 'expected' => [ + 'is_error' => true, + 'error_code' => 'invalid_structure', + ], + ], + + 'shouldFilterNullAndEmptyParameters' => [ + 'config' => [ + 'params' => [ + 'email' => 'user@example.com', + 'lcp' => null, + 'ttfb' => '', + 'global_score' => 65, + ], + 'custom_args' => [], + 'api_url' => 'https://saas.wp-rocket.me/', + 'request_uri' => 'https://saas.wp-rocket.me/recommendations/', + 'request_args' => [ + 'method' => 'GET', + 'body' => [ + 'email' => 'user@example.com', + 'global_score' => 65, + 'credentials' => [ + 'wpr_email' => 'test@example.org', + 'wpr_key' => 'xxxxxx', + ], + ], + 'timeout' => 15, + ], + 'response' => [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => '{"recommendations":[],"metadata":{"language":"en","total_recommendations":0}}', + ], + 'is_wp_error' => false, + ], + 'expected' => [ + 'is_error' => false, + 'code' => 200, + ], + ], + ], +]; diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php new file mode 100644 index 0000000000..0ad19168ad --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/APIClient/getRecommendations.php @@ -0,0 +1,108 @@ +options = Mockery::mock( Options_Data::class ); + $this->api_client = new APIClient( $this->options ); + $this->set_logger( $this->api_client ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + $this->options->shouldReceive( 'get' )->with( 'consumer_email', '' )->andReturn( 'test@example.org' ); + $this->options->shouldReceive( 'get' )->with( 'consumer_key', '' )->andReturn( 'xxxxxx' ); + + // Early return case: missing email - no mocks needed + if ( empty( $config['params']['email'] ) ) { + $result = $this->api_client->get_recommendations( $config['params'], $config['custom_args'] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( $expected['error_code'], $result->get_error_code() ); + return; + } + + // Mock rocket_get_constant for API URL + Functions\expect( 'rocket_get_constant' ) + ->with( 'WP_ROCKET_SAAS_API_URL', false ) + ->andReturn( $config['api_url'] ); + + // Mock wp_remote_request (used by handle_get) + Functions\expect( 'wp_remote_request' ) + ->with( $config['request_uri'], $config['request_args'] ) + ->andReturn( $config['response'] ); + + // Mock is_wp_error + Functions\expect( 'is_wp_error' ) + ->with( $config['response'] ) + ->andReturn( $config['is_wp_error'] ); + + // Mock WordPress response helper functions only if NOT WP_Error + if ( ! $config['is_wp_error'] ) { + Functions\expect( 'wp_remote_retrieve_response_code' ) + ->with( $config['response'] ) + ->andReturn( $config['response']['response']['code'] ?? 0 ); + + Functions\expect( 'wp_remote_retrieve_response_message' ) + ->with( $config['response'] ) + ->andReturn( $config['response']['response']['message'] ?? '' ); + + Functions\expect( 'wp_remote_retrieve_body' ) + ->with( $config['response'] ) + ->andReturn( $config['response']['body'] ?? '' ); + } + + $result = $this->api_client->get_recommendations( $config['params'], $config['custom_args'] ); + + // Assertions + if ( $expected['is_error'] ) { + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( $expected['error_code'], $result->get_error_code() ); + } else { + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'code', $result ); + $this->assertSame( $expected['code'], $result['code'] ); + $this->assertArrayHasKey( 'data', $result ); + $this->assertArrayHasKey( 'recommendations', $result['data'] ); + $this->assertArrayHasKey( 'metadata', $result['data'] ); + } + } +} From 737ac9c9fbc7abef79eae5555bfa56df45f8f6a6 Mon Sep 17 00:00:00 2001 From: WordPress Fan <146129302+wordpressfan@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:18:22 +0200 Subject: [PATCH 3/6] Recommendations data manager (#8124) --- .../Recommendations/DataManager.php | 368 ++++++++++++++++++ .../Admin/RocketInsights/ServiceProvider.php | 18 +- .../DataManager/fetchRecommendations.php | 126 ++++++ .../DataManager/getRecommendations.php | 84 ++++ .../Recommendations/DataManager/getStatus.php | 63 +++ .../DataManager/fetchRecommendations.php | 131 +++++++ .../DataManager/getRecommendations.php | 72 ++++ .../Recommendations/DataManager/getStatus.php | 65 ++++ 8 files changed, 923 insertions(+), 4 deletions(-) create mode 100644 inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php diff --git a/inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php b/inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php new file mode 100644 index 0000000000..6e92086d54 --- /dev/null +++ b/inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php @@ -0,0 +1,368 @@ +api_client = $api_client; + $this->options = $options; + $this->global_score = $global_score; + } + + /** + * Get cached recommendations. + * + * @return array|false Recommendations data or false if not cached. + */ + public function get_recommendations() { + $data = get_transient( self::TRANSIENT_NAME ); + + if ( false === $data ) { + $this->logger::debug( 'Recommendations: No cached data found' ); + return false; + } + + // Validate structure. + if ( ! isset( $data['status'] ) || ! isset( $data['timestamp'] ) ) { + $this->logger::warning( 'Recommendations: Invalid cached data structure, clearing cache' ); + $this->clear_recommendations(); + return false; + } + + $this->logger::debug( + 'Recommendations: Retrieved from cache', + [ + 'status' => $data['status'], + 'timestamp' => $data['timestamp'], + ] + ); + + return $data; + } + + /** + * Fetch recommendations from API and store in transient. + * + * @return bool True on success, false on failure. + */ + public function fetch_recommendations(): bool { + // Set loading status immediately. + $this->set_loading_status(); + + $this->logger::debug( 'Recommendations: Starting fetch from API' ); + + // Get average metrics from global score data. + $average_metrics = $this->get_average_metrics(); + + // Get enabled WP Rocket options. + $enabled_options = $this->get_enabled_options(); + + // Build API parameters. + $params = [ + 'email' => $this->options->get( 'consumer_email', '' ), + 'language' => $this->get_language(), + 'limit' => 20, // Get all recommendations. + 'version' => rocket_get_constant( 'WP_ROCKET_VERSION' ), + 'enabled_options' => implode( ',', $enabled_options ), + ]; + + if ( ! empty( $average_metrics ) ) { + $params = array_merge( $params, $average_metrics ); + } + + // Add global score if available. + $global_score = $average_metrics['global_score'] ?? null; + if ( empty( $global_score ) ) { + $global_score_data = $this->global_score->get_global_score_data(); + $global_score = $global_score_data['score'] ?? null; + } + if ( ! empty( $global_score ) ) { + $params['global_score'] = $global_score; + } + + /** + * Filters the parameters sent to the Recommendations API. + * + * @param array $params API parameters. + * @return array Modified API parameters. + */ + $params = wpm_apply_filters_typed( 'array', 'rocket_insights_api_recommendations_params', $params ); + + // Call API. + $response = $this->api_client->get_recommendations( $params ); + + // Handle error response. + if ( is_wp_error( $response ) ) { + $this->logger::error( + 'Recommendations: API request failed', + [ + 'code' => $response->get_error_code(), + 'message' => $response->get_error_message(), + 'params' => $params, + ] + ); + + $this->save_recommendations( + [ + 'status' => 'failed', + 'recommendations' => [], + 'metadata' => [], + 'timestamp' => time(), + 'error' => $response->get_error_message(), + ] + ); + + return false; + } + + // Handle success response. + if ( isset( $response['code'] ) && 200 === $response['code'] && isset( $response['data'] ) ) { + $this->logger::info( + 'Recommendations: Successfully fetched from API', + [ + 'total' => count( $response['data']['recommendations'] ?? [] ), + ] + ); + + $this->save_recommendations( + [ + 'status' => 'completed', + 'recommendations' => $response['data']['recommendations'] ?? [], + 'metadata' => $response['data']['metadata'] ?? [], + 'timestamp' => time(), + ] + ); + + return true; + } + + // Unexpected response format. + $this->logger::error( + 'Recommendations: Unexpected API response format', + [ 'response' => $response ] + ); + + $this->save_recommendations( + [ + 'status' => 'failed', + 'recommendations' => [], + 'metadata' => [], + 'timestamp' => time(), + 'error' => 'Unexpected API response format', + ] + ); + + return false; + } + + /** + * Clear cached recommendations. + * + * @return void + */ + public function clear_recommendations(): void { + delete_transient( self::TRANSIENT_NAME ); + + $this->logger::debug( 'Recommendations: Cache cleared' ); + } + + /** + * Get current recommendation status. + * + * @return string Status: 'pending', 'loading', 'completed', 'failed'. + */ + public function get_status(): string { + $data = $this->get_recommendations(); + + if ( false === $data || empty( $data['status'] ) ) { + return 'pending'; + } + + return $data['status']; + } + + /** + * Set loading status in transient. + * + * @return void + */ + private function set_loading_status(): void { + $data = [ + 'status' => 'loading', + 'recommendations' => [], + 'metadata' => [], + 'timestamp' => time(), + ]; + + set_transient( self::TRANSIENT_NAME, $data, self::CACHE_EXPIRATION ); + + $this->logger::debug( 'Recommendations: Status set to loading' ); + } + + /** + * Save recommendations to transient. + * + * @param array $data Recommendations data. + * @return void + */ + private function save_recommendations( array $data ): void { + set_transient( self::TRANSIENT_NAME, $data, self::CACHE_EXPIRATION ); + + $this->logger::debug( + 'Recommendations: Saved to cache', + [ + 'status' => $data['status'], + ] + ); + } + + /** + * Get average metrics from global score data (from Task 1.1). + * + * @return array|null Average metrics or null if not available. + */ + private function get_average_metrics(): ?array { + $global_score_data = $this->global_score->get_global_score_data(); + + if ( empty( $global_score_data['average_metrics'] ) ) { + $this->logger::debug( 'Recommendations: No average metrics available' ); + return null; + } + + return $global_score_data['average_metrics']; + } + + /** + * Get enabled WP Rocket options. + * + * Returns array of option slugs that are currently enabled. + * + * @return array Enabled option slugs. + */ + private function get_enabled_options(): array { + $enabled = []; + + // Map of WP Rocket option keys to recommendation option slugs. + $option_map = [ + 'minify_css' => 'minify_css', + 'minify_js' => 'minify_js', + 'minify_concatenate_css' => 'combine_css', + 'minify_concatenate_js' => 'combine_js', + 'defer_all_js' => 'defer_js', + 'delay_js' => 'delay_js', + 'async_css' => 'async_css', + 'critical_css' => 'critical_css', + 'remove_unused_css' => 'remove_unused_css', + 'lazyload' => 'lazyload_images', + 'lazyload_iframes' => 'lazyload_iframes', + 'lazyload_youtube' => 'lazyload_youtube', + 'image_dimensions' => 'add_missing_image_dimensions', + 'cdn' => 'cdn', + 'do_caching_mobile_files' => 'separate_cache_mobile', + 'cache_logged_user' => 'cache_logged_in_users', + 'cache_webp' => 'cache_webp', + 'manual_preload' => 'preload', + 'sitemap_preload' => 'sitemap_preload', + 'control_heartbeat' => 'control_heartbeat', + 'minify_google_fonts' => 'optimize_google_fonts', + ]; + + foreach ( $option_map as $option_key => $option_slug ) { + $value = $this->options->get( $option_key, false ); + + // Check if option is enabled. + if ( $this->is_option_enabled( $option_key, $value ) ) { + $enabled[] = $option_slug; + } + } + + $this->logger::debug( + 'Recommendations: Enabled options', + [ 'enabled' => $enabled ] + ); + + return $enabled; + } + + /** + * Check if a specific option is enabled. + * + * @param string $option_key Option key. + * @param mixed $value Option value. + * @return bool True if enabled, false otherwise. + */ + private function is_option_enabled( string $option_key, $value ): bool { + // Boolean options. + return ! empty( $value ) && 1 === (int) $value; + } + + /** + * Get current language code. + * + * @return string ISO language code (e.g., 'en', 'fr'). + */ + private function get_language(): string { + // Get WordPress locale (e.g., 'en_US', 'fr_FR'). + $locale = get_locale(); + + // Extract language code (first 2 characters). + $language = substr( $locale, 0, 2 ); + + return $language; + } +} diff --git a/inc/Engine/Admin/RocketInsights/ServiceProvider.php b/inc/Engine/Admin/RocketInsights/ServiceProvider.php index 3f8a340ff3..a2568b1977 100644 --- a/inc/Engine/Admin/RocketInsights/ServiceProvider.php +++ b/inc/Engine/Admin/RocketInsights/ServiceProvider.php @@ -5,8 +5,7 @@ use WP_Rocket\Dependencies\League\Container\Argument\Literal\StringArgument; use WP_Rocket\Dependencies\League\Container\ServiceProvider\AbstractServiceProvider; -use WP_Rocket\Engine\Admin\RocketInsights\{ - Database\Tables\RocketInsights as RITable, +use WP_Rocket\Engine\Admin\RocketInsights\{Database\Tables\RocketInsights as RITable, Database\Queries\RocketInsights as RIQuery, APIHandler\APIClient as RIAPIClient, Context\Context, @@ -17,12 +16,12 @@ Managers\Plan, Queue\Queue as RIQueue, Recommendations\APIClient as RecommendationsAPIClient, + Recommendations\DataManager, URLLimit\Subscriber as URLLimitSubscriber, Settings\Controller as SettingsController, Settings\Subscriber as SettingsSubscriber, PostListing\Subscriber as PostListingSubscriber, - GlobalMetrics\Subscriber as GlobalMetricsSubscriber -}; + GlobalMetrics\Subscriber as GlobalMetricsSubscriber}; use WP_Rocket\Engine\Common\JobManager\Queue\Queue as JobManagerQueue; class ServiceProvider extends AbstractServiceProvider { @@ -60,6 +59,7 @@ class ServiceProvider extends AbstractServiceProvider { 'ri_recommendations_api_client', 'ri_global_metrics_calculator', 'ri_global_metrics_subscriber', + 'ri_recommendations_data_manager', ]; /** @@ -196,6 +196,16 @@ public function register(): void { $this->getContainer()->add( 'ri_recommendations_api_client', RecommendationsAPIClient::class ) ->addArgument( 'options' ); + // Recommendations Data Manager. + $this->getContainer()->add( 'ri_recommendations_data_manager', DataManager::class ) + ->addArguments( + [ + 'ri_recommendations_api_client', + 'options', + 'ri_global_score', + ] + ); + // Subscriber. $this->getContainer()->addShared( 'ri_subscriber', Subscriber::class ) ->addArguments( diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php new file mode 100644 index 0000000000..560b89e6bf --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php @@ -0,0 +1,126 @@ + [ + 'shouldFetchSuccessfully' => [ + 'config' => [ + 'version' => '3.20.5', + 'locale' => 'en_US', + 'options' => [ + 'consumer_email' => 'user@example.com', + 'delay_js' => 1, + 'lazyload' => 1, + ], + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + ], + ], + 'expected_params' => [ + 'email' => 'user@example.com', + ], + 'api_response' => [ + 'code' => 200, + 'data' => [ + 'recommendations' => [ + [ + 'option_slug' => 'minify_css', + 'title' => 'Enable CSS Minification', + ], + ], + 'metadata' => [ + 'language' => 'en', + 'total_recommendations' => 1, + ], + ], + ], + 'transient_set_times' => 2, // loading + completed + ], + 'expected' => [ + 'result' => true, + 'final_status' => 'completed', + ], + ], + + 'shouldHandleWPError' => [ + 'config' => [ + 'version' => '3.20.5', + 'locale' => 'en_US', + 'options' => [ + 'consumer_email' => 'user@example.com', + ], + 'global_score_data' => [ + 'score' => 75, + ], + 'expected_params' => [ + 'email' => 'user@example.com', + ], + 'api_response' => new WP_Error( 'api_error', 'API request failed' ), + 'transient_set_times' => 2, // loading + failed + ], + 'expected' => [ + 'result' => false, + 'final_status' => 'failed', + ], + ], + + 'shouldHandleUnexpectedResponseFormat' => [ + 'config' => [ + 'version' => '3.20.5', + 'locale' => 'en_US', + 'options' => [ + 'consumer_email' => 'user@example.com', + ], + 'global_score_data' => [ + 'score' => 75, + ], + 'expected_params' => [ + 'email' => 'user@example.com', + ], + 'api_response' => [ + 'some_key' => 'some_value', // Missing 'code' and 'data' + ], + 'transient_set_times' => 2, // loading + failed + ], + 'expected' => [ + 'result' => false, + 'final_status' => 'failed', + ], + ], + + 'shouldHandleNoAverageMetrics' => [ + 'config' => [ + 'version' => '3.20.5', + 'locale' => 'fr_FR', + 'options' => [ + 'consumer_email' => 'user@example.com', + ], + 'global_score_data' => [ + 'score' => 80, + // No average_metrics + ], + 'expected_params' => [ + 'email' => 'user@example.com', + ], + 'api_response' => [ + 'code' => 200, + 'data' => [ + 'recommendations' => [], + 'metadata' => [ + 'language' => 'fr', + 'total_recommendations' => 0, + ], + ], + ], + 'transient_set_times' => 2, // loading + completed + ], + 'expected' => [ + 'result' => true, + 'final_status' => 'completed', + ], + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php new file mode 100644 index 0000000000..61c0b5f953 --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php @@ -0,0 +1,84 @@ + [ + 'shouldReturnCachedData' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'completed', + 'recommendations' => [ + [ + 'option_slug' => 'delay_js', + 'title' => 'Enable Delay JS', + ], + ], + 'metadata' => [ + 'language' => 'en', + ], + 'timestamp' => 1234567890, + ], + 'should_clear_cache' => false, + ], + 'expected' => [ + 'is_false' => false, + 'status' => 'completed', + ], + ], + + 'shouldReturnFalseWhenNoCache' => [ + 'config' => [ + 'transient_data' => false, + 'should_clear_cache' => false, + ], + 'expected' => [ + 'is_false' => true, + ], + ], + + 'shouldReturnFalseWhenInvalidStructure' => [ + 'config' => [ + 'transient_data' => [ + 'recommendations' => [], + // Missing 'status' and 'timestamp' + ], + 'should_clear_cache' => true, + ], + 'expected' => [ + 'is_false' => true, + ], + ], + + 'shouldReturnLoadingStatus' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'loading', + 'recommendations' => [], + 'metadata' => [], + 'timestamp' => 1234567890, + ], + 'should_clear_cache' => false, + ], + 'expected' => [ + 'is_false' => false, + 'status' => 'loading', + ], + ], + + 'shouldReturnFailedStatus' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'failed', + 'recommendations' => [], + 'metadata' => [], + 'timestamp' => 1234567890, + 'error' => 'API Error', + ], + 'should_clear_cache' => false, + ], + 'expected' => [ + 'is_false' => false, + 'status' => 'failed', + ], + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php new file mode 100644 index 0000000000..1cf71dd205 --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php @@ -0,0 +1,63 @@ + [ + 'shouldReturnPendingWhenNoCache' => [ + 'config' => [ + 'transient_data' => false, + ], + 'expected' => 'pending', + ], + + 'shouldReturnCompletedStatus' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'completed', + 'recommendations' => [ + [ + 'option_slug' => 'delay_js', + ], + ], + 'metadata' => [], + 'timestamp' => 1234567890, + ], + ], + 'expected' => 'completed', + ], + + 'shouldReturnLoadingStatus' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'loading', + 'recommendations' => [], + 'metadata' => [], + 'timestamp' => 1234567890, + ], + ], + 'expected' => 'loading', + ], + + 'shouldReturnFailedStatus' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'failed', + 'recommendations' => [], + 'metadata' => [], + 'timestamp' => 1234567890, + 'error' => 'API Error', + ], + ], + 'expected' => 'failed', + ], + + 'shouldReturnPendingWhenStatusMissing' => [ + 'config' => [ + 'transient_data' => [ + 'recommendations' => [], + 'timestamp' => 1234567890, + ], + ], + 'expected' => 'pending', + ], + ], +]; diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php new file mode 100644 index 0000000000..7668409c05 --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/fetchRecommendations.php @@ -0,0 +1,131 @@ +api_client = Mockery::mock( APIClient::class ); + $this->options = Mockery::mock( Options_Data::class ); + $this->global_score = Mockery::mock( GlobalScore::class ); + + $this->data_manager = new DataManager( $this->api_client, $this->options, $this->global_score ); + $this->set_logger( $this->data_manager ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + // Mock constants. + Functions\expect( 'rocket_get_constant' ) + ->with( 'WP_ROCKET_VERSION' ) + ->andReturn( $config['version'] ); + + // Mock get_locale. + Functions\expect( 'get_locale' ) + ->andReturn( $config['locale'] ); + + // Mock Options_Data::get. + $this->options->shouldReceive( 'get' ) + ->andReturnUsing( + function ( $key, $default ) use ( $config ) { + return $config['options'][ $key ] ?? $default; + } + ); + + // Mock GlobalScore::get_global_score_data. + $this->global_score->shouldReceive( 'get_global_score_data' ) + ->andReturn( $config['global_score_data'] ); + + // Mock is_wp_error. + Functions\expect( 'is_wp_error' ) + ->with( $config['api_response'] ) + ->andReturn( $config['api_response'] instanceof \WP_Error ); + + // Mock API Client response. + $this->api_client->shouldReceive( 'get_recommendations' ) + ->once() + ->with( + Mockery::on( + function ( $params ) use ( $config ) { + return $params['email'] === $config['expected_params']['email']; + } + ) + ) + ->andReturn( $config['api_response'] ); + + // Mock set_transient - verify it's called with correct status but don't check timestamp. + Functions\expect( 'set_transient' ) + ->with( + 'wpr_ri_recommendations', + Mockery::on( + function ( $data ) use ( $expected ) { + if ( ! isset( $data['status'] ) ) { + return false; + } + // Verify status matches expected final status. + return in_array( $data['status'], [ 'loading', $expected['final_status'] ], true ); + } + ), + 86400 // DAY_IN_SECONDS + ) + ->times( $config['transient_set_times'] ); + + Functions\expect( 'delete_transient' )->zeroOrMoreTimes(); + + $result = $this->data_manager->fetch_recommendations(); + + $this->assertSame( $expected['result'], $result ); + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php new file mode 100644 index 0000000000..7f3c4b4102 --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getRecommendations.php @@ -0,0 +1,72 @@ +data_manager = new DataManager( $api_client, $options, $global_score ); + $this->set_logger( $this->data_manager ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + // Mock get_transient + Functions\expect( 'get_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->andReturn( $config['transient_data'] ); + + // Mock delete_transient if invalid structure + if ( $config['should_clear_cache'] ) { + Functions\expect( 'delete_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->once(); + } + + $result = $this->data_manager->get_recommendations(); + + if ( $expected['is_false'] ) { + $this->assertFalse( $result ); + } else { + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'status', $result ); + $this->assertArrayHasKey( 'timestamp', $result ); + $this->assertSame( $expected['status'], $result['status'] ); + } + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php new file mode 100644 index 0000000000..6b73b585b5 --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php @@ -0,0 +1,65 @@ +data_manager = new DataManager( $api_client, $options, $global_score ); + $this->set_logger( $this->data_manager ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + // Mock get_transient + Functions\expect( 'get_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->andReturn( $config['transient_data'] ); + + // Mock delete_transient if needed for invalid structure + if ( false !== $config['transient_data'] && ( ! isset( $config['transient_data']['status'] ) || ! isset( $config['transient_data']['timestamp'] ) ) ) { + Functions\expect( 'delete_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->once(); + } + + $result = $this->data_manager->get_status(); + + $this->assertSame( $expected, $result ); + } +} From a1c3818dc557e6a21e6833f936729eb2b833e0e3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:47:07 +0200 Subject: [PATCH 4/6] Task 2.1: Implement Recommendation Fetching Logic (#8131) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wordpressfan <146129302+wordpressfan@users.noreply.github.com> Co-authored-by: WordpressFan --- .../Admin/RocketInsights/GlobalScore.php | 39 ++++-- .../Recommendations/DataManager.php | 90 +++++++++++++- .../Recommendations/FetchSubscriber.php | 94 +++++++++++++++ .../Admin/RocketInsights/ServiceProvider.php | 8 +- inc/Plugin.php | 1 + .../DataManager/extendTransient.php | 40 +++++++ .../Recommendations/DataManager/getStatus.php | 6 +- .../DataManager/hasRequiredMetrics.php | 110 +++++++++++++++++ .../shouldFetchRecommendations.php | 112 ++++++++++++++++++ .../FetchSubscriber/handleStatusChange.php | 102 ++++++++++++++++ .../DataManager/extendTransient.php | 69 +++++++++++ .../DataManager/hasRequiredMetrics.php | 62 ++++++++++ .../shouldFetchRecommendations.php | 96 +++++++++++++++ .../FetchSubscriber/handleStatusChange.php | 84 +++++++++++++ 14 files changed, 896 insertions(+), 17 deletions(-) create mode 100644 inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php create mode 100644 tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php create mode 100644 tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php diff --git a/inc/Engine/Admin/RocketInsights/GlobalScore.php b/inc/Engine/Admin/RocketInsights/GlobalScore.php index bbb0466979..5adb9da021 100644 --- a/inc/Engine/Admin/RocketInsights/GlobalScore.php +++ b/inc/Engine/Admin/RocketInsights/GlobalScore.php @@ -79,11 +79,13 @@ private function get_cached_data() { * @return array Calculated data containing score, pages_num, and status. */ private function calculate_and_cache_data(): array { + $status = $this->calculate_current_status(); + $data = [ 'score' => $this->calculate_global_score(), 'pages_num' => $this->calculate_pages_number(), - 'status' => $this->calculate_current_status(), - 'is_running' => $this->calculate_current_status() === 'in-progress', + 'status' => $status, + 'is_running' => 'in-progress' === $status, ]; /** @@ -102,10 +104,27 @@ private function calculate_and_cache_data(): array { * } * @return array Filtered global score data. */ - $data = wpm_apply_filters_typed( 'array', 'rocket_insights_global_score_data', $data ); + $data = wpm_apply_filters_typed( 'array', 'rocket_insights_global_score_data', $data ); + + // Get previous status before overwriting the transient. + $previous_data = get_transient( self::TRANSIENT_NAME ); + $previous_status = is_array( $previous_data ) && isset( $previous_data['status'] ) + ? $previous_data['status'] + : null; set_transient( self::TRANSIENT_NAME, $data, self::CACHE_EXPIRATION ); + // Fire event if status changed. + if ( null !== $previous_status && $previous_status !== $status ) { + /** + * Fires when global score status changes. + * + * @param string $new_status New status. + * @param string $previous_status Previous status. + */ + do_action( 'rocket_insights_global_score_status_changed', $status, $previous_status ); + } + return $data; } @@ -121,7 +140,7 @@ private function calculate_global_score(): int { 'status' => 'completed', 'score__not_in' => [ 0 ], ] - ); + ); if ( empty( $scores ) ) { return 0; @@ -161,7 +180,7 @@ private function calculate_current_status(): string { 'count' => true, 'status__in' => [ 'to-submit', 'pending', 'in-progress' ], ] - ); + ); if ( (int) $in_progress_count > 0 ) { return 'in-progress'; @@ -169,11 +188,11 @@ private function calculate_current_status(): string { // Check if any URLs are blurred. $blurred_count = $this->query->query( - [ - 'count' => true, - 'status__in' => [ 'completed' ], - 'is_blurred' => 1, - ] + [ + 'count' => true, + 'status__in' => [ 'completed' ], + 'is_blurred' => 1, + ] ); if ( (int) $blurred_count > 0 ) { diff --git a/inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php b/inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php index 6e92086d54..f28679f633 100644 --- a/inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php +++ b/inc/Engine/Admin/RocketInsights/Recommendations/DataManager.php @@ -185,6 +185,7 @@ public function fetch_recommendations(): bool { 'recommendations' => $response['data']['recommendations'] ?? [], 'metadata' => $response['data']['metadata'] ?? [], 'timestamp' => time(), + 'metrics_hash' => $this->calculate_metrics_hash(), ] ); @@ -224,18 +225,83 @@ public function clear_recommendations(): void { /** * Get current recommendation status. * - * @return string Status: 'pending', 'loading', 'completed', 'failed'. + * @return string Status: 'expired', 'pending', 'loading', 'completed', 'failed'. */ public function get_status(): string { $data = $this->get_recommendations(); - if ( false === $data || empty( $data['status'] ) ) { - return 'pending'; + if ( false === $data ) { + return 'expired'; } return $data['status']; } + /** + * Check if required metrics are available for recommendations. + * + * @return bool True if metrics exist, false otherwise. + */ + public function has_required_metrics(): bool { + $average_metrics = $this->get_average_metrics(); + + if ( null === $average_metrics ) { + return false; + } + + // Verify core metrics exist. + $required = [ 'lcp', 'ttfb', 'cls', 'tbt' ]; + foreach ( $required as $metric ) { + if ( ! isset( $average_metrics[ $metric ] ) ) { + return false; + } + } + + return true; + } + + /** + * Determine if recommendations should be fetched. + * + * Compares current metrics hash with cached hash. + * + * @return bool True if should fetch, false if cache is valid. + */ + public function should_fetch_recommendations(): bool { + $recommendations = $this->get_recommendations(); + + // No cache = should fetch. + if ( false === $recommendations ) { + return true; + } + + // Calculate current hash. + $current_hash = $this->calculate_metrics_hash(); + $cached_hash = $recommendations['metrics_hash'] ?? ''; + + // Fetch if hash changed. + return $current_hash !== $cached_hash; + } + + /** + * Extend transient expiration without fetching. + * + * Used when data hasn't changed but transient is expiring. + * + * @return void + */ + public function extend_transient(): void { + $data = $this->get_recommendations(); + + if ( false === $data ) { + return; + } + + set_transient( self::TRANSIENT_NAME, $data, self::CACHE_EXPIRATION ); + + $this->logger::debug( 'Recommendations: Transient extended (no changes detected)' ); + } + /** * Set loading status in transient. * @@ -271,6 +337,24 @@ private function save_recommendations( array $data ): void { ); } + /** + * Calculate hash of current metrics and settings. + * + * @return string MD5 hash. + */ + private function calculate_metrics_hash(): string { + $global_score_data = $this->global_score->get_global_score_data(); + $enabled_options = $this->get_enabled_options(); + + $data = [ + 'score' => $global_score_data['score'] ?? 0, + 'average_metrics' => $global_score_data['average_metrics'] ?? [], + 'enabled_options' => $enabled_options, + ]; + + return md5( (string) wp_json_encode( $data ) ); + } + /** * Get average metrics from global score data (from Task 1.1). * diff --git a/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber.php b/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber.php new file mode 100644 index 0000000000..1d7fa302b8 --- /dev/null +++ b/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber.php @@ -0,0 +1,94 @@ +data_manager = $data_manager; + } + + /** + * {@inheritdoc} + */ + public static function get_subscribed_events(): array { + return [ + 'rocket_insights_global_score_status_changed' => 'handle_status_change', + ]; + } + + /** + * Handle global score status changes. + * + * @param string $new_status New status. + * @return void + */ + public function handle_status_change( string $new_status ): void { + switch ( $new_status ) { + case 'in-progress': + // Clear recommendations when tests start. + $this->data_manager->clear_recommendations(); + break; + + case 'complete': + // Maybe fetch recommendations when tests complete. + $this->maybe_fetch_recommendations(); + break; + + default: + // No action for other statuses. + break; + } + } + + /** + * Maybe fetch recommendations with validation. + * + * Checks: + * 1. Average metrics are available + * 2. Hash has changed (data is different) + * 3. Not already loading + * + * @return void + */ + private function maybe_fetch_recommendations(): void { + // Bail if already loading. + if ( 'loading' === $this->data_manager->get_status() ) { + return; + } + + // Bail if metrics not ready. + if ( ! $this->data_manager->has_required_metrics() ) { + return; + } + + // Bail if data hasn't changed. + if ( ! $this->data_manager->should_fetch_recommendations() ) { + $this->data_manager->extend_transient(); // Extend for another 24h. + return; + } + + // Fetch new recommendations. + $this->data_manager->fetch_recommendations(); + } +} diff --git a/inc/Engine/Admin/RocketInsights/ServiceProvider.php b/inc/Engine/Admin/RocketInsights/ServiceProvider.php index a2568b1977..66502ff7a0 100644 --- a/inc/Engine/Admin/RocketInsights/ServiceProvider.php +++ b/inc/Engine/Admin/RocketInsights/ServiceProvider.php @@ -17,6 +17,7 @@ Queue\Queue as RIQueue, Recommendations\APIClient as RecommendationsAPIClient, Recommendations\DataManager, + Recommendations\FetchSubscriber as RecommendationsFetchSubscriber, URLLimit\Subscriber as URLLimitSubscriber, Settings\Controller as SettingsController, Settings\Subscriber as SettingsSubscriber, @@ -60,6 +61,7 @@ class ServiceProvider extends AbstractServiceProvider { 'ri_global_metrics_calculator', 'ri_global_metrics_subscriber', 'ri_recommendations_data_manager', + 'ri_recommendations_fetch_subscriber', ]; /** @@ -74,7 +76,7 @@ public function provides( string $id ): bool { } /** - * Registers the classes in the container + * Registers the classes in the container. * * @return void */ @@ -206,6 +208,10 @@ public function register(): void { ] ); + // Recommendations Fetch Subscriber. + $this->getContainer()->addShared( 'ri_recommendations_fetch_subscriber', RecommendationsFetchSubscriber::class ) + ->addArgument( 'ri_recommendations_data_manager' ); + // Subscriber. $this->getContainer()->addShared( 'ri_subscriber', Subscriber::class ) ->addArguments( diff --git a/inc/Plugin.php b/inc/Plugin.php index e824cf4ab0..0d4267bd43 100644 --- a/inc/Plugin.php +++ b/inc/Plugin.php @@ -414,6 +414,7 @@ private function init_common_subscribers() { 'ri_url_limit_subscriber', 'ri_post_listing_subscriber', 'ri_global_metrics_subscriber', + 'ri_recommendations_fetch_subscriber', 'post_subscriber', 'tracking_subscriber', 'logger_subscriber', diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php new file mode 100644 index 0000000000..0a2c46ab5a --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php @@ -0,0 +1,40 @@ + [ + 'shouldDoNothingWhenNoCache' => [ + 'config' => [ + 'transient_data' => false, + ], + 'expected' => [ + 'extends_transient' => false, + ], + ], + + 'shouldExtendTransientWhenCacheExists' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'completed', + 'timestamp' => 1234567890, + 'recommendations' => [], + 'metadata' => [], + 'metrics_hash' => 'abc123', + ], + ], + 'expected' => [ + 'extends_transient' => true, + ], + ], + + 'shouldDoNothingWhenCacheHasInvalidStructure' => [ + 'config' => [ + 'transient_data' => [ + 'recommendations' => [], + // Missing 'status' and 'timestamp' + ], + ], + 'expected' => [ + 'extends_transient' => false, + ], + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php index 1cf71dd205..cdec329446 100644 --- a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/getStatus.php @@ -6,7 +6,7 @@ 'config' => [ 'transient_data' => false, ], - 'expected' => 'pending', + 'expected' => 'expired', ], 'shouldReturnCompletedStatus' => [ @@ -50,14 +50,14 @@ 'expected' => 'failed', ], - 'shouldReturnPendingWhenStatusMissing' => [ + 'shouldReturnExpiredWhenStatusMissing' => [ 'config' => [ 'transient_data' => [ 'recommendations' => [], 'timestamp' => 1234567890, ], ], - 'expected' => 'pending', + 'expected' => 'expired', ], ], ]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php new file mode 100644 index 0000000000..fd596c437f --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php @@ -0,0 +1,110 @@ + [ + 'shouldReturnTrueWhenAllRequiredMetricsPresent' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + ], + ], + ], + 'expected' => true, + ], + + 'shouldReturnFalseWhenNoAverageMetrics' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + ], + ], + 'expected' => false, + ], + + 'shouldReturnFalseWhenAverageMetricsIsEmpty' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [], + ], + ], + 'expected' => false, + ], + + 'shouldReturnFalseWhenLcpIsMissing' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + ], + ], + ], + 'expected' => false, + ], + + 'shouldReturnFalseWhenTtfbIsMissing' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'cls' => 0.15, + 'tbt' => 350, + ], + ], + ], + 'expected' => false, + ], + + 'shouldReturnFalseWhenClsIsMissing' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'tbt' => 350, + ], + ], + ], + 'expected' => false, + ], + + 'shouldReturnFalseWhenTbtIsMissing' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + ], + ], + ], + 'expected' => false, + ], + + 'shouldReturnTrueWhenExtraMetricsPresent' => [ + 'config' => [ + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + 'global_score' => 75, + ], + ], + ], + 'expected' => true, + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php new file mode 100644 index 0000000000..24341dc8ba --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php @@ -0,0 +1,112 @@ + [ + 'shouldReturnTrueWhenNoCache' => [ + 'config' => [ + 'transient_data' => false, + 'calculate_hash' => false, + 'global_score_data' => [], + 'options' => [], + ], + 'expected' => true, + ], + + 'shouldReturnTrueWhenCacheHasInvalidStructure' => [ + 'config' => [ + 'transient_data' => [ + 'recommendations' => [], + // Missing 'status' and 'timestamp' + ], + 'calculate_hash' => false, + 'global_score_data' => [], + 'options' => [], + ], + 'expected' => true, + ], + + 'shouldReturnTrueWhenHashChanged' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'completed', + 'timestamp' => 1234567890, + 'metrics_hash' => 'old_hash_value', + ], + 'calculate_hash' => true, + 'global_score_data' => [ + 'score' => 85, + 'average_metrics' => [ + 'lcp' => 2.5, + 'ttfb' => 0.5, + 'cls' => 0.1, + 'tbt' => 200, + ], + ], + 'options' => [ + 'delay_js' => 1, + 'lazyload' => 1, + ], + ], + 'expected' => true, + ], + + 'shouldReturnFalseWhenHashUnchanged' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'completed', + 'timestamp' => 1234567890, + 'metrics_hash' => md5( + (string) json_encode( + [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + ], + 'enabled_options' => [ 'delay_js', 'lazyload_images' ], + ] + ) + ), + ], + 'calculate_hash' => true, + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + ], + ], + 'options' => [ + 'delay_js' => 1, + 'lazyload' => 1, + ], + ], + 'expected' => false, + ], + + 'shouldReturnTrueWhenNoMetricsHashInCache' => [ + 'config' => [ + 'transient_data' => [ + 'status' => 'completed', + 'timestamp' => 1234567890, + // No metrics_hash key + ], + 'calculate_hash' => true, + 'global_score_data' => [ + 'score' => 75, + 'average_metrics' => [ + 'lcp' => 3.2, + 'ttfb' => 0.8, + 'cls' => 0.15, + 'tbt' => 350, + ], + ], + 'options' => [], + ], + 'expected' => true, + ], + ], +]; diff --git a/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php new file mode 100644 index 0000000000..2f91926728 --- /dev/null +++ b/tests/Fixtures/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php @@ -0,0 +1,102 @@ + [ + 'shouldClearRecommendationsWhenStatusIsInProgress' => [ + 'config' => [ + 'new_status' => 'in-progress', + 'previous_status' => 'complete', + 'status' => 'completed', + 'has_required_metrics' => true, + 'should_fetch' => true, + ], + 'expected' => [ + 'clears_recommendations' => true, + 'fetches_recommendations' => false, + ], + ], + + 'shouldFetchWhenStatusIsCompletedAndDataChanged' => [ + 'config' => [ + 'new_status' => 'complete', + 'previous_status' => 'in-progress', + 'status' => 'pending', + 'has_required_metrics' => true, + 'should_fetch' => true, + ], + 'expected' => [ + 'clears_recommendations' => false, + 'fetches_recommendations' => true, + ], + ], + + 'shouldExtendTransientWhenStatusIsCompletedAndDataUnchanged' => [ + 'config' => [ + 'new_status' => 'complete', + 'previous_status' => 'in-progress', + 'status' => 'pending', + 'has_required_metrics' => true, + 'should_fetch' => false, + ], + 'expected' => [ + 'clears_recommendations' => false, + 'fetches_recommendations' => true, + ], + ], + + 'shouldSkipFetchWhenStatusIsCompletedAndAlreadyLoading' => [ + 'config' => [ + 'new_status' => 'complete', + 'previous_status' => 'in-progress', + 'status' => 'loading', + 'has_required_metrics' => true, + 'should_fetch' => true, + ], + 'expected' => [ + 'clears_recommendations' => false, + 'fetches_recommendations' => true, + ], + ], + + 'shouldSkipFetchWhenStatusIsCompletedAndMetricsNotReady' => [ + 'config' => [ + 'new_status' => 'complete', + 'previous_status' => 'in-progress', + 'status' => 'pending', + 'has_required_metrics' => false, + 'should_fetch' => true, + ], + 'expected' => [ + 'clears_recommendations' => false, + 'fetches_recommendations' => true, + ], + ], + + 'shouldDoNothingWhenStatusIsNoUrl' => [ + 'config' => [ + 'new_status' => 'no-url', + 'previous_status' => 'in-progress', + 'status' => 'pending', + 'has_required_metrics' => true, + 'should_fetch' => true, + ], + 'expected' => [ + 'clears_recommendations' => false, + 'fetches_recommendations' => false, + ], + ], + + 'shouldDoNothingWhenStatusIsFailed' => [ + 'config' => [ + 'new_status' => 'failed', + 'previous_status' => 'in-progress', + 'status' => 'pending', + 'has_required_metrics' => true, + 'should_fetch' => true, + ], + 'expected' => [ + 'clears_recommendations' => false, + 'fetches_recommendations' => false, + ], + ], + ], +]; diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php new file mode 100644 index 0000000000..c4a797f2fd --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/extendTransient.php @@ -0,0 +1,69 @@ +data_manager = new DataManager( $api_client, $options, $global_score ); + $this->set_logger( $this->data_manager ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + Functions\expect( 'get_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->andReturn( $config['transient_data'] ); + + if ( false !== $config['transient_data'] && ( ! isset( $config['transient_data']['status'] ) || ! isset( $config['transient_data']['timestamp'] ) ) ) { + Functions\expect( 'delete_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->once(); + } + + if ( $expected['extends_transient'] ) { + Functions\expect( 'set_transient' ) + ->with( 'wpr_ri_recommendations', Mockery::type( 'array' ), 86400 ) + ->once(); + } else { + Functions\expect( 'set_transient' )->never(); + } + + $this->data_manager->extend_transient(); + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php new file mode 100644 index 0000000000..026ed7cc4d --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/hasRequiredMetrics.php @@ -0,0 +1,62 @@ +global_score = Mockery::mock( GlobalScore::class ); + + $this->data_manager = new DataManager( $api_client, $options, $this->global_score ); + $this->set_logger( $this->data_manager ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + $this->global_score->shouldReceive( 'get_global_score_data' ) + ->andReturn( $config['global_score_data'] ); + + $result = $this->data_manager->has_required_metrics(); + + $this->assertSame( $expected, $result ); + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php new file mode 100644 index 0000000000..0af84f636d --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/DataManager/shouldFetchRecommendations.php @@ -0,0 +1,96 @@ +options = Mockery::mock( Options_Data::class ); + $this->global_score = Mockery::mock( GlobalScore::class ); + + $this->data_manager = new DataManager( $api_client, $this->options, $this->global_score ); + $this->set_logger( $this->data_manager ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + Functions\expect( 'get_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->andReturn( $config['transient_data'] ); + + if ( false !== $config['transient_data'] && ( ! isset( $config['transient_data']['status'] ) || ! isset( $config['transient_data']['timestamp'] ) ) ) { + Functions\expect( 'delete_transient' ) + ->with( 'wpr_ri_recommendations' ) + ->once(); + } + + if ( $config['calculate_hash'] ) { + $this->global_score->shouldReceive( 'get_global_score_data' ) + ->andReturn( $config['global_score_data'] ); + + $this->options->shouldReceive( 'get' ) + ->andReturnUsing( + function ( $key, $default ) use ( $config ) { + return $config['options'][ $key ] ?? $default; + } + ); + + Functions\expect( 'wp_json_encode' ) + ->andReturnUsing( + function ( $data ) { + return json_encode( $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + } + ); + } + + $result = $this->data_manager->should_fetch_recommendations(); + + $this->assertSame( $expected, $result ); + } +} diff --git a/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php new file mode 100644 index 0000000000..fc4f244a7a --- /dev/null +++ b/tests/Unit/inc/Engine/Admin/RocketInsights/Recommendations/FetchSubscriber/handleStatusChange.php @@ -0,0 +1,84 @@ +data_manager = Mockery::mock( DataManager::class ); + $this->subscriber = new FetchSubscriber( $this->data_manager ); + } + + /** + * @dataProvider configTestData + */ + public function testShouldReturnAsExpected( $config, $expected ) { + if ( $expected['clears_recommendations'] ) { + $this->data_manager->shouldReceive( 'clear_recommendations' )->once(); + } else { + $this->data_manager->shouldReceive( 'clear_recommendations' )->never(); + } + + if ( $expected['fetches_recommendations'] ) { + $this->data_manager->shouldReceive( 'get_status' ) + ->once() + ->andReturn( $config['status'] ); + + if ( 'loading' !== $config['status'] ) { + $this->data_manager->shouldReceive( 'has_required_metrics' ) + ->once() + ->andReturn( $config['has_required_metrics'] ); + + if ( $config['has_required_metrics'] ) { + $this->data_manager->shouldReceive( 'should_fetch_recommendations' ) + ->once() + ->andReturn( $config['should_fetch'] ); + + if ( $config['should_fetch'] ) { + $this->data_manager->shouldReceive( 'fetch_recommendations' )->once(); + } else { + $this->data_manager->shouldReceive( 'extend_transient' )->once(); + } + } + } + } else { + $this->data_manager->shouldReceive( 'get_status' )->never(); + $this->data_manager->shouldReceive( 'has_required_metrics' )->never(); + $this->data_manager->shouldReceive( 'should_fetch_recommendations' )->never(); + $this->data_manager->shouldReceive( 'fetch_recommendations' )->never(); + $this->data_manager->shouldReceive( 'extend_transient' )->never(); + } + + $this->subscriber->handle_status_change( $config['new_status'] ); + } +} From c1a5c871064dda07fb62ee636b3ac092550770d0 Mon Sep 17 00:00:00 2001 From: WordpressFan Date: Thu, 12 Mar 2026 09:49:03 +0200 Subject: [PATCH 5/6] fix bug --- inc/Engine/Admin/RocketInsights/GlobalScore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/Engine/Admin/RocketInsights/GlobalScore.php b/inc/Engine/Admin/RocketInsights/GlobalScore.php index 5adb9da021..c63c3ca887 100644 --- a/inc/Engine/Admin/RocketInsights/GlobalScore.php +++ b/inc/Engine/Admin/RocketInsights/GlobalScore.php @@ -115,7 +115,7 @@ private function calculate_and_cache_data(): array { set_transient( self::TRANSIENT_NAME, $data, self::CACHE_EXPIRATION ); // Fire event if status changed. - if ( null !== $previous_status && $previous_status !== $status ) { + if ( $previous_status !== $status ) { /** * Fires when global score status changes. * From d6b0265b751d99c8327521dcc087eeb124de9f33 Mon Sep 17 00:00:00 2001 From: WordpressFan Date: Thu, 12 Mar 2026 10:15:49 +0200 Subject: [PATCH 6/6] set average metrics to null when global score is in-progress --- inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php b/inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php index 679637b7b0..8198e31925 100644 --- a/inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php +++ b/inc/Engine/Admin/RocketInsights/GlobalMetrics/Subscriber.php @@ -42,7 +42,8 @@ public static function get_subscribed_events(): array { */ public function add_average_metrics( array $data ): array { // Only add metrics if there are completed tests. - if ( ! $this->calculator->has_completed_tests() ) { + if ( 'in-progress' === $data['status'] ) { + $data['average_metrics'] = null; return $data; }