diff --git a/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.js b/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.js index 85a5e456bdc..8fcff7fb758 100644 --- a/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.js +++ b/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.js @@ -25,6 +25,7 @@ import useViewOnly from '@/js/hooks/useViewOnly'; import PermissionsErrorNotice from '@/js/components/email-reporting/notices/errors/PermissionsErrorNotice'; import ReportErrorNotice from '@/js/components/email-reporting/notices/errors/ReportErrorNotice'; import SendingErrorNotice from '@/js/components/email-reporting/notices/errors/SendingErrorNotice'; +import CronSchedulerErrorNotice from '@/js/components/email-reporting/notices/errors/CronSchedulerErrorNotice'; import ServerErrorNotice from '@/js/components/email-reporting/notices/errors/ServerErrorNotice'; export default function EmailReportingErrorNotices() { @@ -66,6 +67,8 @@ export default function EmailReportingErrorNotices() { ); case 'sending_error': return ; + case 'cron_scheduler_error': + return ; default: return ; } diff --git a/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.test.js b/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.test.js index 68f69d1171f..324930e14c0 100644 --- a/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.test.js +++ b/assets/js/components/email-reporting/notices/EmailReportingErrorNotices.test.js @@ -172,6 +172,39 @@ describe( 'EmailReportingErrorNotices', () => { ).toBeInTheDocument(); } ); + it( 'should render the cron scheduler error notice when there is a cron_scheduler_error category ID', () => { + registry.dispatch( CORE_SITE ).receiveGetEmailReportingSettings( { + enabled: true, + } ); + registry.dispatch( CORE_SITE ).receiveGetEmailReportingErrors( { + errors: { + cron_scheduler_error: [ 'Cron issue.' ], + }, + error_data: { + cron_scheduler_error: { + category_id: 'cron_scheduler_error', + }, + }, + } ); + + const { container, getByText } = render( + , + { + registry, + } + ); + + expect( container ).not.toBeEmptyDOMElement(); + expect( + getByText( 'Email reports are failing to send' ) + ).toBeInTheDocument(); + expect( + getByText( + 'We were unable to deliver your report, likely due to a WP-Cron configuration error in your WordPress site’s system settings. To fix this, contact your administrator or get help. Report delivery will automatically resume once the issue is resolved.' + ) + ).toBeInTheDocument(); + } ); + it( 'should not render when email reporting is not enabled', () => { registry.dispatch( CORE_SITE ).receiveGetEmailReportingSettings( { enabled: false, diff --git a/assets/js/components/email-reporting/notices/errors/CronSchedulerErrorNotice.js b/assets/js/components/email-reporting/notices/errors/CronSchedulerErrorNotice.js new file mode 100644 index 00000000000..d9377291810 --- /dev/null +++ b/assets/js/components/email-reporting/notices/errors/CronSchedulerErrorNotice.js @@ -0,0 +1,57 @@ +/** + * CronSchedulerErrorNotice component. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Notice from '@/js/components/Notice'; +import { TYPES } from '@/js/components/Notice/constants'; +import useNotificationEvents from '@/js/googlesitekit/notifications/hooks/useNotificationEvents'; +import withIntersectionObserver from '@/js/util/withIntersectionObserver'; + +export const EMAIL_REPORTING_CRON_SCHEDULER_ERROR_NOTICE = + 'email_reporting_cron_scheduler_error_notice'; + +const NoticeWithIntersectionObserver = withIntersectionObserver( Notice ); + +export default function CronSchedulerErrorNotice() { + const trackEvents = useNotificationEvents( + EMAIL_REPORTING_CRON_SCHEDULER_ERROR_NOTICE + ); + + return ( + + ); +} diff --git a/assets/js/components/email-reporting/notices/errors/CronSchedulerErrorNotice.test.js b/assets/js/components/email-reporting/notices/errors/CronSchedulerErrorNotice.test.js new file mode 100644 index 00000000000..2a3c8bd0c92 --- /dev/null +++ b/assets/js/components/email-reporting/notices/errors/CronSchedulerErrorNotice.test.js @@ -0,0 +1,38 @@ +/** + * CronSchedulerErrorNotice component tests. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import CronSchedulerErrorNotice from './CronSchedulerErrorNotice'; +import { render } from '../../../../../../tests/js/test-utils'; + +describe( 'CronSchedulerErrorNotice', () => { + it( 'renders expected title and description', () => { + const { getByText } = render( ); + + expect( + getByText( 'Email reports are failing to send' ) + ).toBeInTheDocument(); + expect( + getByText( + 'We were unable to deliver your report, likely due to a WP-Cron configuration error in your WordPress site’s system settings. To fix this, contact your administrator or get help. Report delivery will automatically resume once the issue is resolved.' + ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/assets/sass/components/email-reporting/_googlesitekit-user-settings-selection-panel.scss b/assets/sass/components/email-reporting/_googlesitekit-user-settings-selection-panel.scss index cdd7edd5a1b..8abc4fcf798 100644 --- a/assets/sass/components/email-reporting/_googlesitekit-user-settings-selection-panel.scss +++ b/assets/sass/components/email-reporting/_googlesitekit-user-settings-selection-panel.scss @@ -41,7 +41,6 @@ // Ensure every notice after the first notice has a margin // so the notices don't render with no gap. - // // See: https://github.com/google/site-kit-wp/issues/12279 .googlesitekit-notice-container:nth-of-type(n + 2) { margin-top: $grid-gap-phone; diff --git a/includes/Core/Email_Reporting/Content_Map.php b/includes/Core/Email_Reporting/Content_Map.php index a0b0381547a..4d968eb6f73 100644 --- a/includes/Core/Email_Reporting/Content_Map.php +++ b/includes/Core/Email_Reporting/Content_Map.php @@ -107,9 +107,10 @@ function ( $paragraph ) use ( $args ) { protected static function get_all_titles() { return array( /* translators: 1: opening anchor tag with mailto link, 2: inviter email address, 3: closing anchor tag */ - 'invitation-email' => __( '%1$s%2$s%3$s invited you to receive periodic performance reports', 'google-site-kit' ), - 'subscription-confirmation' => __( 'Success! You’re subscribed to Site Kit reports', 'google-site-kit' ), - 'error-email' => __( 'Action needed: your Site Kit report couldn’t be generated', 'google-site-kit' ), + 'invitation-email' => __( '%1$s%2$s%3$s invited you to receive periodic performance reports', 'google-site-kit' ), + 'subscription-confirmation' => __( 'Success! You’re subscribed to Site Kit reports', 'google-site-kit' ), + 'error-email' => __( 'Action needed: your Site Kit report couldn’t be generated', 'google-site-kit' ), + 'error-email-cron-scheduler' => __( 'Email reports are failing to send', 'google-site-kit' ), ); } @@ -135,6 +136,9 @@ protected static function get_all_bodies() { 'error-email' => array( __( 'We were unable to generate your report due to a server error. To fix this, contact your host. Report delivery will automatically resume once the issue is resolved.', 'google-site-kit' ), ), + 'error-email-cron-scheduler' => array( + __( 'We were unable to deliver your report, likely due to a WP-Cron configuration error in your WordPress site’s system settings. To fix this, contact your administrator or get help. Report delivery will automatically resume once the issue is resolved.', 'google-site-kit' ), + ), // Opening/closing tag placeholders keep inline styles and HTML // out of translation strings. Inline color styles are required // because many email clients strip or ignore CSS classes. diff --git a/includes/Core/Email_Reporting/Cron_Health_Check.php b/includes/Core/Email_Reporting/Cron_Health_Check.php new file mode 100644 index 00000000000..3bf6c1da85a --- /dev/null +++ b/includes/Core/Email_Reporting/Cron_Health_Check.php @@ -0,0 +1,275 @@ +batch_query = $batch_query; + $this->scheduler = $scheduler; + } + + /** + * Checks for stale cron-related work and marks logs with cron errors when found. + * + * @since n.e.x.t + */ + public function check_stale_tasks() { + $now = time(); + $has_overdue_cron_tasks = false; + $frequencies = array( + User_Email_Reporting_Settings::FREQUENCY_WEEKLY, + User_Email_Reporting_Settings::FREQUENCY_MONTHLY, + User_Email_Reporting_Settings::FREQUENCY_QUARTERLY, + ); + + foreach ( $frequencies as $frequency ) { + $scheduled = $this->scheduler->get_initiator_timestamp( $frequency ); + + if ( false === $scheduled ) { + continue; + } + + if ( ( (int) $scheduled + DAY_IN_SECONDS ) < $now ) { + $has_overdue_cron_tasks = true; + break; + } + } + + if ( $has_overdue_cron_tasks ) { + $this->set_cron_scheduler_error(); + } + + if ( $this->batch_query->has_stale_pending_logs() ) { + $this->mark_stale_logs_cron_error(); + } + } + + /** + * Tracks worker progress by frequency and marks cron errors when retries stall. + * + * @since n.e.x.t + * + * @param string $frequency Frequency slug. + * @param int $emails_processed Number of emails sent in the worker run. + * @param string $batch_id Batch ID for the current run. + */ + public function track_worker_progress( string $frequency, int $emails_processed, string $batch_id ) { + if ( '' === $frequency || '' === $batch_id ) { + return; + } + + $transient_key = $this->get_zero_send_transient_key( $frequency ); + + if ( $emails_processed > 0 ) { + delete_transient( $transient_key ); + return; + } + + $count = ( (int) get_transient( $transient_key ) ) + 1; + + set_transient( $transient_key, $count, HOUR_IN_SECONDS ); + + if ( $count >= self::ZERO_SEND_THRESHOLD ) { + $this->mark_batch_cron_error( $batch_id ); + } + } + + /** + * Marks all pending logs in the given batch as failed due to cron scheduler issues. + * + * @since n.e.x.t + * + * @param string $batch_id Batch identifier. + */ + public function mark_batch_cron_error( $batch_id ) { + $batch_id = (string) $batch_id; + if ( '' === $batch_id ) { + return; + } + + $pending_ids = $this->batch_query->get_pending_ids( $batch_id ); + + if ( empty( $pending_ids ) ) { + return; + } + + $error_details = $this->get_cron_scheduler_error_json(); + + foreach ( $pending_ids as $post_id ) { + $this->mark_post_with_cron_error( $post_id, $error_details ); + } + } + + /** + * Marks stale scheduled logs as failed with cron scheduler errors. + * + * @since n.e.x.t + */ + public function mark_stale_logs_cron_error() { + $stale_ids = $this->get_stale_pending_log_ids(); + if ( empty( $stale_ids ) ) { + return; + } + + $error_details = $this->get_cron_scheduler_error_json(); + + foreach ( $stale_ids as $post_id ) { + $this->mark_post_with_cron_error( $post_id, $error_details ); + } + } + + /** + * Marks latest batch pending logs with cron scheduler errors. + * + * @since n.e.x.t + */ + private function set_cron_scheduler_error() { + $latest_batch_post_ids = $this->batch_query->get_latest_batch_post_ids(); + if ( empty( $latest_batch_post_ids ) ) { + return; + } + + $first_post_id = (int) reset( $latest_batch_post_ids ); + $batch_id = get_post_meta( $first_post_id, Email_Log::META_BATCH_ID, true ); + + if ( empty( $batch_id ) ) { + return; + } + + $this->mark_batch_cron_error( (string) $batch_id ); + } + + /** + * Builds the transient key for zero-send tracking. + * + * @since n.e.x.t + * + * @param string $frequency Frequency slug. + * @return string + */ + private function get_zero_send_transient_key( $frequency ) { + return sprintf( 'googlesitekit_email_cron_zero_sends_%s', sanitize_key( $frequency ) ); + } + + /** + * Gets stale pending log IDs older than one day. + * + * @since n.e.x.t + * + * @return array + */ + private function get_stale_pending_log_ids() { + $query = new WP_Query( + array( + 'post_type' => Email_Log::POST_TYPE, + 'post_status' => Email_Log::STATUS_SCHEDULED, + // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page + 'posts_per_page' => 10000, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'date_query' => array( + array( + 'column' => 'post_date', + 'before' => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ), + ), + ), + ) + ); + + return array_map( 'intval', $query->posts ); + } + + /** + * Gets error details payload for cron scheduler errors. + * + * @since n.e.x.t + * + * @return string + */ + private function get_cron_scheduler_error_json() { + $error = new \WP_Error( + 'cron_scheduler_error', + __( 'Email report generation could not be completed due to a cron scheduling issue.', 'google-site-kit' ), + array( + 'category_id' => 'cron_scheduler_error', + ) + ); + + $payload = array( + 'errors' => $error->errors, + 'error_data' => $error->error_data, + ); + + $encoded = wp_json_encode( $payload, JSON_UNESCAPED_UNICODE ); + + return is_string( $encoded ) ? $encoded : ''; + } + + /** + * Marks a single log post as failed due to cron scheduler error. + * + * @since n.e.x.t + * + * @param int $post_id Email log post ID. + * @param string $error_details Encoded cron scheduler error payload. + */ + private function mark_post_with_cron_error( $post_id, $error_details ) { + $this->batch_query->update_status( $post_id, Email_Log::STATUS_FAILED ); + update_post_meta( $post_id, Email_Log::META_SEND_ATTEMPTS, Email_Log_Batch_Query::MAX_ATTEMPTS ); + update_post_meta( $post_id, Email_Log::META_ERROR_DETAILS, $error_details ); + } +} diff --git a/includes/Core/Email_Reporting/Email_Log_Batch_Query.php b/includes/Core/Email_Reporting/Email_Log_Batch_Query.php index dfd324a6b41..555a0970718 100644 --- a/includes/Core/Email_Reporting/Email_Log_Batch_Query.php +++ b/includes/Core/Email_Reporting/Email_Log_Batch_Query.php @@ -320,6 +320,35 @@ public function get_latest_batch_error() { return get_post_meta( $first_post_id, Email_Log::META_ERROR_DETAILS, true ); } + /** + * Checks if any scheduled email logs are stale (older than one day). + * + * @since n.e.x.t + * + * @return bool True when at least one stale scheduled log exists. + */ + public function has_stale_pending_logs() { + $query = new WP_Query( + array( + 'post_type' => Email_Log::POST_TYPE, + 'post_status' => Email_Log::STATUS_SCHEDULED, + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'date_query' => array( + array( + 'column' => 'post_date', + 'before' => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ), + ), + ), + ) + ); + + return ! empty( $query->posts ); + } + /** * Checks if all logs in a batch have failed after maximum attempts. * diff --git a/includes/Core/Email_Reporting/Email_Reporting.php b/includes/Core/Email_Reporting/Email_Reporting.php index 3fa515b5708..042f5640c02 100644 --- a/includes/Core/Email_Reporting/Email_Reporting.php +++ b/includes/Core/Email_Reporting/Email_Reporting.php @@ -232,6 +232,8 @@ public function __construct( $this->user_settings = new User_Email_Reporting_Settings( $this->user_options ); $conversion_tracking_settings = new Conversion_Tracking_Settings( $this->options ); $conversion_tracking = new Conversion_Tracking( $this->context, $this->options ); + $frequency_planner = new Frequency_Planner(); + $this->scheduler = new Email_Reporting_Scheduler( $frequency_planner ); $this->email_notices = new Email_Notices( $this->context, $this->golinks, @@ -241,11 +243,11 @@ public function __construct( ) ); - $frequency_planner = new Frequency_Planner(); $this->subscribed_users_query = new Subscribed_Users_Query( $this->user_settings, $this->modules ); $eligible_subscribers_query = new Eligible_Subscribers_Query( $this->modules, $this->user_options ); $max_execution_limiter = new Max_Execution_Limiter( (int) ini_get( 'max_execution_time' ) ); $this->email_log_batch_query = new Email_Log_Batch_Query(); + $health_check = new Cron_Health_Check( $this->email_log_batch_query, $this->scheduler ); $email_sender = new Email(); $section_builder = new Email_Report_Section_Builder( $this->context ); $template_formatter = new Email_Template_Formatter( $this->context, $section_builder, $this->golinks, $this->email_notices ); @@ -259,10 +261,10 @@ public function __construct( $this->user_settings, $eligible_subscribers_query, $email_sender, - $this->golinks + $this->golinks, + $health_check ); $this->email_log = new Email_Log(); - $this->scheduler = new Email_Reporting_Scheduler( $frequency_planner ); $this->initiator_task = new Initiator_Task( $this->scheduler, $this->subscribed_users_query ); $notifier = new Batch_Error_Notifier( $this->email_log_batch_query, $email_sender, $this->context, $this->golinks ); $this->worker_task = new Worker_Task( @@ -271,7 +273,8 @@ public function __construct( $this->scheduler, $log_processor, $this->data_requests, - $notifier + $notifier, + $health_check ); $this->fallback_task = new Fallback_Task( $this->email_log_batch_query, $this->scheduler, $this->worker_task, $notifier ); $this->monitor_task = new Monitor_Task( $this->scheduler, $this->settings ); diff --git a/includes/Core/Email_Reporting/REST_Email_Reporting_Controller.php b/includes/Core/Email_Reporting/REST_Email_Reporting_Controller.php index 845dba29022..17c9b77f30c 100644 --- a/includes/Core/Email_Reporting/REST_Email_Reporting_Controller.php +++ b/includes/Core/Email_Reporting/REST_Email_Reporting_Controller.php @@ -88,6 +88,14 @@ class REST_Email_Reporting_Controller { */ private $email_log_batch_query; + /** + * Cron health check instance. + * + * @since n.e.x.t + * @var Cron_Health_Check + */ + private $health_check; + /** * Email sender instance. * @@ -111,6 +119,7 @@ class REST_Email_Reporting_Controller { * @since 1.170.0 Added modules and user email reporting settings dependencies. * @since 1.173.0 Added eligible subscribers query and email sender dependencies and removed unused user options dependency. * @since 1.174.0 Added golinks dependency. + * @since n.e.x.t Added cron health check dependency. * * @param Email_Reporting_Settings $settings Email_Reporting_Settings instance. * @param Modules $modules Modules instance. @@ -118,6 +127,7 @@ class REST_Email_Reporting_Controller { * @param Eligible_Subscribers_Query $eligible_subscribers_query Eligible subscribers query instance. * @param Email $email_sender Email sender instance. * @param Golinks $golinks Golinks instance. + * @param Cron_Health_Check $health_check Cron health check instance. */ public function __construct( Email_Reporting_Settings $settings, @@ -125,7 +135,8 @@ public function __construct( User_Email_Reporting_Settings $user_email_reporting_settings, Eligible_Subscribers_Query $eligible_subscribers_query, Email $email_sender, - Golinks $golinks + Golinks $golinks, + Cron_Health_Check $health_check ) { $this->settings = $settings; $this->modules = $modules; @@ -134,6 +145,7 @@ public function __construct( $this->email_log_batch_query = new Email_Log_Batch_Query(); $this->email_sender = $email_sender; $this->golinks = $golinks; + $this->health_check = $health_check; } /** @@ -157,6 +169,7 @@ function ( $paths ) { array( '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting', '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting-eligible-subscribers', + '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting-errors', ) ); } @@ -285,6 +298,7 @@ function ( WP_User $user ) use ( $meta_key ) { array( 'methods' => WP_REST_Server::READABLE, 'callback' => function () { + $this->health_check->check_stale_tasks(); $errors = $this->email_log_batch_query->get_latest_batch_error(); return new WP_REST_Response( is_string( $errors ) ? json_decode( $errors, true ) : array() ); diff --git a/includes/Core/Email_Reporting/Worker_Task.php b/includes/Core/Email_Reporting/Worker_Task.php index 098599988c7..ed22599ce88 100644 --- a/includes/Core/Email_Reporting/Worker_Task.php +++ b/includes/Core/Email_Reporting/Worker_Task.php @@ -76,6 +76,15 @@ class Worker_Task { */ private $notifier; + /** + * Cron health check service. + * + * @since n.e.x.t + * + * @var Cron_Health_Check + */ + private $health_check; + /** * Constructor. * @@ -88,6 +97,7 @@ class Worker_Task { * @param Email_Log_Processor $log_processor Log processor instance. * @param Email_Reporting_Data_Requests $data_requests Data requests helper. * @param Batch_Error_Notifier $notifier Batch error notifier. + * @param Cron_Health_Check $health_check Cron health check service. */ public function __construct( Max_Execution_Limiter $max_execution_limiter, @@ -95,7 +105,8 @@ public function __construct( Email_Reporting_Scheduler $scheduler, Email_Log_Processor $log_processor, Email_Reporting_Data_Requests $data_requests, - Batch_Error_Notifier $notifier + Batch_Error_Notifier $notifier, + Cron_Health_Check $health_check ) { $this->max_execution_limiter = $max_execution_limiter; $this->batch_query = $batch_query; @@ -103,6 +114,7 @@ public function __construct( $this->log_processor = $log_processor; $this->data_requests = $data_requests; $this->notifier = $notifier; + $this->health_check = $health_check; } /** @@ -150,7 +162,8 @@ public function handle_callback_action( $batch_id, $frequency, $initiator_timest return; } - $this->process_pending_logs( $pending_ids, $frequency, $initiator_timestamp ); + $emails_processed = $this->process_pending_logs( $pending_ids, $frequency, $initiator_timestamp ); + $this->health_check->track_worker_progress( $frequency, $emails_processed, $batch_id ); $this->notifier->maybe_notify( $batch_id ); } finally { @@ -169,15 +182,19 @@ public function handle_callback_action( $batch_id, $frequency, $initiator_timest * @param array $pending_ids Pending post IDs. * @param string $frequency Frequency slug. * @param int $initiator_timestamp Initiator timestamp. + * @return int Number of emails processed and marked sent. */ private function process_pending_logs( array $pending_ids, $frequency, $initiator_timestamp ) { - $shared_payloads = $this->get_shared_payloads_for_pending_ids( $pending_ids ); + $shared_payloads = $this->get_shared_payloads_for_pending_ids( $pending_ids ); + $processed_sent_ids = array(); foreach ( $pending_ids as $post_id ) { if ( $this->should_abort( $initiator_timestamp ) ) { - return; + return count( $processed_sent_ids ); } + $previous_status = get_post_status( $post_id ); + $email_log = get_post( $post_id ); $user = null; @@ -203,7 +220,13 @@ private function process_pending_logs( array $pending_ids, $frequency, $initiato } else { $this->log_processor->process( $post_id, $frequency, $shared_payloads_for_user ); } + + if ( Email_Log::STATUS_SENT === get_post_status( $post_id ) && Email_Log::STATUS_SENT !== $previous_status ) { + $processed_sent_ids[] = $post_id; + } } + + return count( $processed_sent_ids ); } /** diff --git a/tests/phpunit/integration/Core/Email_Reporting/Cron_Health_CheckTest.php b/tests/phpunit/integration/Core/Email_Reporting/Cron_Health_CheckTest.php new file mode 100644 index 00000000000..462f69fbe4f --- /dev/null +++ b/tests/phpunit/integration/Core/Email_Reporting/Cron_Health_CheckTest.php @@ -0,0 +1,232 @@ +register_email_log_dependencies(); + + $this->query = new Email_Log_Batch_Query(); + $scheduler = new Email_Reporting_Scheduler( new Frequency_Planner() ); + $this->health_check = new Cron_Health_Check( $this->query, $scheduler ); + $this->created_post_ids = array(); + + $this->clear_state(); + } + + public function tear_down() { + $this->clear_state(); + + foreach ( $this->created_post_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + + if ( post_type_exists( Email_Log::POST_TYPE ) && function_exists( 'unregister_post_type' ) ) { + unregister_post_type( Email_Log::POST_TYPE ); + } + + foreach ( array( Email_Log::STATUS_SENT, Email_Log::STATUS_FAILED, Email_Log::STATUS_SCHEDULED ) as $status ) { + if ( isset( $GLOBALS['wp_post_statuses'][ $status ] ) ) { + unset( $GLOBALS['wp_post_statuses'][ $status ] ); + } + } + + parent::tear_down(); + } + + public function test_check_stale_tasks_marks_latest_pending_batch_when_initiator_is_overdue() { + $batch_id = 'batch-overdue'; + $post_id = $this->create_log_post( $batch_id, Email_Log::STATUS_SCHEDULED, 0 ); + + wp_schedule_single_event( + time() - DAY_IN_SECONDS - HOUR_IN_SECONDS, + Email_Reporting_Scheduler::ACTION_INITIATOR, + array( Email_Reporting_Settings::FREQUENCY_WEEKLY, time() - DAY_IN_SECONDS - HOUR_IN_SECONDS ) + ); + wp_schedule_single_event( + time() + DAY_IN_SECONDS, + Email_Reporting_Scheduler::ACTION_INITIATOR, + array( Email_Reporting_Settings::FREQUENCY_MONTHLY, time() + DAY_IN_SECONDS ) + ); + wp_schedule_single_event( + time() + DAY_IN_SECONDS, + Email_Reporting_Scheduler::ACTION_INITIATOR, + array( Email_Reporting_Settings::FREQUENCY_QUARTERLY, time() + DAY_IN_SECONDS ) + ); + + $this->health_check->check_stale_tasks(); + + $this->assertSame( Email_Log::STATUS_FAILED, get_post_status( $post_id ), 'Overdue initiator should mark latest pending logs as failed.' ); + $this->assertSame( Email_Log_Batch_Query::MAX_ATTEMPTS, (int) get_post_meta( $post_id, Email_Log::META_SEND_ATTEMPTS, true ), 'Overdue initiator should set max attempts on failed logs.' ); + $this->assertStringContainsString( 'cron_scheduler_error', (string) get_post_meta( $post_id, Email_Log::META_ERROR_DETAILS, true ), 'Overdue initiator should store cron scheduler error details.' ); + } + + public function test_check_stale_tasks_does_not_mark_logs_when_all_frequencies_are_healthy() { + $batch_id = 'batch-healthy'; + $post_id = $this->create_log_post( $batch_id, Email_Log::STATUS_SCHEDULED, 0 ); + + wp_schedule_single_event( + time() + DAY_IN_SECONDS, + Email_Reporting_Scheduler::ACTION_INITIATOR, + array( Email_Reporting_Settings::FREQUENCY_WEEKLY, time() + DAY_IN_SECONDS ) + ); + wp_schedule_single_event( + time() + DAY_IN_SECONDS, + Email_Reporting_Scheduler::ACTION_INITIATOR, + array( Email_Reporting_Settings::FREQUENCY_MONTHLY, time() + DAY_IN_SECONDS ) + ); + wp_schedule_single_event( + time() + DAY_IN_SECONDS, + Email_Reporting_Scheduler::ACTION_INITIATOR, + array( Email_Reporting_Settings::FREQUENCY_QUARTERLY, time() + DAY_IN_SECONDS ) + ); + + $this->health_check->check_stale_tasks(); + + $this->assertSame( Email_Log::STATUS_SCHEDULED, get_post_status( $post_id ), 'Healthy schedules should not mark logs as failed.' ); + } + + public function test_check_stale_tasks_marks_stale_pending_logs() { + $batch_id = 'batch-stale'; + $post_id = $this->create_log_post( + $batch_id, + Email_Log::STATUS_SCHEDULED, + 0, + array( + 'post_date' => gmdate( 'Y-m-d H:i:s', time() - ( 2 * DAY_IN_SECONDS ) ), + 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', time() - ( 2 * DAY_IN_SECONDS ) ), + ) + ); + + $this->health_check->check_stale_tasks(); + + $this->assertSame( Email_Log::STATUS_FAILED, get_post_status( $post_id ), 'Stale scheduled logs should be marked as failed.' ); + $this->assertSame( Email_Log_Batch_Query::MAX_ATTEMPTS, (int) get_post_meta( $post_id, Email_Log::META_SEND_ATTEMPTS, true ), 'Stale scheduled logs should set attempts to max.' ); + $this->assertStringContainsString( 'cron_scheduler_error', (string) get_post_meta( $post_id, Email_Log::META_ERROR_DETAILS, true ), 'Stale scheduled logs should store cron scheduler error details.' ); + } + + public function test_track_worker_progress_marks_batch_after_three_consecutive_zero_sends() { + $batch_id = 'batch-zero-send'; + $post_id = $this->create_log_post( $batch_id, Email_Log::STATUS_SCHEDULED, 0 ); + + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, $batch_id ); + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, $batch_id ); + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, $batch_id ); + + $this->assertSame( Email_Log::STATUS_FAILED, get_post_status( $post_id ), 'Three zero-send runs should mark the batch as failed.' ); + $this->assertSame( Email_Log_Batch_Query::MAX_ATTEMPTS, (int) get_post_meta( $post_id, Email_Log::META_SEND_ATTEMPTS, true ), 'Three zero-send runs should set attempts to max.' ); + } + + public function test_track_worker_progress_keeps_frequency_counters_isolated() { + $weekly_key = 'googlesitekit_email_cron_zero_sends_' . Email_Reporting_Settings::FREQUENCY_WEEKLY; + $monthly_key = 'googlesitekit_email_cron_zero_sends_' . Email_Reporting_Settings::FREQUENCY_MONTHLY; + + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, 'batch-weekly' ); + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, 'batch-weekly' ); + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_MONTHLY, 0, 'batch-monthly' ); + + $this->assertSame( 2, (int) get_transient( $weekly_key ), 'Weekly counter should track only weekly runs.' ); + $this->assertSame( 1, (int) get_transient( $monthly_key ), 'Monthly counter should track only monthly runs.' ); + } + + public function test_track_worker_progress_resets_counter_on_successful_send() { + $batch_id = 'batch-reset'; + $post_id = $this->create_log_post( $batch_id, Email_Log::STATUS_SCHEDULED, 0 ); + + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, $batch_id ); + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, $batch_id ); + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 1, $batch_id ); + $this->health_check->track_worker_progress( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, $batch_id ); + + $this->assertSame( Email_Log::STATUS_SCHEDULED, get_post_status( $post_id ), 'Counter reset should prevent premature batch failure.' ); + $this->assertSame( + 1, + (int) get_transient( 'googlesitekit_email_cron_zero_sends_' . Email_Reporting_Settings::FREQUENCY_WEEKLY ), + 'Counter should restart from 1 after a successful send.' + ); + } + + public function test_mark_batch_cron_error_updates_logs_and_latest_batch_error_pipeline() { + $batch_id = 'batch-mark'; + $post_id = $this->create_log_post( $batch_id, Email_Log::STATUS_SCHEDULED, 0 ); + + $this->health_check->mark_batch_cron_error( $batch_id ); + + $this->assertSame( Email_Log::STATUS_FAILED, get_post_status( $post_id ), 'mark_batch_cron_error should set failed status.' ); + $this->assertSame( Email_Log_Batch_Query::MAX_ATTEMPTS, (int) get_post_meta( $post_id, Email_Log::META_SEND_ATTEMPTS, true ), 'mark_batch_cron_error should set max attempts.' ); + + $latest_error = $this->query->get_latest_batch_error(); + $this->assertIsString( $latest_error, 'Latest batch error should be persisted as JSON string.' ); + $this->assertStringContainsString( 'cron_scheduler_error', $latest_error, 'Latest batch error should include cron scheduler category.' ); + } + + private function create_log_post( $batch_id, $status, $attempts, array $overrides = array() ) { + $post_id = $this->factory()->post->create( + array_merge( + array( + 'post_type' => Email_Log::POST_TYPE, + 'post_status' => $status, + 'post_title' => 'Cron log ' . uniqid(), + 'post_date' => current_time( 'mysql' ), + 'post_date_gmt' => current_time( 'mysql', 1 ), + ), + $overrides + ) + ); + + update_post_meta( $post_id, Email_Log::META_BATCH_ID, $batch_id ); + update_post_meta( $post_id, Email_Log::META_REPORT_FREQUENCY, Email_Reporting_Settings::FREQUENCY_WEEKLY ); + update_post_meta( $post_id, Email_Log::META_SEND_ATTEMPTS, $attempts ); + update_post_meta( + $post_id, + Email_Log::META_REPORT_REFERENCE_DATES, + array( + 'startDate' => time() - DAY_IN_SECONDS, + 'sendDate' => time(), + ) + ); + + $this->created_post_ids[] = $post_id; + + return $post_id; + } + + private function register_email_log_dependencies() { + $email_log = new Email_Log( new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ) ); + $register_method = new \ReflectionMethod( Email_Log::class, 'register_email_log' ); + $register_method->setAccessible( true ); + $register_method->invoke( $email_log ); + } + + private function clear_state() { + wp_unschedule_hook( Email_Reporting_Scheduler::ACTION_INITIATOR ); + + delete_transient( 'googlesitekit_email_cron_zero_sends_' . Email_Reporting_Settings::FREQUENCY_WEEKLY ); + delete_transient( 'googlesitekit_email_cron_zero_sends_' . Email_Reporting_Settings::FREQUENCY_MONTHLY ); + delete_transient( 'googlesitekit_email_cron_zero_sends_' . Email_Reporting_Settings::FREQUENCY_QUARTERLY ); + } +} diff --git a/tests/phpunit/integration/Core/Email_Reporting/Email_Log_Batch_QueryTest.php b/tests/phpunit/integration/Core/Email_Reporting/Email_Log_Batch_QueryTest.php index 52b5d9763f1..6a5f0348333 100644 --- a/tests/phpunit/integration/Core/Email_Reporting/Email_Log_Batch_QueryTest.php +++ b/tests/phpunit/integration/Core/Email_Reporting/Email_Log_Batch_QueryTest.php @@ -137,6 +137,26 @@ public function test_get_latest_batch_error__number_of_attempts_not_exceeded() { $this->assertNull( $latest_error, 'Latest batch error should return null if not all logs have exceeded max attempts.' ); } + public function test_has_stale_pending_logs_returns_true_when_scheduled_log_is_older_than_a_day() { + $post_id = $this->create_log_post( 'batch-stale', Email_Log::STATUS_SCHEDULED, 0 ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_date' => gmdate( 'Y-m-d H:i:s', time() - ( 2 * DAY_IN_SECONDS ) ), + 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', time() - ( 2 * DAY_IN_SECONDS ) ), + ) + ); + + $this->assertTrue( $this->query->has_stale_pending_logs(), 'A scheduled log older than one day should be considered stale.' ); + } + + public function test_has_stale_pending_logs_returns_false_when_only_recent_scheduled_logs_exist() { + $this->create_log_post( 'batch-recent', Email_Log::STATUS_SCHEDULED, 0 ); + + $this->assertFalse( $this->query->has_stale_pending_logs(), 'Recent scheduled logs should not be considered stale.' ); + } + public function test_get_total_count_by_status__returns_zero_when_no_logs() { $this->assertSame( 0, $this->query->get_total_count_by_status( Email_Log::STATUS_SENT ), 'Expected zero sent logs when none exist.' ); } diff --git a/tests/phpunit/integration/Core/Email_Reporting/REST_Email_Reporting_ControllerTest.php b/tests/phpunit/integration/Core/Email_Reporting/REST_Email_Reporting_ControllerTest.php index 919bb18e337..5eecbc92066 100644 --- a/tests/phpunit/integration/Core/Email_Reporting/REST_Email_Reporting_ControllerTest.php +++ b/tests/phpunit/integration/Core/Email_Reporting/REST_Email_Reporting_ControllerTest.php @@ -13,6 +13,8 @@ use Google\Site_Kit\Context; use Google\Site_Kit\Core\Authentication\Authentication; use Google\Site_Kit\Core\Email\Email; +use Google\Site_Kit\Core\Email_Reporting\Cron_Health_Check; +use Google\Site_Kit\Core\Email_Reporting\Email_Log_Batch_Query; use Google\Site_Kit\Core\Email_Reporting\Email_Reporting_Golink_Handler; use Google\Site_Kit\Core\Email_Reporting\Email_Reporting_Settings; use Google\Site_Kit\Core\Email_Reporting\Eligible_Subscribers_Query; @@ -121,6 +123,7 @@ public function set_up() { $golinks = new Golinks( $this->context ); $golinks->register_handler( 'manage-subscription-email-reporting', new Email_Reporting_Golink_Handler() ); + $health_check = $this->createMock( Cron_Health_Check::class ); $this->controller = new REST_Email_Reporting_Controller( $this->settings, @@ -128,7 +131,8 @@ public function set_up() { $this->user_settings, new Eligible_Subscribers_Query( $this->modules, $this->user_options ), new Email(), - $golinks + $golinks, + $health_check ); $this->original_sharing_option = get_option( Module_Sharing_Settings::OPTION ); } @@ -169,6 +173,7 @@ public function test_get_routes() { $routes = array( '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting', '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting-eligible-subscribers', + '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting-errors', '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting-invite-user', ); $get_routes = array_intersect( $routes, array_keys( $server->get_routes() ) ); @@ -374,6 +379,58 @@ public function test_get_eligible_subscribers_non_matching_search_returns_empty_ $this->assertSame( 0, $data['totalPages'], 'Non-matching search should return zero total pages.' ); } + public function test_get_email_reporting_errors_runs_health_check_before_reading_latest_batch_error() { + remove_all_filters( 'googlesitekit_rest_routes' ); + + $call_order = array(); + + $health_check = $this->createMock( Cron_Health_Check::class ); + $health_check->expects( $this->once() ) + ->method( 'check_stale_tasks' ) + ->willReturnCallback( + function () use ( &$call_order ) { + $call_order[] = 'health_check'; + } + ); + + $controller = new REST_Email_Reporting_Controller( + $this->settings, + $this->modules, + $this->user_settings, + new Eligible_Subscribers_Query( $this->modules, $this->user_options ), + new Email(), + new Golinks( $this->context ), + $health_check + ); + + $batch_query = $this->createMock( Email_Log_Batch_Query::class ); + $batch_query->expects( $this->once() ) + ->method( 'get_latest_batch_error' ) + ->willReturnCallback( + function () use ( &$call_order ) { + $call_order[] = 'latest_batch_error'; + return '{"errors":{"cron_scheduler_error":["Cron issue"]},"error_data":{"cron_scheduler_error":{"category_id":"cron_scheduler_error"}}}'; + } + ); + + $reflection = new \ReflectionProperty( REST_Email_Reporting_Controller::class, 'email_log_batch_query' ); + $reflection->setAccessible( true ); + $reflection->setValue( $controller, $batch_query ); + + $controller->register(); + $this->register_rest_routes(); + + $request = new \WP_REST_Request( 'GET', '/' . REST_Routes::REST_ROOT . '/core/site/data/email-reporting-errors' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status(), 'Email reporting errors endpoint should return 200.' ); + $this->assertSame( + array( 'health_check', 'latest_batch_error' ), + $call_order, + 'Cron health check should run before reading latest batch error.' + ); + } + public function test_get_eligible_subscribers_includes_invited_field() { $current_admin = $this->create_admin_with_token( 'admin-current' ); $other_admin = $this->create_admin_with_token( 'admin-other' ); diff --git a/tests/phpunit/integration/Core/Email_Reporting/Worker_TaskTest.php b/tests/phpunit/integration/Core/Email_Reporting/Worker_TaskTest.php index d8226561436..b4813837e32 100644 --- a/tests/phpunit/integration/Core/Email_Reporting/Worker_TaskTest.php +++ b/tests/phpunit/integration/Core/Email_Reporting/Worker_TaskTest.php @@ -10,6 +10,7 @@ use Google\Site_Kit\Context; use Google\Site_Kit\Core\Email\Email; use Google\Site_Kit\Core\Email_Reporting\Batch_Error_Notifier; +use Google\Site_Kit\Core\Email_Reporting\Cron_Health_Check; use Google\Site_Kit\Core\Email_Reporting\Email_Log; use Google\Site_Kit\Core\Email_Reporting\Email_Log_Batch_Query; use Google\Site_Kit\Core\Email_Reporting\Email_Log_Processor; @@ -94,6 +95,11 @@ class Worker_TaskTest extends TestCase { */ private $real_batch_query; + /** + * @var Cron_Health_Check|\PHPUnit_Framework_MockObject_MockObject + */ + private $health_check; + public function set_up() { parent::set_up(); @@ -108,6 +114,7 @@ public function set_up() { $this->template_renderer_factory = $this->createMock( Email_Template_Renderer_Factory::class ); $this->template_renderer_factory->method( 'create' )->willReturn( $this->template_renderer ); $this->notifier = $this->createMock( Batch_Error_Notifier::class ); + $this->health_check = $this->createMock( Cron_Health_Check::class ); $this->email_log = new Email_Log( $this->context ); $this->created_post_ids = array(); $this->real_batch_query = new Email_Log_Batch_Query(); @@ -247,6 +254,51 @@ function ( $delay ) use ( $initiator_stamp, &$captured_delay, $expected_delay ) $this->assertNotNull( $captured_delay, 'Follow-up delay should be captured for assertion.' ); } + public function test_tracks_worker_progress_after_processing_pending_logs() { + $pending_ids = array( 123 ); + $initiator_stamp = time(); + $log_processor_mock = $this->createMock( Email_Log_Processor::class ); + + $this->limiter->method( 'should_abort' )->willReturn( false ); + + $this->batch_query->expects( $this->once() ) + ->method( 'is_complete' ) + ->with( 'batch-progress' ) + ->willReturn( false ); + + $this->batch_query->expects( $this->once() ) + ->method( 'get_pending_ids' ) + ->with( 'batch-progress' ) + ->willReturn( $pending_ids ); + + $this->scheduler->expects( $this->once() ) + ->method( 'schedule_worker' ); + + $log_processor_mock->expects( $this->once() ) + ->method( 'process' ) + ->with( $pending_ids[0], Email_Reporting_Settings::FREQUENCY_WEEKLY ); + + $this->health_check->expects( $this->once() ) + ->method( 'track_worker_progress' ) + ->with( Email_Reporting_Settings::FREQUENCY_WEEKLY, 0, 'batch-progress' ); + + $this->notifier->expects( $this->once() ) + ->method( 'maybe_notify' ) + ->with( 'batch-progress' ); + + $task = new Worker_Task( + $this->limiter, + $this->batch_query, + $this->scheduler, + $log_processor_mock, + $this->data_requests, + $this->notifier, + $this->health_check + ); + + $task->handle_callback_action( 'batch-progress', Email_Reporting_Settings::FREQUENCY_WEEKLY, $initiator_stamp ); + } + public function test_switches_to_log_site_id_on_multisite() { if ( ! is_multisite() ) { $this->markTestSkipped( 'This test only runs on multisite.' ); @@ -297,7 +349,8 @@ function () use ( &$observed_blog_id ) { $this->scheduler, $log_processor_mock, $this->data_requests, - $this->notifier + $this->notifier, + $this->health_check ); $task->handle_callback_action( $batch_id, Email_Reporting_Settings::FREQUENCY_WEEKLY, time() ); @@ -327,7 +380,8 @@ public function test_increments_attempts_for_pending_posts() { new Email_Report_Sender( $this->template_renderer_factory, $this->email_sender ) ), $this->data_requests, - $this->notifier + $this->notifier, + $this->health_check ); $batch_id = 'batch-real'; @@ -776,7 +830,8 @@ private function create_worker_task( $batch_query = null, $template_renderer_fac $this->scheduler, $log_processor, $this->data_requests, - $this->notifier + $this->notifier, + $this->health_check ); }