From 04097ce102ce3d7df9dc43b0560a33481f74eac5 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:51:09 +0200 Subject: [PATCH 01/14] Premium Analytics: add Jetpack Stats WP-Cron CSV export --- .../changelog/add-pa-stats-wpcron-export | 4 + .../class-top-posts-export-controller.php | 140 ++++++ .../Export/Logging/class-error-log-logger.php | 87 ++++ .../class-abstract-csv-report-controller.php | 14 + .../Export/class-report-data-fetcher.php | 53 ++- .../class-stats-csv-export-controller.php | 445 ++++++++++++++++++ .../src/Reports/Export/class-stats-export.php | 74 +++ .../Export/class-wp-cron-export-scheduler.php | 312 ++++++++++++ .../Export/class-wp-mail-export-email.php | 130 +++++ ...erface-csv-report-controller-interface.php | 12 + .../premium-analytics/src/class-analytics.php | 5 + .../Exports/TopPostsExportController_Test.php | 79 ++++ .../Export/Logging/ErrorLogLogger_Test.php | 79 ++++ .../Reports/Export/ReportDataFetcher_Test.php | 148 ++++++ .../Export/StatsCSVExportController_Test.php | 320 +++++++++++++ .../php/Reports/Export/StatsExport_Test.php | 146 ++++++ .../Export/WPCronExportScheduler_Test.php | 149 ++++++ .../Reports/Export/WPMailExportEmail_Test.php | 102 ++++ .../Export/fixtures/class-fake-generator.php | 22 + .../fixtures/class-fake-wp-cron-scheduler.php | 52 ++ .../fixtures/class-fake-wp-mail-email.php | 38 ++ .../fixtures/class-fetcher-spy-controller.php | 103 ++++ 22 files changed, 2507 insertions(+), 7 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export create mode 100644 projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php create mode 100644 projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php create mode 100644 projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php create mode 100644 projects/packages/premium-analytics/src/Reports/Export/class-stats-export.php create mode 100644 projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php create mode 100644 projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/Logging/ErrorLogLogger_Test.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-cron-scheduler.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-mail-email.php create mode 100644 projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fetcher-spy-controller.php diff --git a/projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export b/projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export new file mode 100644 index 000000000000..6f46697321bd --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a WooCommerce-free server-side CSV export pipeline for Jetpack Stats reports, using WP-Cron for scheduled email delivery and wp_mail for the attachment. diff --git a/projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php new file mode 100644 index 000000000000..6d8ed1566cc5 --- /dev/null +++ b/projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php @@ -0,0 +1,140 @@ + 1, + 'max' => self::MAX_POSTS, + ); + } + + /** + * Map the generic from/to range onto the WPCOM Stats top-posts query shape. + * + * @param array $params The per-period request parameters. + * @return array The Stats-native parameters. + */ + public function prepare_request_params( array $params ): array { + $to_ts = ! empty( $params['to'] ) ? strtotime( (string) $params['to'] ) : time(); + $from_ts = ! empty( $params['from'] ) ? strtotime( (string) $params['from'] ) : strtotime( '-' . self::DEFAULT_DAYS . ' days', $to_ts ); + + $days = (int) floor( ( $to_ts - $from_ts ) / DAY_IN_SECONDS ) + 1; + $days = max( 1, $days ); + + $params['period'] = 'day'; + $params['date'] = gmdate( 'Y-m-d', $to_ts ); + $params['num'] = $days; + + unset( $params['from'], $params['to'], $params['interval'], $params['compare_from'], $params['compare_to'] ); + + return $params; + } + + /** + * Get the column headers for this controller. + * + * @param string|null $interval Optional time interval. + * @return array The column headers. + */ + public function get_column_headers( ?string $interval = null ): array { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- The summarized list has no interval column. + return array( + 'post_title' => __( 'Title', 'jetpack-premium-analytics' ), + 'views' => __( 'Views', 'jetpack-premium-analytics' ), + 'type' => __( 'Type', 'jetpack-premium-analytics' ), + 'url' => __( 'URL', 'jetpack-premium-analytics' ), + ); + } + + /** + * Get default values for missing data fields. + * + * @return array Array of field_name => default_value pairs. + */ + public function get_default_values(): array { + return array( + 'post_title' => '', + 'views' => 0, + 'type' => '', + 'url' => '', + ); + } + + /** + * Format a single Stats postviews item for CSV export. + * + * @param array $item The raw postviews item. + * @param string|null $interval Optional time interval. + * @return array The formatted row. + */ + public function format_row_for_csv( array $item, ?string $interval = null ): array { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- The summarized list has no interval column. + $defaults = $this->get_default_values(); + + return array( + 'post_title' => (string) ( $item['title'] ?? $defaults['post_title'] ), + 'views' => (int) ( $item['views'] ?? $defaults['views'] ), + 'type' => (string) ( $item['type'] ?? $defaults['type'] ), + 'url' => (string) ( $item['href'] ?? $defaults['url'] ), + ); + } +} diff --git a/projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php b/projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php new file mode 100644 index 000000000000..98fcdbd04005 --- /dev/null +++ b/projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php @@ -0,0 +1,87 @@ +write( 'ERROR', $method, $exception->getMessage() ); + } + + /** + * Log an error. + * + * @param string $message The error message. + * @param string $method The method where the error occurred. + */ + public function log_error( string $message, string $method ): void { + $this->write( 'ERROR', $method, $message ); + } + + /** + * Log a generic note. + * + * @param string $message The note to log. + * @param string $method The method where the note occurred. + */ + public function log_message( string $message, string $method ): void { + $this->write( 'INFO', $method, $message ); + } + + /** + * Log a JSON response. + * + * @param mixed $response The response to log. + * @param string $method The method where the response occurred. + */ + public function log_response( $response, string $method ): void { + $message = wp_json_encode( $response, JSON_UNESCAPED_SLASHES ); + $this->write( 'INFO', $method, false === $message ? '' : $message ); + } + + /** + * Write a single line to the error log. + * + * @param string $level Log level tag. + * @param string $method Originating method. + * @param string $message Message body. + * @return void + */ + private function write( string $level, string $method, string $message ): void { + if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { + return; + } + + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + sprintf( '[%s] %s: %s %s', self::SOURCE, $level, $method, $message ) + ); + } +} diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-abstract-csv-report-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-abstract-csv-report-controller.php index d145c81470c3..845805d0d9c4 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-abstract-csv-report-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-abstract-csv-report-controller.php @@ -184,6 +184,20 @@ public function get_additional_params(): array { return array(); } + /** + * Transform the per-period request parameters before the data request. + * + * Default is identity; WooCommerce Analytics reports use the generic from/to + * range as-is. Reports whose endpoint expects a different parameter shape + * can override this. + * + * @param array $params The per-period request parameters. + * @return array The transformed parameters. + */ + public function prepare_request_params( array $params ): array { + return $params; + } + /** * Get the list of fields to request from the API. * diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-report-data-fetcher.php b/projects/packages/premium-analytics/src/Reports/Export/class-report-data-fetcher.php index 9b47fb5eff71..17d72ea4cdb0 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-report-data-fetcher.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-report-data-fetcher.php @@ -48,13 +48,31 @@ class Report_Data_Fetcher { */ const MAX_ID_FILTER_COUNT = 300; + /** + * WPCOM API prefix forwarded through the package proxy. + * + * @var string + */ + private $proxy_prefix; + + /** + * WPCOM API version segment forwarded through the package proxy. + * + * @var string + */ + private $proxy_version; + /** * Constructor. * - * @param Logger_Interface $logger The logger instance. + * @param Logger_Interface $logger The logger instance. + * @param string $proxy_prefix Proxy prefix for the data source. + * @param string $proxy_version WPCOM API version segment. */ - public function __construct( Logger_Interface $logger ) { - $this->logger = $logger; + public function __construct( Logger_Interface $logger, string $proxy_prefix = 'analytics', string $proxy_version = '2' ) { + $this->logger = $logger; + $this->proxy_prefix = $proxy_prefix; + $this->proxy_version = $proxy_version; } /** @@ -275,6 +293,10 @@ private function request_endpoint_data( array $params, Csv_Report_Controller_Interface $controller ) { + // Let each report map the generic from/to range onto its endpoint's native + // request shape. This runs once per period so comparison ranges map independently. + $params = $controller->prepare_request_params( $params ); + if ( method_exists( $controller, 'fetch_data' ) ) { // @phan-suppress-next-line PhanUndeclaredMethod -- Optional hook, guarded by method_exists() above; not part of the interface. return $controller->fetch_data( $endpoint, $params ); @@ -422,10 +444,15 @@ private function add_id_filter( array $params, string $field, array $ids, Csv_Re * @return array|\WP_Error The response data or error. */ protected function make_proxy_request( string $endpoint, array $params ) { - // Re-pointed from WooCommerce Analytics' own /wc/v3//proxy route to Premium - // Analytics' existing data proxy, which forwards the `analytics` prefix to the WPCOM - // analytics API (v2 base). The endpoint lives in the route path. - $proxy_route = sprintf( '/jetpack-premium-analytics/v1/proxy/v2/analytics/%s', $endpoint ); + // Re-pointed from WooCommerce Analytics' own /wc/v3//proxy route to + // Premium Analytics' existing data proxy. The prefix/version are configurable + // so this fetcher can serve both analytics (v2) and Jetpack Stats (v1.1) endpoints. + $proxy_route = sprintf( + '/jetpack-premium-analytics/v1/proxy/v%s/%s/%s', + $this->proxy_version, + $this->proxy_prefix, + $endpoint + ); // Remaining params are forwarded as query args. They must be set as query params (the // proxy reads get_query_params()), not appended to the route string, or they would @@ -463,6 +490,18 @@ protected function make_proxy_request( string $endpoint, array $params ) { unset( $data['items'] ); } + // Jetpack Stats list endpoints can return flat rows under `summary.` + // instead of the analytics `data`/`items` shape. Surface the first list found + // under summary as the report rows. + if ( ! isset( $data['data'] ) && isset( $data['summary'] ) && is_array( $data['summary'] ) ) { + foreach ( $data['summary'] as $value ) { + if ( is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) ) ) { + $data['data'] = $value; + break; + } + } + } + // Check if the response has error status (API returned error). if ( isset( $data['data']['status'] ) && $data['data']['status'] >= 400 ) { $message = $data['message'] ?? 'Unknown error from API'; diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php new file mode 100644 index 000000000000..bc6878b503c6 --- /dev/null +++ b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php @@ -0,0 +1,445 @@ +namespace = self::SLUG . '/v1'; + $this->rest_base = 'reports/stats-csv-export'; + $this->registry = $registry; + $this->data_fetcher = $data_fetcher; + $this->csv_generator = $csv_generator; + $this->scheduler = $scheduler; + $this->logger = $logger; + } + + /** + * Register the controller. + * + * @return void + */ + public function register(): void { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Register REST API routes. + * + * @return void + */ + public function register_routes(): void { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_export' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => $this->get_endpoint_args(), + ), + ) + ); + } + + /** + * Permission check. + * + * @return bool + */ + public function check_permission(): bool { + return current_user_can( 'view_stats' ) || current_user_can( 'manage_options' ); + } + + /** + * Endpoint argument schema. + * + * @return array + */ + private function get_endpoint_args(): array { + return array( + 'report_type' => array( + 'description' => __( 'The type of Stats report to export.', 'jetpack-premium-analytics' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_report_type' ), + ), + 'from' => array( + 'description' => __( 'Start date for the report period (ISO 8601 format).', 'jetpack-premium-analytics' ), + 'type' => 'string', + 'format' => 'date-time', + 'required' => true, + 'validate_callback' => array( $this, 'validate_from_date' ), + ), + 'to' => array( + 'description' => __( 'End date for the report period (ISO 8601 format).', 'jetpack-premium-analytics' ), + 'type' => 'string', + 'format' => 'date-time', + 'required' => true, + 'validate_callback' => array( $this, 'validate_to_date' ), + ), + 'interval' => array( + 'description' => __( 'Time interval for grouping data.', 'jetpack-premium-analytics' ), + 'type' => 'string', + 'default' => 'day', + 'enum' => array( 'hour', 'day', 'week', 'month', 'quarter', 'year' ), + 'validate_callback' => 'rest_validate_request_arg', + ), + 'compare_from' => array( + 'description' => __( 'Start date for comparison period (ISO 8601 format).', 'jetpack-premium-analytics' ), + 'type' => 'string', + 'format' => 'date-time', + 'validate_callback' => array( $this, 'validate_compare_from_date' ), + ), + 'compare_to' => array( + 'description' => __( 'End date for comparison period (ISO 8601 format).', 'jetpack-premium-analytics' ), + 'type' => 'string', + 'format' => 'date-time', + 'validate_callback' => array( $this, 'validate_compare_to_date' ), + ), + 'delivery_method' => array( + 'description' => __( 'Delivery method for the export.', 'jetpack-premium-analytics' ), + 'type' => 'string', + 'default' => 'download', + 'enum' => array( 'download', 'email' ), + ), + ); + } + + /** + * Validate that the report type is registered. + * + * @param mixed $value The parameter value. + * @return bool|WP_Error + */ + public function validate_report_type( $value ) { + if ( ! is_string( $value ) || ! $this->registry->is_registered( $value ) ) { + return new WP_Error( + 'invalid_report_type', + __( 'Invalid report type.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + return true; + } + + /** + * Validate from date parameter. + * + * @param mixed $value The parameter value. + * @param WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return bool|WP_Error + */ + public function validate_from_date( $value, WP_REST_Request $request, string $param ) { + $validated = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $validated ) ) { + return $validated; + } + + $to_date = $request->get_param( 'to' ); + if ( $to_date && strtotime( $value ) >= strtotime( $to_date ) ) { + return new WP_Error( + 'invalid_date_range', + __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Validate to date parameter. + * + * @param mixed $value The parameter value. + * @param WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return bool|WP_Error + */ + public function validate_to_date( $value, WP_REST_Request $request, string $param ) { + $validated = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $validated ) ) { + return $validated; + } + + if ( wp_date( 'Y-m-d', strtotime( $value ) ) > current_datetime()->format( 'Y-m-d' ) ) { + return new WP_Error( + 'future_date', + __( 'The "to" date cannot be later than today.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + $from_date = $request->get_param( 'from' ); + if ( $from_date && strtotime( $from_date ) >= strtotime( $value ) ) { + return new WP_Error( + 'invalid_date_range', + __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Validate compare_from date parameter. + * + * @param mixed $value The parameter value. + * @param WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return bool|WP_Error + */ + public function validate_compare_from_date( $value, WP_REST_Request $request, string $param ) { + $validated = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $validated ) ) { + return $validated; + } + + $compare_to = $request->get_param( 'compare_to' ); + if ( $compare_to ) { + return $this->validate_compare_period( $value, $compare_to ); + } + + return new WP_Error( + 'missing_compare_to', + __( 'The "compare_to" parameter is required when "compare_from" is provided.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + /** + * Validate compare_to date parameter. + * + * @param mixed $value The parameter value. + * @param WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return bool|WP_Error + */ + public function validate_compare_to_date( $value, WP_REST_Request $request, string $param ) { + $validated = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $validated ) ) { + return $validated; + } + + if ( wp_date( 'Y-m-d', strtotime( $value ) ) > current_datetime()->format( 'Y-m-d' ) ) { + return new WP_Error( + 'future_date', + __( 'The "compare_to" date cannot be later than today.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + $compare_from = $request->get_param( 'compare_from' ); + if ( $compare_from ) { + return $this->validate_compare_period( $compare_from, $value ); + } + + return new WP_Error( + 'missing_compare_from', + __( 'The "compare_from" parameter is required when "compare_to" is provided.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + /** + * Validate the comparison period date order. + * + * @param string $compare_from The compare_from date. + * @param string $compare_to The compare_to date. + * @return bool|WP_Error + */ + private function validate_compare_period( string $compare_from, string $compare_to ) { + if ( strtotime( $compare_from ) >= strtotime( $compare_to ) ) { + return new WP_Error( + 'invalid_compare_date_range', + __( 'The "compare_from" date must be before the "compare_to" date.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Create a CSV export. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response|WP_Error + */ + public function create_export( WP_REST_Request $request ) { + $report_type = $request->get_param( 'report_type' ); + $delivery_method = $request->get_param( 'delivery_method' ); + + $controller = $this->registry->get_controller( $report_type ); + if ( is_wp_error( $controller ) ) { + return $controller; + } + + $params = array( + 'from' => $request->get_param( 'from' ), + 'to' => $request->get_param( 'to' ), + 'interval' => $request->get_param( 'interval' ), + 'compare_from' => $request->get_param( 'compare_from' ), + 'compare_to' => $request->get_param( 'compare_to' ), + ); + + if ( 'email' === $delivery_method ) { + return $this->schedule_email_export( $report_type, $params ); + } + + return $this->generate_download_export( $report_type, $params ); + } + + /** + * Fetch, generate, and stream the CSV for direct download. + * + * @param string $report_type The report type. + * @param array $params Request parameters. + * @return WP_REST_Response|WP_Error + */ + private function generate_download_export( string $report_type, array $params ) { + $controller = $this->registry->get_controller( $report_type ); + if ( is_wp_error( $controller ) ) { + return $controller; + } + + $data = $this->data_fetcher->fetch( $params, $controller ); + if ( is_wp_error( $data ) ) { + return $data; + } + + $is_comparison = $this->is_comparison_request( $params ); + $interval = $params['interval'] ?? null; + + $columns = $this->registry->get_columns( $report_type, $is_comparison, $interval ); + if ( is_wp_error( $columns ) ) { + return $columns; + } + + $formatter = $this->registry->get_row_formatter( $report_type, $interval ); + if ( is_wp_error( $formatter ) ) { + return $formatter; + } + + $filename = $this->registry->build_filename( $report_type, $params ); + $file_path = $this->csv_generator->generate( $data, $columns, $formatter, $filename ); + if ( is_wp_error( $file_path ) ) { + return $file_path; + } + + $streamed = $this->csv_generator->stream_file( $file_path, $filename . '.csv' ); + if ( ! $streamed ) { + $this->csv_generator->delete_file( $file_path ); + return new WP_Error( + 'csv_stream_failed', + __( 'Failed to stream the export file.', 'jetpack-premium-analytics' ), + array( 'status' => 500 ) + ); + } + + $this->csv_generator->delete_file( $file_path ); + exit; + } + + /** + * Schedule an async email export via WP-Cron. + * + * @param string $report_type The report type. + * @param array $params Request parameters. + * @return WP_REST_Response|WP_Error + */ + private function schedule_email_export( string $report_type, array $params ) { + $user = wp_get_current_user(); + $scheduled = $this->scheduler->schedule_export( $report_type, $params, $user->ID, $user->user_email ); + + if ( is_wp_error( $scheduled ) ) { + return $scheduled; + } + + return new WP_REST_Response( + array( + 'success' => true, + 'message' => __( 'Export has been scheduled. You will receive an email when it is ready.', 'jetpack-premium-analytics' ), + ), + 202 + ); + } +} diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-stats-export.php b/projects/packages/premium-analytics/src/Reports/Export/class-stats-export.php new file mode 100644 index 000000000000..fac41b3e1a9e --- /dev/null +++ b/projects/packages/premium-analytics/src/Reports/Export/class-stats-export.php @@ -0,0 +1,74 @@ +is_connected() ) { + return; + } + + self::$initialized = true; + + $logger = new Error_Log_Logger(); + $registry = new Report_Registry(); + $fetcher = new Report_Data_Fetcher( $logger, self::PROXY_PREFIX, self::PROXY_VERSION ); + $generator = new Report_Csv_Generator( $logger ); + $email = new Wp_Mail_Export_Email( $logger ); + $scheduler = new Wp_Cron_Export_Scheduler( $registry, $fetcher, $generator, $email, $logger ); + $controller = new Stats_Csv_Export_Controller( $registry, $fetcher, $generator, $scheduler, $logger ); + + $controller->register(); + $scheduler->register(); + $email->register(); + + ( new Top_Posts_Export_Controller( $registry ) )->register(); + } +} diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php new file mode 100644 index 000000000000..3a27f75cb472 --- /dev/null +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php @@ -0,0 +1,312 @@ +registry = $registry; + $this->data_fetcher = $data_fetcher; + $this->csv_generator = $csv_generator; + $this->email_sender = $email_sender; + $this->logger = $logger; + + if ( null === $this->email_sender->get_logger() ) { + $this->email_sender->set_logger( $this->logger ); + } + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( self::EXPORT_ACTION_HOOK, array( $this, 'process_export_job' ), 10, 4 ); + add_action( self::CLEANUP_HOOK, array( $this, 'cleanup_old_exports' ) ); + add_action( 'init', array( $this, 'schedule_cleanup' ) ); + } + + /** + * Schedule a CSV export job on WP-Cron. + * + * @param string $report_type The report type. + * @param array $params Report parameters. + * @param int $user_id User ID requesting the export. + * @param string $user_email User email for notification. + * @return true|\WP_Error True on success, WP_Error on failure. + */ + public function schedule_export( string $report_type, array $params, int $user_id, string $user_email ) { + if ( ! \is_email( $user_email ) ) { + return new \WP_Error( + 'invalid_email', + __( 'Invalid email address provided.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + if ( ! $this->registry->is_registered( $report_type ) ) { + return new \WP_Error( + 'invalid_report_type', + __( 'Invalid report type.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } + + $scheduled = wp_schedule_single_event( + time(), + self::EXPORT_ACTION_HOOK, + array( $report_type, $params, $user_id, $user_email ) + ); + + if ( true !== $scheduled ) { + $this->logger->log_error( 'Failed to schedule WP-Cron CSV export event', __METHOD__ ); + return new \WP_Error( + 'schedule_failed', + __( 'Failed to schedule export job.', 'jetpack-premium-analytics' ), + array( 'status' => 500 ) + ); + } + + $this->logger->log_message( + sprintf( 'Scheduled WP-Cron CSV export for report type: %s', $report_type ), + __METHOD__ + ); + + return true; + } + + // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing -- Exceptions are caught so WP-Cron can continue processing. + /** + * Process a scheduled export job. + * + * @param string $report_type The report type. + * @param array $params Report parameters. + * @param int $user_id User ID. + * @param string $user_email User email. + * @return void + */ + public function process_export_job( string $report_type, array $params, int $user_id, string $user_email ): void { + $this->logger->log_message( + sprintf( 'Processing WP-Cron CSV export for report type: %s, user: %d', $report_type, $user_id ), + __METHOD__ + ); + + $previous_user_id = \get_current_user_id(); + \wp_set_current_user( $user_id ); + + try { + $controller = $this->registry->get_controller( $report_type ); + if ( is_wp_error( $controller ) ) { + throw new \RuntimeException( $controller->get_error_message() ); + } + + $data = $this->data_fetcher->fetch( $params, $controller ); + if ( is_wp_error( $data ) ) { + throw new \RuntimeException( $data->get_error_message() ); + } + + $is_comparison = $this->is_comparison_request( $params ); + $interval = $params['interval'] ?? null; + + $columns = $this->registry->get_columns( $report_type, $is_comparison, $interval ); + if ( is_wp_error( $columns ) ) { + throw new \RuntimeException( $columns->get_error_message() ); + } + + $formatter = $this->registry->get_row_formatter( $report_type, $interval ); + if ( is_wp_error( $formatter ) ) { + throw new \RuntimeException( $formatter->get_error_message() ); + } + + $filename = $this->registry->build_filename( $report_type, $params ); + $file_path = $this->csv_generator->generate( $data, $columns, $formatter, $filename ); + if ( is_wp_error( $file_path ) ) { + throw new \RuntimeException( $file_path->get_error_message() ); + } + + $report_label = $this->registry->get_label( $report_type ); + if ( is_wp_error( $report_label ) ) { + $report_label = $report_type; + } + + $email_sent = $this->email_sender->send_export_email( $user_email, $report_label, $params, $file_path ); + $this->csv_generator->delete_file( $file_path ); + + if ( ! $email_sent ) { + throw new \RuntimeException( 'Failed to send export email' ); + } + + $this->logger->log_message( + sprintf( 'WP-Cron CSV export completed and emailed to: %s', $user_email ), + __METHOD__ + ); + } catch ( Throwable $e ) { + $this->logger->log_exception( $e, __METHOD__ ); + $this->send_error_email( $user_email, $report_type ); + } finally { + \wp_set_current_user( $previous_user_id ); + } + } + // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.Missing + + /** + * Send a generic export-failure notification. + * + * @param string $user_email User email. + * @param string $report_type Report type. + * @return void + */ + private function send_error_email( string $user_email, string $report_type ): void { + $report_label = $this->registry->get_label( $report_type ); + if ( is_wp_error( $report_label ) ) { + $report_label = $report_type; + } + + $subject = sprintf( + /* translators: %s: Report label */ + __( 'Export Failed: %s', 'jetpack-premium-analytics' ), + $report_label + ); + + $message = sprintf( + /* translators: %s: Report label */ + __( 'Your export for "%s" could not be completed. Please try again later.', 'jetpack-premium-analytics' ), + $report_label + ); + + wp_mail( $user_email, $subject, $message ); + } + + /** + * Schedule daily cleanup of old export files. + * + * @return void + */ + public function schedule_cleanup(): void { + if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) { + wp_schedule_event( time(), 'daily', self::CLEANUP_HOOK ); + } + } + + /** + * Clean up export files older than the retention period. + * + * @return void + */ + public function cleanup_old_exports(): void { + $upload_dir = wp_upload_dir(); + $export_dir = trailingslashit( $upload_dir['basedir'] ) . 'jetpack-premium-analytics-exports'; + + if ( ! is_dir( $export_dir ) ) { + return; + } + + /** + * Filter the CSV export file retention period. + * + * @param int $retention_seconds Retention period in seconds. Default: 48 hours. + */ + $retention = apply_filters( 'jetpack_premium_analytics_csv_export_retention', self::DEFAULT_RETENTION_PERIOD ); + + $files = glob( $export_dir . '/*.csv' ); + if ( ! is_array( $files ) ) { + $files = array(); + } + + $cutoff = time() - $retention; + $deleted = 0; + + foreach ( $files as $file ) { + $mtime = filemtime( $file ); + if ( false !== $mtime && $mtime < $cutoff && wp_delete_file( $file ) ) { + ++$deleted; + } + } + + if ( $deleted > 0 ) { + $this->logger->log_message( sprintf( 'Cleaned up %d old CSV export files', $deleted ), __METHOD__ ); + } + } +} diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php new file mode 100644 index 000000000000..955075c83bcf --- /dev/null +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php @@ -0,0 +1,130 @@ +logger = $logger; + } + } + + /** + * No hooks to register; wp_mail() needs no setup. + * + * @return void + */ + public function register(): void {} + + /** + * Send the export-ready email with the CSV attached. + * + * @param string $recipient Recipient email address. + * @param string $report_label Report label. + * @param array $params Report parameters. + * @param string $file_path Path to the generated CSV file. + * @return bool True if the mail was handed off successfully. + */ + public function send_export_email( + string $recipient, + string $report_label, + array $params, + string $file_path + ): bool { + $file_size = is_readable( $file_path ) ? filesize( $file_path ) : false; + if ( false === $file_size || $file_size >= self::MAX_ATTACHMENT_SIZE ) { + if ( null !== $this->logger ) { + $this->logger->log_error( + sprintf( 'Export file missing or too large to attach: %s', $file_path ), + __METHOD__ + ); + } + return false; + } + + $subject = sprintf( + /* translators: %s: Report name */ + __( 'Your %s export is ready!', 'jetpack-premium-analytics' ), + $report_label + ); + + $sent = wp_mail( + $recipient, + $subject, + $this->build_body( $report_label, $params ), + array( 'Content-Type: text/html; charset=UTF-8' ), + array( $file_path ) + ); + + if ( null !== $this->logger ) { + if ( $sent ) { + $this->logger->log_message( sprintf( 'Export email sent to: %s', $recipient ), __METHOD__ ); + } else { + $this->logger->log_error( sprintf( 'Failed to send export email to: %s', $recipient ), __METHOD__ ); + } + } + + return $sent; + } + + /** + * Build the HTML email body. + * + * @param string $report_label Report label. + * @param array $params Report parameters. + * @return string The HTML body. + */ + private function build_body( string $report_label, array $params ): string { + $heading = sprintf( + /* translators: %s: Report name */ + __( 'Your %s export is ready!', 'jetpack-premium-analytics' ), + $report_label + ); + + $body = '

' . esc_html( $heading ) . '

'; + $body .= '

' . esc_html__( 'The report you requested is attached to this email as a CSV file.', 'jetpack-premium-analytics' ) . '

'; + + if ( ! empty( $params['from'] ) && ! empty( $params['to'] ) ) { + $range = sprintf( + /* translators: 1: Start date, 2: End date */ + __( 'Date range: %1$s to %2$s', 'jetpack-premium-analytics' ), + gmdate( 'F j, Y', strtotime( (string) $params['from'] ) ), + gmdate( 'F j, Y', strtotime( (string) $params['to'] ) ) + ); + $body .= '

' . esc_html( $range ) . '

'; + } + + return $body; + } +} diff --git a/projects/packages/premium-analytics/src/Reports/Export/interface-csv-report-controller-interface.php b/projects/packages/premium-analytics/src/Reports/Export/interface-csv-report-controller-interface.php index 1f3c86824430..d43ab4e7d3ea 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/interface-csv-report-controller-interface.php +++ b/projects/packages/premium-analytics/src/Reports/Export/interface-csv-report-controller-interface.php @@ -164,4 +164,16 @@ public function get_fields(): array; * @return array Additional parameters to include in data requests. */ public function get_additional_params(): array; + + /** + * Transform the per-period request parameters immediately before the data request. + * + * Runs once per fetched period, so comparison periods are mapped independently. + * Lets a report reshape the generic from/to/interval range into the query + * parameters its endpoint expects. Default is identity. + * + * @param array $params The per-period request parameters. + * @return array The transformed parameters. + */ + public function prepare_request_params( array $params ): array; } diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 9f6fce0683c0..67c00305fadf 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -8,6 +8,7 @@ namespace Automattic\Jetpack\PremiumAnalytics; use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Export; +use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Stats_Export; use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller; use Automattic\Jetpack\PremiumAnalytics\REST\Notices_Controller; use Automattic\Jetpack\PremiumAnalytics\Sync\Configuration as Sync_Configuration; @@ -76,6 +77,10 @@ public static function init( $options = array() ) { // WooCommerce being active + Jetpack connected. Export::configure(); + // Jetpack Stats CSV report export pipeline (WOOA7S-1672). This is WooCommerce-free + // and uses WP-Cron/wp_mail for async email delivery. + Stats_Export::configure(); + // Load the widget type registry: hydration routine, registry-time and // runtime filters, and the registry accessors. require_once __DIR__ . '/widget-types.php'; diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php new file mode 100644 index 000000000000..9f129f3b16a2 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php @@ -0,0 +1,79 @@ +controller(); + + $this->assertSame( 'stats-top-posts', $controller->get_report_key() ); + $this->assertSame( 'Top Posts & Pages', $controller->get_report_label() ); + $this->assertSame( 'top-posts', $controller->get_data_endpoint() ); + $this->assertSame( + array( + 'summarize' => 1, + 'max' => 500, + ), + $controller->get_additional_params() + ); + } + + public function test_prepare_request_params_maps_from_to_to_stats_period_shape() { + $params = $this->controller()->prepare_request_params( + array( + 'from' => '2026-01-01T00:00:00', + 'to' => '2026-01-03T00:00:00', + 'interval' => 'day', + 'compare_from' => '2025-01-01T00:00:00', + 'compare_to' => '2025-01-03T00:00:00', + 'summarize' => 1, + ) + ); + + $this->assertSame( 'day', $params['period'] ); + $this->assertSame( '2026-01-03', $params['date'] ); + $this->assertSame( 3, $params['num'] ); + $this->assertSame( 1, $params['summarize'] ); + $this->assertArrayNotHasKey( 'from', $params ); + $this->assertArrayNotHasKey( 'compare_from', $params ); + } + + public function test_format_row_for_csv_maps_stats_postviews_fields() { + $row = $this->controller()->format_row_for_csv( + array( + 'title' => 'Hello World', + 'views' => '42', + 'type' => 'post', + 'href' => 'https://example.com/hello-world/', + ) + ); + + $this->assertSame( + array( + 'post_title' => 'Hello World', + 'views' => 42, + 'type' => 'post', + 'url' => 'https://example.com/hello-world/', + ), + $row + ); + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/Logging/ErrorLogLogger_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/Logging/ErrorLogLogger_Test.php new file mode 100644 index 000000000000..e17555d07e49 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/Logging/ErrorLogLogger_Test.php @@ -0,0 +1,79 @@ +log_file = tempnam( sys_get_temp_dir(), 'pa-error-log-' ); + $this->previous_error_log = ini_get( 'error_log' ); + ini_set( 'error_log', $this->log_file ); + } + + protected function tearDown(): void { + if ( false !== $this->previous_error_log ) { + ini_set( 'error_log', $this->previous_error_log ); + } + + if ( is_string( $this->log_file ) && file_exists( $this->log_file ) ) { + unlink( $this->log_file ); + } + + parent::tearDown(); + } + + public function test_logs_messages_errors_exceptions_and_responses() { + $logger = new Error_Log_Logger(); + + $logger->log_message( 'hello', 'My_Class::method' ); + $logger->log_error( 'boom', 'My_Class::method' ); + $logger->log_exception( new Exception( 'kaboom' ), 'My_Class::method' ); + $logger->log_response( array( 'url' => 'https://example.com/a' ), 'My_Class::method' ); + + $resource = fopen( 'php://temp', 'r' ); + try { + $logger->log_response( array( 'resource' => $resource ), 'My_Class::method' ); + } finally { + fclose( $resource ); + } + + $contents = file_get_contents( $this->log_file ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $this->assertStringContainsString( '[jetpack-premium-analytics] INFO: My_Class::method hello', $contents ); + $this->assertStringContainsString( '[jetpack-premium-analytics] ERROR: My_Class::method boom', $contents ); + $this->assertStringContainsString( '[jetpack-premium-analytics] ERROR: My_Class::method kaboom', $contents ); + $this->assertStringContainsString( '{"url":"https://example.com/a"}', $contents ); + } else { + $this->assertSame( '', $contents ); + } + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/ReportDataFetcher_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/ReportDataFetcher_Test.php index 848be28c26b4..3093f9425004 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/ReportDataFetcher_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/ReportDataFetcher_Test.php @@ -16,6 +16,8 @@ use WP_Error; require_once __DIR__ . '/fixtures/class-spy-logger.php'; +require_once __DIR__ . '/fixtures/class-fake-report-controller.php'; +require_once __DIR__ . '/fixtures/class-fetcher-spy-controller.php'; /** * @covers \Automattic\Jetpack\PremiumAnalytics\Reports\Export\Report_Data_Fetcher @@ -111,4 +113,150 @@ public function test_normalize_response_data_returns_error_when_json_encoding_fa $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( 'proxy_response_encode_failed', $result->get_error_code() ); } + + public function test_fetch_prepares_params_adds_fields_and_uses_controller_fetch_data() { + $controller = new Fetcher_Spy_Controller( new Report_Registry() ); + + $result = $this->fetcher->fetch( + array( + 'from' => '2026-01-01T00:00:00', + 'to' => '2026-01-02T00:00:00', + 'overridden' => 'request', + ), + $controller + ); + + $this->assertSame( + array( + array( + 'bucket' => 'A', + 'count' => 10, + ), + ), + $result['data'] + ); + $this->assertCount( 1, $controller->requests ); + $this->assertSame( 'reports/fake-report', $controller->requests[0]['endpoint'] ); + $this->assertTrue( $controller->requests[0]['params']['prepared'] ); + $this->assertSame( 100, $controller->requests[0]['params']['max'] ); + $this->assertSame( 'request', $controller->requests[0]['params']['overridden'] ); + $this->assertSame( array( 'bucket', 'count' ), $controller->requests[0]['params']['fields'] ); + } + + public function test_fetch_retries_without_fields_when_endpoint_rejects_fields_param() { + $controller = new Fetcher_Spy_Controller( new Report_Registry() ); + $controller->results = array( + new WP_Error( + 'rest_invalid_param', + 'Invalid parameter(s): fields', + array( + 'params' => array( + 'fields' => 'fields is not one of the accepted values.', + ), + ) + ), + array( + 'data' => array( + array( + 'bucket' => 'B', + 'count' => 3, + ), + ), + ), + ); + + $result = $this->fetcher->fetch( + array( + 'from' => '2026-01-01T00:00:00', + 'to' => '2026-01-02T00:00:00', + ), + $controller + ); + + $this->assertSame( + array( + array( + 'bucket' => 'B', + 'count' => 3, + ), + ), + $result['data'] + ); + $this->assertCount( 2, $controller->requests ); + $this->assertArrayHasKey( 'fields', $controller->requests[0]['params'] ); + $this->assertArrayNotHasKey( 'fields', $controller->requests[1]['params'] ); + } + + public function test_fetch_returns_error_when_comparison_params_are_missing() { + $controller = new Fetcher_Spy_Controller( new Report_Registry() ); + + $result = $this->fetcher->fetch( + array( + 'from' => '2026-01-01T00:00:00', + 'compare_from' => '2025-01-01T00:00:00', + 'compare_to' => '2025-01-02T00:00:00', + ), + $controller + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'missing_comparison_param', $result->get_error_code() ); + } + + public function test_make_proxy_request_uses_configured_stats_route_and_summary_rows() { + $fetcher = new class( new Spy_Logger(), 'stats', '1.1' ) extends Report_Data_Fetcher { + public function proxy( string $endpoint, array $params ) { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing -- Test exposure. + return $this->make_proxy_request( $endpoint, $params ); + } + }; + + add_action( + 'rest_api_init', + static function () { + register_rest_route( + 'jetpack-premium-analytics/v1', + '/proxy/v1.1/stats/top-posts', + array( + 'methods' => 'GET', + 'callback' => static function ( \WP_REST_Request $request ) { + return rest_ensure_response( + array( + 'summary' => array( + 'posts' => array( + array( + 'title' => 'Hello World', + 'views' => 12, + ), + ), + ), + 'query' => $request->get_query_params(), + ) + ); + }, + 'permission_callback' => '__return_true', + ) + ); + } + ); + do_action( 'rest_api_init' ); + + $result = $fetcher->proxy( + 'top-posts', + array( + 'endpoint' => 'ignored', + 'period' => 'day', + ) + ); + + $this->assertSame( + array( + array( + 'title' => 'Hello World', + 'views' => 12, + ), + ), + $result['data'] + ); + $this->assertSame( array( 'period' => 'day' ), $result['query'] ); + } } diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php new file mode 100644 index 000000000000..86640261ca7c --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php @@ -0,0 +1,320 @@ +register_controller( new Top_Posts_Export_Controller( $registry ) ); + + $logger = new Spy_Logger(); + $fetcher = new Fake_Fetcher( $logger ); + $fetcher->result = array( + 'data' => array( + array( + 'title' => 'Hello', + 'views' => 4, + ), + ), + ); + $this->scheduler = new Fake_Wp_Cron_Scheduler(); + $this->generator = new Fake_Generator( $logger ); + $this->controller = new Stats_Csv_Export_Controller( + $registry, + $fetcher, + $this->generator, + $this->scheduler, + $logger + ); + } + + /** + * @after + */ + #[After] + public function tear_down() { + wp_set_current_user( 0 ); + remove_action( 'rest_api_init', array( $this->controller, 'register_routes' ) ); + } + + public function test_register_hooks_route_registration() { + $this->controller->register(); + + $this->assertNotFalse( has_action( 'rest_api_init', array( $this->controller, 'register_routes' ) ) ); + } + + public function test_register_routes_exposes_expected_endpoint_args() { + $this->controller->register(); + do_action( 'rest_api_init' ); + + $route = '/jetpack-premium-analytics/v1/reports/stats-csv-export'; + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( $route, $routes ); + $this->assertArrayHasKey( \WP_REST_Server::CREATABLE, $routes[ $route ][0]['methods'] ); + $this->assertArrayHasKey( 'report_type', $routes[ $route ][0]['args'] ); + $this->assertArrayHasKey( 'from', $routes[ $route ][0]['args'] ); + $this->assertArrayHasKey( 'to', $routes[ $route ][0]['args'] ); + $this->assertSame( array( 'download', 'email' ), $routes[ $route ][0]['args']['delivery_method']['enum'] ); + } + + public function test_validate_report_type() { + $this->assertTrue( $this->controller->validate_report_type( 'stats-top-posts' ) ); + + $error = $this->controller->validate_report_type( 'nope' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'invalid_report_type', $error->get_error_code() ); + + $this->assertInstanceOf( \WP_Error::class, $this->controller->validate_report_type( array( 'x' ) ) ); + } + + public function test_check_permission_allows_view_stats_capability() { + $user_id = wp_insert_user( + array( + 'user_login' => 'stats_exporter', + 'user_pass' => 'pass', + 'role' => 'subscriber', + ) + ); + $this->assertIsInt( $user_id ); + + $user = new \WP_User( $user_id ); + $user->add_cap( 'view_stats' ); + wp_set_current_user( $user_id ); + + $this->assertTrue( $this->controller->check_permission() ); + } + + public function test_check_permission_allows_manage_options_and_denies_anonymous() { + $this->assertFalse( $this->controller->check_permission() ); + + $user_id = wp_insert_user( + array( + 'user_login' => 'stats_export_admin', + 'user_pass' => 'pass', + 'role' => 'administrator', + ) + ); + $this->assertIsInt( $user_id ); + + wp_set_current_user( $user_id ); + + $this->assertTrue( $this->controller->check_permission() ); + } + + public function test_validate_date_ranges() { + $request = new WP_REST_Request(); + $request->set_param( 'to', '2026-01-02T00:00:00' ); + + $this->assertTrue( $this->controller->validate_from_date( '2026-01-01T00:00:00', $request, 'from' ) ); + + $error = $this->controller->validate_from_date( '2026-01-03T00:00:00', $request, 'from' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'invalid_date_range', $error->get_error_code() ); + + $request = new WP_REST_Request(); + $request->set_param( 'from', '2026-01-01T00:00:00' ); + $this->assertTrue( $this->controller->validate_to_date( '2026-01-02T00:00:00', $request, 'to' ) ); + + $error = $this->controller->validate_to_date( '2026-01-01T00:00:00', $request, 'to' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'invalid_date_range', $error->get_error_code() ); + + $error = $this->controller->validate_to_date( gmdate( 'Y-m-d\T00:00:00', strtotime( '+1 day' ) ), $request, 'to' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'future_date', $error->get_error_code() ); + } + + public function test_validate_comparison_date_ranges() { + $request = new WP_REST_Request(); + $request->set_param( 'compare_to', '2025-01-02T00:00:00' ); + + $this->assertTrue( $this->controller->validate_compare_from_date( '2025-01-01T00:00:00', $request, 'compare_from' ) ); + + $error = $this->controller->validate_compare_from_date( '2025-01-03T00:00:00', $request, 'compare_from' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'invalid_compare_date_range', $error->get_error_code() ); + + $request = new WP_REST_Request(); + $error = $this->controller->validate_compare_from_date( '2025-01-01T00:00:00', $request, 'compare_from' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'missing_compare_to', $error->get_error_code() ); + + $request = new WP_REST_Request(); + $request->set_param( 'compare_from', '2025-01-01T00:00:00' ); + + $this->assertTrue( $this->controller->validate_compare_to_date( '2025-01-02T00:00:00', $request, 'compare_to' ) ); + + $error = $this->controller->validate_compare_to_date( '2025-01-01T00:00:00', $request, 'compare_to' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'invalid_compare_date_range', $error->get_error_code() ); + + $error = $this->controller->validate_compare_to_date( gmdate( 'Y-m-d\T00:00:00', strtotime( '+1 day' ) ), $request, 'compare_to' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'future_date', $error->get_error_code() ); + + $request = new WP_REST_Request(); + $error = $this->controller->validate_compare_to_date( '2025-01-02T00:00:00', $request, 'compare_to' ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'missing_compare_from', $error->get_error_code() ); + } + + public function test_create_export_returns_registry_error_for_unknown_report() { + $request = new WP_REST_Request(); + $request->set_param( 'report_type', 'missing-report' ); + $request->set_param( 'delivery_method', 'download' ); + + $result = $this->controller->create_export( $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'invalid_report_type', $result->get_error_code() ); + } + + public function test_create_export_email_path_schedules_wp_cron_job() { + wp_set_current_user( + wp_insert_user( + array( + 'user_login' => 'stats_exporter_email', + 'user_pass' => 'pass', + 'user_email' => 'stats-exporter@example.com', + 'role' => 'administrator', + ) + ) + ); + + $request = new WP_REST_Request(); + $request->set_param( 'report_type', 'stats-top-posts' ); + $request->set_param( 'delivery_method', 'email' ); + $request->set_param( 'from', '2026-01-01T00:00:00' ); + $request->set_param( 'to', '2026-01-03T00:00:00' ); + + $response = $this->controller->create_export( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 202, $response->get_status() ); + $this->assertTrue( $response->get_data()['success'] ); + $this->assertCount( 1, $this->scheduler->calls ); + $this->assertSame( 'stats-top-posts', $this->scheduler->calls[0]['report_type'] ); + $this->assertSame( 'stats-exporter@example.com', $this->scheduler->calls[0]['user_email'] ); + } + + public function test_create_export_email_path_returns_scheduler_error() { + $this->scheduler->return_value = new \WP_Error( 'schedule_failed', 'Nope' ); + + wp_set_current_user( + wp_insert_user( + array( + 'user_login' => 'stats_exporter_email_failure', + 'user_pass' => 'pass', + 'user_email' => 'stats-exporter-failure@example.com', + 'role' => 'administrator', + ) + ) + ); + + $request = new WP_REST_Request(); + $request->set_param( 'report_type', 'stats-top-posts' ); + $request->set_param( 'delivery_method', 'email' ); + $request->set_param( 'from', '2026-01-01T00:00:00' ); + $request->set_param( 'to', '2026-01-03T00:00:00' ); + + $result = $this->controller->create_export( $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'schedule_failed', $result->get_error_code() ); + } + + public function test_create_export_download_path_returns_stream_error_without_exiting() { + $this->generator->stream_result = false; + + $request = new WP_REST_Request(); + $request->set_param( 'report_type', 'stats-top-posts' ); + $request->set_param( 'delivery_method', 'download' ); + $request->set_param( 'from', '2026-01-01T00:00:00' ); + $request->set_param( 'to', '2026-01-03T00:00:00' ); + + $result = $this->controller->create_export( $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'csv_stream_failed', $result->get_error_code() ); + $this->assertCount( 1, $this->generator->streams ); + $this->assertSame( 'top-posts-pages-2026-01-01-to-2026-01-03.csv', $this->generator->streams[0]['filename'] ); + } + + public function test_create_export_download_path_returns_fetch_error() { + $registry = new Report_Registry(); + $registry->register_controller( new Top_Posts_Export_Controller( $registry ) ); + + $logger = new Spy_Logger(); + $fetcher = new Fake_Fetcher( $logger ); + $fetcher->result = new \WP_Error( 'fetch_failed', 'Fetch failed' ); + + $controller = new Stats_Csv_Export_Controller( + $registry, + $fetcher, + $this->generator, + $this->scheduler, + $logger + ); + + $request = new WP_REST_Request(); + $request->set_param( 'report_type', 'stats-top-posts' ); + $request->set_param( 'delivery_method', 'download' ); + $request->set_param( 'from', '2026-01-01T00:00:00' ); + $request->set_param( 'to', '2026-01-03T00:00:00' ); + + $result = $controller->create_export( $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'fetch_failed', $result->get_error_code() ); + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php new file mode 100644 index 000000000000..d527e2454bc0 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php @@ -0,0 +1,146 @@ +set_initialized( false ); + $this->remove_hook_objects( 'rest_api_init', array( Stats_Csv_Export_Controller::class ) ); + $this->remove_hook_objects( Wp_Cron_Export_Scheduler::EXPORT_ACTION_HOOK, array( Wp_Cron_Export_Scheduler::class ) ); + $this->remove_hook_objects( Wp_Cron_Export_Scheduler::CLEANUP_HOOK, array( Wp_Cron_Export_Scheduler::class ) ); + $this->remove_hook_objects( 'init', array( Wp_Cron_Export_Scheduler::class ) ); + + \Jetpack_Options::delete_option( array( 'id', 'blog_token' ) ); + ( new Manager( Stats_Export::SLUG ) )->reset_connection_status(); + } + + public function test_configure_returns_without_connection() { + \Jetpack_Options::delete_option( array( 'id', 'blog_token' ) ); + ( new Manager( Stats_Export::SLUG ) )->reset_connection_status(); + + Stats_Export::configure(); + + $this->assertFalse( $this->has_hook_object( 'rest_api_init', Stats_Csv_Export_Controller::class, 'register_routes' ) ); + } + + public function test_configure_registers_stats_export_services_once_when_connected() { + \Jetpack_Options::update_option( 'id', 1234 ); + \Jetpack_Options::update_option( 'blog_token', 'test.secret' ); + ( new Manager( Stats_Export::SLUG ) )->reset_connection_status(); + + Stats_Export::configure(); + + $this->assertTrue( $this->has_hook_object( 'rest_api_init', Stats_Csv_Export_Controller::class, 'register_routes' ) ); + $this->assertTrue( $this->has_hook_object( Wp_Cron_Export_Scheduler::EXPORT_ACTION_HOOK, Wp_Cron_Export_Scheduler::class, 'process_export_job' ) ); + $this->assertTrue( $this->has_hook_object( Wp_Cron_Export_Scheduler::CLEANUP_HOOK, Wp_Cron_Export_Scheduler::class, 'cleanup_old_exports' ) ); + $this->assertTrue( $this->has_hook_object( 'init', Wp_Cron_Export_Scheduler::class, 'schedule_cleanup' ) ); + + $rest_callback_count = $this->count_hook_objects( 'rest_api_init', Stats_Csv_Export_Controller::class, 'register_routes' ); + + Stats_Export::configure(); + + $this->assertSame( $rest_callback_count, $this->count_hook_objects( 'rest_api_init', Stats_Csv_Export_Controller::class, 'register_routes' ) ); + } + + /** + * Set the private initialized guard. + * + * @param bool $initialized Whether initialized. + * @return void + */ + private function set_initialized( bool $initialized ): void { + $property = new ReflectionProperty( Stats_Export::class, 'initialized' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( null, $initialized ); + } + + /** + * Determine whether a hook contains an object method callback. + * + * @param string $hook Hook name. + * @param string $class Object class. + * @param string $method Method name. + * @return bool + */ + private function has_hook_object( string $hook, string $class, string $method ): bool { + return $this->count_hook_objects( $hook, $class, $method ) > 0; + } + + /** + * Count object method callbacks on a hook. + * + * @param string $hook Hook name. + * @param string $class Object class. + * @param string $method Method name. + * @return int + */ + private function count_hook_objects( string $hook, string $class, string $method ): int { + global $wp_filter; + + if ( empty( $wp_filter[ $hook ] ) ) { + return 0; + } + + $count = 0; + foreach ( $wp_filter[ $hook ]->callbacks as $callbacks ) { + foreach ( $callbacks as $callback ) { + $function = $callback['function']; + if ( is_array( $function ) && is_object( $function[0] ) && is_a( $function[0], $class ) && $method === $function[1] ) { + ++$count; + } + } + } + + return $count; + } + + /** + * Remove object callbacks for the given classes. + * + * @param string $hook Hook name. + * @param array $classes Classes to remove. + * @return void + */ + private function remove_hook_objects( string $hook, array $classes ): void { + global $wp_filter; + + if ( empty( $wp_filter[ $hook ] ) ) { + return; + } + + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + $function = $callback['function']; + if ( is_array( $function ) && is_object( $function[0] ) ) { + foreach ( $classes as $class ) { + if ( is_a( $function[0], $class ) ) { + remove_action( $hook, $function, $priority ); + } + } + } + } + } + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php new file mode 100644 index 000000000000..2afe50a2700b --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php @@ -0,0 +1,149 @@ +register_controller( new Top_Posts_Export_Controller( $registry ) ); + + $logger = $logger ?? new Spy_Logger(); + $fetcher = new Fake_Fetcher( $logger ); + $fetcher->result = null === $fetch_result ? array( + 'data' => array( + array( + 'title' => 'Hello', + 'views' => 3, + ), + ), + ) : $fetch_result; + + return new Wp_Cron_Export_Scheduler( + $registry, + $fetcher, + new Fake_Generator( $logger ), + $email ?? new Fake_Wp_Mail_Email( $logger ), + $logger + ); + } + + public function test_schedule_export_rejects_invalid_email() { + $result = $this->scheduler()->schedule_export( 'stats-top-posts', array(), 1, 'not-an-email' ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'invalid_email', $result->get_error_code() ); + } + + public function test_schedule_export_rejects_unknown_report_type() { + $result = $this->scheduler()->schedule_export( 'nope', array(), 1, 'admin@example.com' ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'invalid_report_type', $result->get_error_code() ); + } + + public function test_schedule_export_schedules_single_wp_cron_event() { + $result = $this->scheduler()->schedule_export( + 'stats-top-posts', + array( 'from' => '2026-01-01T00:00:00' ), + 1, + 'admin@example.com' + ); + + $this->assertTrue( $result ); + $this->assertIsInt( + wp_next_scheduled( + Wp_Cron_Export_Scheduler::EXPORT_ACTION_HOOK, + array( + 'stats-top-posts', + array( 'from' => '2026-01-01T00:00:00' ), + 1, + 'admin@example.com', + ) + ) + ); + } + + public function test_process_export_job_emails_attachment_and_restores_user() { + $email = new Fake_Wp_Mail_Email(); + $scheduler = $this->scheduler( null, $email ); + + wp_set_current_user( 0 ); + $scheduler->process_export_job( + 'stats-top-posts', + array( + 'from' => '2026-01-01T00:00:00', + 'to' => '2026-01-03T00:00:00', + 'interval' => 'day', + ), + 1, + 'admin@example.com' + ); + + $this->assertCount( 1, $email->sends ); + $this->assertSame( 'admin@example.com', $email->sends[0]['recipient'] ); + $this->assertSame( 'Top Posts & Pages', $email->sends[0]['report_label'] ); + $this->assertSame( 0, get_current_user_id() ); + } + + public function test_process_export_job_logs_failure_sends_error_email_and_does_not_throw() { + $logger = new Spy_Logger(); + $email = new Fake_Wp_Mail_Email(); + $scheduler = $this->scheduler( new \WP_Error( 'boom', 'fetch failed' ), $email, $logger ); + + $error_mail = array(); + $capture = static function ( $return, $atts ) use ( &$error_mail ) { + $error_mail = $atts; + return true; + }; + + wp_set_current_user( 0 ); + add_filter( 'pre_wp_mail', $capture, 10, 2 ); + try { + $scheduler->process_export_job( + 'stats-top-posts', + array( + 'from' => '2026-01-01T00:00:00', + 'to' => '2026-01-03T00:00:00', + ), + 1, + 'admin@example.com' + ); + } finally { + remove_filter( 'pre_wp_mail', $capture ); + } + + $this->assertCount( 0, $email->sends ); + $this->assertSame( 'exception', $logger->entries[1]['level'] ); + $this->assertSame( 'fetch failed', $logger->entries[1]['message'] ); + $this->assertSame( 'admin@example.com', $error_mail['to'] ); + $this->assertSame( 0, get_current_user_id() ); + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php new file mode 100644 index 000000000000..444997f61515 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php @@ -0,0 +1,102 @@ +files as $file ) { + if ( file_exists( $file ) ) { + wp_delete_file( $file ); + } + } + $this->files = array(); + } + + private function make_file( string $contents ): string { + $file = trailingslashit( sys_get_temp_dir() ) . 'pa-wp-mail-test-' . wp_generate_password( 8, false ) . '.csv'; + $this->files[] = $file; + file_put_contents( $file, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + return $file; + } + + public function test_send_export_email_attaches_csv_and_logs_success() { + $logger = new Spy_Logger(); + $email = new Wp_Mail_Export_Email( $logger ); + $file = $this->make_file( "Title,Views\nHello,5\n" ); + $sent = array(); + + $capture = static function ( $return, $atts ) use ( &$sent ) { + $sent = $atts; + return true; + }; + + add_filter( 'pre_wp_mail', $capture, 10, 2 ); + try { + $result = $email->send_export_email( + 'admin@example.com', + 'Top Posts & Pages', + array( + 'from' => '2026-01-01T00:00:00', + 'to' => '2026-01-03T00:00:00', + ), + $file + ); + } finally { + remove_filter( 'pre_wp_mail', $capture ); + } + + $this->assertTrue( $result ); + $this->assertSame( 'admin@example.com', $sent['to'] ); + $this->assertSame( array( $file ), $sent['attachments'] ); + $this->assertSame( 'message', $logger->entries[0]['level'] ); + } + + public function test_send_export_email_fails_when_file_missing() { + $logger = new Spy_Logger(); + $email = new Wp_Mail_Export_Email( $logger ); + + $this->assertFalse( $email->send_export_email( 'admin@example.com', 'Top Posts', array(), '/does/not/exist.csv' ) ); + $this->assertSame( 'error', $logger->entries[0]['level'] ); + } + + public function test_send_export_email_fails_when_file_is_too_large() { + $logger = new Spy_Logger(); + $email = new Wp_Mail_Export_Email( $logger ); + $file = $this->make_file( 'x' ); + + $handle = fopen( $file, 'c' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + fseek( $handle, Wp_Mail_Export_Email::MAX_ATTACHMENT_SIZE ); + fwrite( $handle, 'x' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite + fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + + $this->assertFalse( $email->send_export_email( 'admin@example.com', 'Top Posts', array(), $file ) ); + $this->assertSame( 'error', $logger->entries[0]['level'] ); + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-generator.php b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-generator.php index e6b99ed4ee73..6575ed17a1ff 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-generator.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-generator.php @@ -20,6 +20,20 @@ class Fake_Generator extends Report_Csv_Generator { */ public $result = '/tmp/pa-fake-export.csv'; + /** + * Value stream_file() returns. + * + * @var bool + */ + public $stream_result = true; + + /** + * Recorded stream_file() calls. + * + * @var array[] + */ + public $streams = array(); + public function generate( array $data, array $columns, callable $formatter, string $filename = '' ) { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing -- Test double. return $this->result; } @@ -27,4 +41,12 @@ public function generate( array $data, array $columns, callable $formatter, stri public function delete_file( string $file_path ): bool { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing -- Test double. return true; } + + public function stream_file( string $file_path, string $filename = '' ): bool { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing -- Test double. + $this->streams[] = array( + 'file_path' => $file_path, + 'filename' => $filename, + ); + return $this->stream_result; + } } diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-cron-scheduler.php b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-cron-scheduler.php new file mode 100644 index 000000000000..c2510566b815 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-cron-scheduler.php @@ -0,0 +1,52 @@ +calls[] = array( + 'report_type' => $report_type, + 'params' => $params, + 'user_id' => $user_id, + 'user_email' => $user_email, + ); + return $this->return_value; + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-mail-email.php b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-mail-email.php new file mode 100644 index 000000000000..d2481c94d32a --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fake-wp-mail-email.php @@ -0,0 +1,38 @@ +sends[] = array( + 'recipient' => $recipient, + 'report_label' => $report_label, + 'params' => $params, + 'file_path' => $file_path, + ); + return $this->return; + } +} diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fetcher-spy-controller.php b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fetcher-spy-controller.php new file mode 100644 index 000000000000..599692338a26 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/fixtures/class-fetcher-spy-controller.php @@ -0,0 +1,103 @@ + 100, + 'overridden' => 'default', + ); + } + + /** + * Fields requested from the data source. + * + * @return array + */ + public function get_fields(): array { + return array( 'bucket', 'count' ); + } + + /** + * Transform request params immediately before fetching. + * + * @param array $params Request params. + * @return array + */ + public function prepare_request_params( array $params ): array { + $params['prepared'] = true; + return $params; + } + + /** + * Optional custom data fetcher hook. + * + * @param string $endpoint Endpoint. + * @param array $params Request params. + * @return mixed + */ + public function fetch_data( string $endpoint, array $params ) { + $this->requests[] = array( + 'endpoint' => $endpoint, + 'params' => $params, + ); + + if ( ! empty( $this->results ) ) { + return array_shift( $this->results ); + } + + return array( + 'data' => array( + array( + 'bucket' => 'A', + 'count' => 10, + ), + ), + ); + } + + /** + * Get the matching field for comparison data alignment. + * + * @return string|null + */ + public function get_matching_field(): ?string { + return $this->matching_field; + } +} From 67a7b7460dd85a0c3c5f3a2dc3ae3cedab667e80 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:11:16 +0200 Subject: [PATCH 02/14] Premium Analytics: derive Stats export dates from calendar range --- .../class-top-posts-export-controller.php | 75 +++++++++++++++++-- .../Exports/TopPostsExportController_Test.php | 13 ++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php index 6d8ed1566cc5..257a60b8ed6b 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/Exports/class-top-posts-export-controller.php @@ -76,14 +76,21 @@ public function get_additional_params(): array { * @return array The Stats-native parameters. */ public function prepare_request_params( array $params ): array { - $to_ts = ! empty( $params['to'] ) ? strtotime( (string) $params['to'] ) : time(); - $from_ts = ! empty( $params['from'] ) ? strtotime( (string) $params['from'] ) : strtotime( '-' . self::DEFAULT_DAYS . ' days', $to_ts ); + $to_date = $this->extract_request_date( $params['to'] ?? null ); + $from_date = $this->extract_request_date( $params['from'] ?? null ); - $days = (int) floor( ( $to_ts - $from_ts ) / DAY_IN_SECONDS ) + 1; - $days = max( 1, $days ); + if ( null === $to_date ) { + $to_date = gmdate( 'Y-m-d' ); + } + + if ( null === $from_date ) { + $from_date = $this->shift_date( $to_date, '-' . ( self::DEFAULT_DAYS - 1 ) . ' days' ); + } + + $days = $this->count_inclusive_days( $from_date, $to_date ); $params['period'] = 'day'; - $params['date'] = gmdate( 'Y-m-d', $to_ts ); + $params['date'] = $to_date; $params['num'] = $days; unset( $params['from'], $params['to'], $params['interval'], $params['compare_from'], $params['compare_to'] ); @@ -91,6 +98,64 @@ public function prepare_request_params( array $params ): array { return $params; } + /** + * Extract the request calendar date without applying timezone conversion. + * + * @param mixed $value Date-like request value. + * @return string|null The YYYY-MM-DD date, or null when unavailable. + */ + private function extract_request_date( $value ): ?string { + if ( ! is_scalar( $value ) ) { + return null; + } + + $date = (string) $value; + if ( preg_match( '/^(\d{4}-\d{2}-\d{2})/', $date, $matches ) ) { + return $matches[1]; + } + + $timestamp = strtotime( $date ); + if ( false === $timestamp ) { + return null; + } + + return gmdate( 'Y-m-d', $timestamp ); + } + + /** + * Shift a YYYY-MM-DD date by a relative interval. + * + * @param string $date Date in YYYY-MM-DD format. + * @param string $modifier Relative date modifier. + * @return string Shifted date in YYYY-MM-DD format. + */ + private function shift_date( string $date, string $modifier ): string { + $date_time = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date, new \DateTimeZone( 'UTC' ) ); + if ( false === $date_time ) { + return gmdate( 'Y-m-d' ); + } + + return $date_time->modify( $modifier )->format( 'Y-m-d' ); + } + + /** + * Count calendar days in an inclusive YYYY-MM-DD range. + * + * @param string $from_date Start date in YYYY-MM-DD format. + * @param string $to_date End date in YYYY-MM-DD format. + * @return int Inclusive day count. + */ + private function count_inclusive_days( string $from_date, string $to_date ): int { + $from = \DateTimeImmutable::createFromFormat( '!Y-m-d', $from_date, new \DateTimeZone( 'UTC' ) ); + $to = \DateTimeImmutable::createFromFormat( '!Y-m-d', $to_date, new \DateTimeZone( 'UTC' ) ); + + if ( false === $from || false === $to ) { + return 1; + } + + return max( 1, (int) $from->diff( $to )->format( '%r%a' ) + 1 ); + } + /** * Get the column headers for this controller. * diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php index 9f129f3b16a2..83d9c5d3a4b2 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/Exports/TopPostsExportController_Test.php @@ -56,6 +56,19 @@ public function test_prepare_request_params_maps_from_to_to_stats_period_shape() $this->assertArrayNotHasKey( 'compare_from', $params ); } + public function test_prepare_request_params_uses_calendar_dates_without_timezone_shift() { + $params = $this->controller()->prepare_request_params( + array( + 'from' => '2026-01-01T23:00:00-05:00', + 'to' => '2026-01-03T01:00:00+09:00', + 'interval' => 'day', + ) + ); + + $this->assertSame( '2026-01-03', $params['date'] ); + $this->assertSame( 3, $params['num'] ); + } + public function test_format_row_for_csv_maps_stats_postviews_fields() { $row = $this->controller()->format_row_for_csv( array( From 9de9135556ecf1afd8f86f4a595dff5c40687cdd Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:11:49 +0200 Subject: [PATCH 03/14] Premium Analytics: format export email dates from request dates --- .../Export/class-wp-mail-export-email.php | 33 ++++++++++- .../Reports/Export/WPMailExportEmail_Test.php | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php index 955075c83bcf..e07c6a8c5fce 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-mail-export-email.php @@ -115,16 +115,43 @@ private function build_body( string $report_label, array $params ): string { $body = '

' . esc_html( $heading ) . '

'; $body .= '

' . esc_html__( 'The report you requested is attached to this email as a CSV file.', 'jetpack-premium-analytics' ) . '

'; - if ( ! empty( $params['from'] ) && ! empty( $params['to'] ) ) { + $from_date = $this->format_request_date( $params['from'] ?? null ); + $to_date = $this->format_request_date( $params['to'] ?? null ); + + if ( null !== $from_date && null !== $to_date ) { $range = sprintf( /* translators: 1: Start date, 2: End date */ __( 'Date range: %1$s to %2$s', 'jetpack-premium-analytics' ), - gmdate( 'F j, Y', strtotime( (string) $params['from'] ) ), - gmdate( 'F j, Y', strtotime( (string) $params['to'] ) ) + $from_date, + $to_date ); $body .= '

' . esc_html( $range ) . '

'; } return $body; } + + /** + * Format a request date without applying timezone conversion. + * + * @param mixed $value Date-like request value. + * @return string|null Formatted date, or null when unavailable. + */ + private function format_request_date( $value ): ?string { + if ( ! is_scalar( $value ) ) { + return null; + } + + $date = (string) $value; + if ( ! preg_match( '/^(\d{4}-\d{2}-\d{2})/', $date, $matches ) ) { + return null; + } + + $date_time = \DateTimeImmutable::createFromFormat( '!Y-m-d', $matches[1], new \DateTimeZone( 'UTC' ) ); + if ( false === $date_time ) { + return null; + } + + return $date_time->format( 'F j, Y' ); + } } diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php index 444997f61515..1248b8cdc3e6 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php @@ -78,6 +78,64 @@ public function test_send_export_email_attaches_csv_and_logs_success() { $this->assertSame( 'message', $logger->entries[0]['level'] ); } + public function test_send_export_email_formats_request_dates_without_timezone_shift() { + $email = new Wp_Mail_Export_Email(); + $file = $this->make_file( "Title,Views\nHello,5\n" ); + $sent = array(); + + $capture = static function ( $return, $atts ) use ( &$sent ) { + $sent = $atts; + return true; + }; + + add_filter( 'pre_wp_mail', $capture, 10, 2 ); + try { + $result = $email->send_export_email( + 'admin@example.com', + 'Top Posts & Pages', + array( + 'from' => '2026-01-01T23:00:00-05:00', + 'to' => '2026-01-03T01:00:00+09:00', + ), + $file + ); + } finally { + remove_filter( 'pre_wp_mail', $capture ); + } + + $this->assertTrue( $result ); + $this->assertStringContainsString( 'Date range: January 1, 2026 to January 3, 2026', $sent['message'] ); + } + + public function test_send_export_email_omits_date_range_when_request_dates_are_invalid() { + $email = new Wp_Mail_Export_Email(); + $file = $this->make_file( "Title,Views\nHello,5\n" ); + $sent = array(); + + $capture = static function ( $return, $atts ) use ( &$sent ) { + $sent = $atts; + return true; + }; + + add_filter( 'pre_wp_mail', $capture, 10, 2 ); + try { + $result = $email->send_export_email( + 'admin@example.com', + 'Top Posts & Pages', + array( + 'from' => 'not-a-date', + 'to' => '2026-01-03T00:00:00', + ), + $file + ); + } finally { + remove_filter( 'pre_wp_mail', $capture ); + } + + $this->assertTrue( $result ); + $this->assertStringNotContainsString( 'Date range:', $sent['message'] ); + } + public function test_send_export_email_fails_when_file_missing() { $logger = new Spy_Logger(); $email = new Wp_Mail_Export_Email( $logger ); From 6c051ce92512b6f786895aa698d953a7ca649e66 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:12:40 +0200 Subject: [PATCH 04/14] Premium Analytics: preserve WP-Cron scheduling failures --- .../Export/class-wp-cron-export-scheduler.php | 31 ++++++++++++- .../Export/WPCronExportScheduler_Test.php | 46 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php index 3a27f75cb472..8c5f5aa43ccb 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php @@ -134,13 +134,42 @@ public function schedule_export( string $report_type, array $params, int $user_i ); } + $args = array( $report_type, $params, $user_id, $user_email ); $scheduled = wp_schedule_single_event( time(), self::EXPORT_ACTION_HOOK, - array( $report_type, $params, $user_id, $user_email ) + $args, + true ); + if ( is_wp_error( $scheduled ) ) { + if ( 'duplicate_event' === $scheduled->get_error_code() && wp_next_scheduled( self::EXPORT_ACTION_HOOK, $args ) ) { + $this->logger->log_message( + sprintf( 'WP-Cron CSV export already scheduled for report type: %s', $report_type ), + __METHOD__ + ); + return true; + } + + $this->logger->log_error( + sprintf( 'Failed to schedule WP-Cron CSV export event: %s', $scheduled->get_error_message() ), + __METHOD__ + ); + if ( null === $scheduled->get_error_data() ) { + $scheduled->add_data( array( 'status' => 500 ) ); + } + return $scheduled; + } + if ( true !== $scheduled ) { + if ( wp_next_scheduled( self::EXPORT_ACTION_HOOK, $args ) ) { + $this->logger->log_message( + sprintf( 'WP-Cron CSV export already scheduled for report type: %s', $report_type ), + __METHOD__ + ); + return true; + } + $this->logger->log_error( 'Failed to schedule WP-Cron CSV export event', __METHOD__ ); return new \WP_Error( 'schedule_failed', diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php index 2afe50a2700b..18606a60fe85 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php @@ -91,6 +91,52 @@ public function test_schedule_export_schedules_single_wp_cron_event() { ); } + public function test_schedule_export_treats_duplicate_wp_cron_event_as_success() { + $args = array( 'from' => '2026-01-01T00:00:00' ); + + $this->assertTrue( $this->scheduler()->schedule_export( 'stats-top-posts', $args, 1, 'admin@example.com' ) ); + $this->assertTrue( $this->scheduler()->schedule_export( 'stats-top-posts', $args, 1, 'admin@example.com' ) ); + + $this->assertIsInt( + wp_next_scheduled( + Wp_Cron_Export_Scheduler::EXPORT_ACTION_HOOK, + array( + 'stats-top-posts', + $args, + 1, + 'admin@example.com', + ) + ) + ); + } + + public function test_schedule_export_preserves_wp_cron_error_details() { + $block_schedule = static function ( $pre, $event ) { + if ( Wp_Cron_Export_Scheduler::EXPORT_ACTION_HOOK === $event->hook ) { + return new \WP_Error( 'cron_blocked', 'Cron scheduling is blocked.' ); + } + + return $pre; + }; + + add_filter( 'pre_schedule_event', $block_schedule, 10, 2 ); + try { + $result = $this->scheduler()->schedule_export( + 'stats-top-posts', + array( 'from' => '2026-01-01T00:00:00' ), + 1, + 'admin@example.com' + ); + } finally { + remove_filter( 'pre_schedule_event', $block_schedule, 10 ); + } + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'cron_blocked', $result->get_error_code() ); + $this->assertSame( 'Cron scheduling is blocked.', $result->get_error_message() ); + $this->assertSame( array( 'status' => 500 ), $result->get_error_data() ); + } + public function test_process_export_job_emails_attachment_and_restores_user() { $email = new Fake_Wp_Mail_Email(); $scheduler = $this->scheduler( null, $email ); From ac8bac0266d7e205ae6152322f641fcbe58f54f6 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:13:20 +0200 Subject: [PATCH 05/14] Premium Analytics: schedule WP-Cron cleanup lazily --- .../Export/class-wp-cron-export-scheduler.php | 22 ++++++++++++++--- .../php/Reports/Export/StatsExport_Test.php | 2 +- .../Export/WPCronExportScheduler_Test.php | 24 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php index 8c5f5aa43ccb..845b2efb2ed5 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php @@ -105,7 +105,6 @@ public function __construct( public function register(): void { add_action( self::EXPORT_ACTION_HOOK, array( $this, 'process_export_job' ), 10, 4 ); add_action( self::CLEANUP_HOOK, array( $this, 'cleanup_old_exports' ) ); - add_action( 'init', array( $this, 'schedule_cleanup' ) ); } /** @@ -148,6 +147,7 @@ public function schedule_export( string $report_type, array $params, int $user_i sprintf( 'WP-Cron CSV export already scheduled for report type: %s', $report_type ), __METHOD__ ); + $this->schedule_cleanup(); return true; } @@ -167,6 +167,7 @@ public function schedule_export( string $report_type, array $params, int $user_i sprintf( 'WP-Cron CSV export already scheduled for report type: %s', $report_type ), __METHOD__ ); + $this->schedule_cleanup(); return true; } @@ -183,6 +184,8 @@ public function schedule_export( string $report_type, array $params, int $user_i __METHOD__ ); + $this->schedule_cleanup(); + return true; } @@ -294,8 +297,21 @@ private function send_error_email( string $user_email, string $report_type ): vo * @return void */ public function schedule_cleanup(): void { - if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) { - wp_schedule_event( time(), 'daily', self::CLEANUP_HOOK ); + if ( wp_next_scheduled( self::CLEANUP_HOOK ) ) { + return; + } + + $scheduled = wp_schedule_event( time(), 'daily', self::CLEANUP_HOOK, array(), true ); + if ( is_wp_error( $scheduled ) ) { + $this->logger->log_error( + sprintf( 'Failed to schedule WP-Cron CSV export cleanup: %s', $scheduled->get_error_message() ), + __METHOD__ + ); + return; + } + + if ( true !== $scheduled ) { + $this->logger->log_error( 'Failed to schedule WP-Cron CSV export cleanup', __METHOD__ ); } } diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php index d527e2454bc0..5b220f006558 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsExport_Test.php @@ -53,7 +53,7 @@ public function test_configure_registers_stats_export_services_once_when_connect $this->assertTrue( $this->has_hook_object( 'rest_api_init', Stats_Csv_Export_Controller::class, 'register_routes' ) ); $this->assertTrue( $this->has_hook_object( Wp_Cron_Export_Scheduler::EXPORT_ACTION_HOOK, Wp_Cron_Export_Scheduler::class, 'process_export_job' ) ); $this->assertTrue( $this->has_hook_object( Wp_Cron_Export_Scheduler::CLEANUP_HOOK, Wp_Cron_Export_Scheduler::class, 'cleanup_old_exports' ) ); - $this->assertTrue( $this->has_hook_object( 'init', Wp_Cron_Export_Scheduler::class, 'schedule_cleanup' ) ); + $this->assertFalse( $this->has_hook_object( 'init', Wp_Cron_Export_Scheduler::class, 'schedule_cleanup' ) ); $rest_callback_count = $this->count_hook_objects( 'rest_api_init', Stats_Csv_Export_Controller::class, 'register_routes' ); diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php index 18606a60fe85..2a012ea7245f 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php @@ -89,6 +89,7 @@ public function test_schedule_export_schedules_single_wp_cron_event() { ) ) ); + $this->assertIsInt( wp_next_scheduled( Wp_Cron_Export_Scheduler::CLEANUP_HOOK ) ); } public function test_schedule_export_treats_duplicate_wp_cron_event_as_success() { @@ -137,6 +138,29 @@ public function test_schedule_export_preserves_wp_cron_error_details() { $this->assertSame( array( 'status' => 500 ), $result->get_error_data() ); } + public function test_schedule_cleanup_logs_wp_cron_failure() { + $logger = new Spy_Logger(); + $scheduler = $this->scheduler( null, null, $logger ); + + $block_schedule = static function ( $pre, $event ) { + if ( Wp_Cron_Export_Scheduler::CLEANUP_HOOK === $event->hook ) { + return new \WP_Error( 'cleanup_blocked', 'Cleanup scheduling is blocked.' ); + } + + return $pre; + }; + + add_filter( 'pre_schedule_event', $block_schedule, 10, 2 ); + try { + $scheduler->schedule_cleanup(); + } finally { + remove_filter( 'pre_schedule_event', $block_schedule, 10 ); + } + + $this->assertSame( 'error', $logger->entries[0]['level'] ); + $this->assertStringContainsString( 'Cleanup scheduling is blocked.', $logger->entries[0]['message'] ); + } + public function test_process_export_job_emails_attachment_and_restores_user() { $email = new Fake_Wp_Mail_Email(); $scheduler = $this->scheduler( null, $email ); From e7f4f828a837e0cf7935f71606fdc467bac9aee5 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:14:24 +0200 Subject: [PATCH 06/14] Premium Analytics: harden WP-Cron export cleanup --- .../Export/class-wp-cron-export-scheduler.php | 6 ++ .../Export/WPCronExportScheduler_Test.php | 82 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php index 845b2efb2ed5..2b8b755e667d 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php @@ -322,6 +322,11 @@ public function schedule_cleanup(): void { */ public function cleanup_old_exports(): void { $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['basedir'] ) ) { + $this->logger->log_error( 'Cannot clean up CSV exports because the uploads directory is unavailable', __METHOD__ ); + return; + } + $export_dir = trailingslashit( $upload_dir['basedir'] ) . 'jetpack-premium-analytics-exports'; if ( ! is_dir( $export_dir ) ) { @@ -334,6 +339,7 @@ public function cleanup_old_exports(): void { * @param int $retention_seconds Retention period in seconds. Default: 48 hours. */ $retention = apply_filters( 'jetpack_premium_analytics_csv_export_retention', self::DEFAULT_RETENTION_PERIOD ); + $retention = is_numeric( $retention ) ? max( 0, (int) $retention ) : self::DEFAULT_RETENTION_PERIOD; $files = glob( $export_dir . '/*.csv' ); if ( ! is_array( $files ) ) { diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php index 2a012ea7245f..98fd3ed4c0f5 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php @@ -23,6 +23,13 @@ #[CoversClass( Wp_Cron_Export_Scheduler::class )] class WPCronExportScheduler_Test extends TestCase { + /** + * Temporary upload base directories for cleanup tests. + * + * @var string[] + */ + private $cleanup_dirs = array(); + /** * @after */ @@ -31,6 +38,23 @@ public function tear_down() { wp_clear_scheduled_hook( Wp_Cron_Export_Scheduler::EXPORT_ACTION_HOOK ); wp_clear_scheduled_hook( Wp_Cron_Export_Scheduler::CLEANUP_HOOK ); wp_set_current_user( 0 ); + + foreach ( $this->cleanup_dirs as $dir ) { + $files = glob( trailingslashit( $dir ) . 'jetpack-premium-analytics-exports/*.csv' ); + if ( is_array( $files ) ) { + foreach ( $files as $file ) { + wp_delete_file( $file ); + } + } + $export_dir = trailingslashit( $dir ) . 'jetpack-premium-analytics-exports'; + if ( is_dir( $export_dir ) ) { + rmdir( $export_dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + } + if ( is_dir( $dir ) ) { + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + } + } + $this->cleanup_dirs = array(); } private function scheduler( $fetch_result = null, ?Fake_Wp_Mail_Email $email = null, ?Spy_Logger $logger = null ): Wp_Cron_Export_Scheduler { @@ -57,6 +81,13 @@ private function scheduler( $fetch_result = null, ?Fake_Wp_Mail_Email $email = n ); } + private function make_cleanup_upload_dir(): string { + $dir = trailingslashit( sys_get_temp_dir() ) . 'pa-wpcron-cleanup-' . wp_generate_password( 8, false ); + $this->cleanup_dirs[] = $dir; + wp_mkdir_p( trailingslashit( $dir ) . 'jetpack-premium-analytics-exports' ); + return $dir; + } + public function test_schedule_export_rejects_invalid_email() { $result = $this->scheduler()->schedule_export( 'stats-top-posts', array(), 1, 'not-an-email' ); $this->assertInstanceOf( \WP_Error::class, $result ); @@ -161,6 +192,57 @@ public function test_schedule_cleanup_logs_wp_cron_failure() { $this->assertStringContainsString( 'Cleanup scheduling is blocked.', $logger->entries[0]['message'] ); } + public function test_cleanup_old_exports_bails_when_upload_dir_is_unavailable() { + $logger = new Spy_Logger(); + $scheduler = $this->scheduler( null, null, $logger ); + + $bad_upload_dir = static function ( $uploads ) { + $uploads['basedir'] = ''; + $uploads['error'] = 'Uploads unavailable.'; + return $uploads; + }; + + add_filter( 'upload_dir', $bad_upload_dir ); + try { + $scheduler->cleanup_old_exports(); + } finally { + remove_filter( 'upload_dir', $bad_upload_dir ); + } + + $this->assertSame( 'error', $logger->entries[0]['level'] ); + $this->assertStringContainsString( 'uploads directory is unavailable', $logger->entries[0]['message'] ); + } + + public function test_cleanup_old_exports_clamps_negative_retention() { + $base_dir = $this->make_cleanup_upload_dir(); + $export_dir = trailingslashit( $base_dir ) . 'jetpack-premium-analytics-exports'; + $file = trailingslashit( $export_dir ) . 'future-export.csv'; + + file_put_contents( $file, 'csv' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + touch( $file, time() + HOUR_IN_SECONDS ); + + $upload_dir = static function ( $uploads ) use ( $base_dir ) { + $uploads['basedir'] = $base_dir; + $uploads['error'] = false; + return $uploads; + }; + + $retention = static function () { + return -DAY_IN_SECONDS; + }; + + add_filter( 'upload_dir', $upload_dir ); + add_filter( 'jetpack_premium_analytics_csv_export_retention', $retention ); + try { + $this->scheduler()->cleanup_old_exports(); + } finally { + remove_filter( 'jetpack_premium_analytics_csv_export_retention', $retention ); + remove_filter( 'upload_dir', $upload_dir ); + } + + $this->assertFileExists( $file ); + } + public function test_process_export_job_emails_attachment_and_restores_user() { $email = new Fake_Wp_Mail_Email(); $scheduler = $this->scheduler( null, $email ); From 3df8991a20bcc7ea16cdffa4cc7f7e98e2d2e6b6 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:15:14 +0200 Subject: [PATCH 07/14] Premium Analytics: verify cron export user permissions --- .../Export/class-wp-cron-export-scheduler.php | 23 +++++++-- .../Export/WPCronExportScheduler_Test.php | 51 ++++++++++++++++--- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php index 2b8b755e667d..7aefaf15f24b 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php @@ -205,6 +205,23 @@ public function process_export_job( string $report_type, array $params, int $use __METHOD__ ); + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + $this->logger->log_error( sprintf( 'Cannot process WP-Cron CSV export for missing user: %d', $user_id ), __METHOD__ ); + return; + } + + $recipient_email = (string) $user->user_email; + if ( ! is_email( $recipient_email ) ) { + $this->logger->log_error( sprintf( 'Cannot process WP-Cron CSV export for user %d because their email is invalid', $user_id ), __METHOD__ ); + return; + } + + if ( ! user_can( $user, 'view_stats' ) && ! user_can( $user, 'manage_options' ) ) { + $this->logger->log_error( sprintf( 'Cannot process WP-Cron CSV export for unauthorized user: %d', $user_id ), __METHOD__ ); + return; + } + $previous_user_id = \get_current_user_id(); \wp_set_current_user( $user_id ); @@ -243,7 +260,7 @@ public function process_export_job( string $report_type, array $params, int $use $report_label = $report_type; } - $email_sent = $this->email_sender->send_export_email( $user_email, $report_label, $params, $file_path ); + $email_sent = $this->email_sender->send_export_email( $recipient_email, $report_label, $params, $file_path ); $this->csv_generator->delete_file( $file_path ); if ( ! $email_sent ) { @@ -251,12 +268,12 @@ public function process_export_job( string $report_type, array $params, int $use } $this->logger->log_message( - sprintf( 'WP-Cron CSV export completed and emailed to: %s', $user_email ), + sprintf( 'WP-Cron CSV export completed and emailed to: %s', $recipient_email ), __METHOD__ ); } catch ( Throwable $e ) { $this->logger->log_exception( $e, __METHOD__ ); - $this->send_error_email( $user_email, $report_type ); + $this->send_error_email( $recipient_email, $report_type ); } finally { \wp_set_current_user( $previous_user_id ); } diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php index 98fd3ed4c0f5..2060c3f112bc 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php @@ -88,6 +88,20 @@ private function make_cleanup_upload_dir(): string { return $dir; } + private function create_export_user( string $role, string $email ): int { + $user_id = wp_insert_user( + array( + 'user_login' => 'stats_export_cron_' . wp_generate_password( 8, false ), + 'user_pass' => 'pass', + 'user_email' => $email, + 'role' => $role, + ) + ); + + $this->assertIsInt( $user_id ); + return $user_id; + } + public function test_schedule_export_rejects_invalid_email() { $result = $this->scheduler()->schedule_export( 'stats-top-posts', array(), 1, 'not-an-email' ); $this->assertInstanceOf( \WP_Error::class, $result ); @@ -246,6 +260,7 @@ public function test_cleanup_old_exports_clamps_negative_retention() { public function test_process_export_job_emails_attachment_and_restores_user() { $email = new Fake_Wp_Mail_Email(); $scheduler = $this->scheduler( null, $email ); + $user_id = $this->create_export_user( 'administrator', 'stats-cron@example.com' ); wp_set_current_user( 0 ); $scheduler->process_export_job( @@ -255,20 +270,44 @@ public function test_process_export_job_emails_attachment_and_restores_user() { 'to' => '2026-01-03T00:00:00', 'interval' => 'day', ), - 1, - 'admin@example.com' + $user_id, + 'spoofed@example.com' ); $this->assertCount( 1, $email->sends ); - $this->assertSame( 'admin@example.com', $email->sends[0]['recipient'] ); + $this->assertSame( 'stats-cron@example.com', $email->sends[0]['recipient'] ); $this->assertSame( 'Top Posts & Pages', $email->sends[0]['report_label'] ); $this->assertSame( 0, get_current_user_id() ); } + public function test_process_export_job_refuses_user_without_stats_permissions() { + $logger = new Spy_Logger(); + $email = new Fake_Wp_Mail_Email(); + $scheduler = $this->scheduler( null, $email, $logger ); + $user_id = $this->create_export_user( 'subscriber', 'subscriber@example.com' ); + + wp_set_current_user( 0 ); + $scheduler->process_export_job( + 'stats-top-posts', + array( + 'from' => '2026-01-01T00:00:00', + 'to' => '2026-01-03T00:00:00', + ), + $user_id, + 'spoofed@example.com' + ); + + $this->assertCount( 0, $email->sends ); + $this->assertSame( 'error', $logger->entries[1]['level'] ); + $this->assertStringContainsString( 'unauthorized user', $logger->entries[1]['message'] ); + $this->assertSame( 0, get_current_user_id() ); + } + public function test_process_export_job_logs_failure_sends_error_email_and_does_not_throw() { $logger = new Spy_Logger(); $email = new Fake_Wp_Mail_Email(); $scheduler = $this->scheduler( new \WP_Error( 'boom', 'fetch failed' ), $email, $logger ); + $user_id = $this->create_export_user( 'administrator', 'stats-cron-failure@example.com' ); $error_mail = array(); $capture = static function ( $return, $atts ) use ( &$error_mail ) { @@ -285,8 +324,8 @@ public function test_process_export_job_logs_failure_sends_error_email_and_does_ 'from' => '2026-01-01T00:00:00', 'to' => '2026-01-03T00:00:00', ), - 1, - 'admin@example.com' + $user_id, + 'spoofed@example.com' ); } finally { remove_filter( 'pre_wp_mail', $capture ); @@ -295,7 +334,7 @@ public function test_process_export_job_logs_failure_sends_error_email_and_does_ $this->assertCount( 0, $email->sends ); $this->assertSame( 'exception', $logger->entries[1]['level'] ); $this->assertSame( 'fetch failed', $logger->entries[1]['message'] ); - $this->assertSame( 'admin@example.com', $error_mail['to'] ); + $this->assertSame( 'stats-cron-failure@example.com', $error_mail['to'] ); $this->assertSame( 0, get_current_user_id() ); } } From 058eb4705a978bf5e63639f77ce920099a3be7a1 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:15:32 +0200 Subject: [PATCH 08/14] Premium Analytics: report invalid Stats export types --- .../Reports/Export/class-stats-csv-export-controller.php | 6 +++++- .../php/Reports/Export/StatsCSVExportController_Test.php | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php index bc6878b503c6..c782d2e97806 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php @@ -192,7 +192,11 @@ public function validate_report_type( $value ) { if ( ! is_string( $value ) || ! $this->registry->is_registered( $value ) ) { return new WP_Error( 'invalid_report_type', - __( 'Invalid report type.', 'jetpack-premium-analytics' ), + sprintf( + /* translators: %s: Report type */ + __( 'Invalid report type: %s', 'jetpack-premium-analytics' ), + is_string( $value ) ? $value : wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) + ), array( 'status' => 400 ) ); } diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php index 86640261ca7c..576aa2d73d3f 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php @@ -113,8 +113,11 @@ public function test_validate_report_type() { $error = $this->controller->validate_report_type( 'nope' ); $this->assertInstanceOf( \WP_Error::class, $error ); $this->assertSame( 'invalid_report_type', $error->get_error_code() ); + $this->assertSame( 'Invalid report type: nope', $error->get_error_message() ); - $this->assertInstanceOf( \WP_Error::class, $this->controller->validate_report_type( array( 'x' ) ) ); + $error = $this->controller->validate_report_type( array( 'x' ) ); + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'Invalid report type: ["x"]', $error->get_error_message() ); } public function test_check_permission_allows_view_stats_capability() { From 4458e1c45d7a4f8a5b1339176eaa07a76d34fa38 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:16:10 +0200 Subject: [PATCH 09/14] Premium Analytics: guard Stats export date comparisons --- .../class-stats-csv-export-controller.php | 44 ++++++++++++------- .../Export/StatsCSVExportController_Test.php | 24 ++++++++++ 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php index c782d2e97806..6a8c17088f52 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php @@ -218,12 +218,17 @@ public function validate_from_date( $value, WP_REST_Request $request, string $pa } $to_date = $request->get_param( 'to' ); - if ( $to_date && strtotime( $value ) >= strtotime( $to_date ) ) { - return new WP_Error( - 'invalid_date_range', - __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ), - array( 'status' => 400 ) - ); + if ( $to_date ) { + $from_timestamp = strtotime( (string) $value ); + $to_timestamp = strtotime( (string) $to_date ); + + if ( false !== $from_timestamp && false !== $to_timestamp && $from_timestamp >= $to_timestamp ) { + return new WP_Error( + 'invalid_date_range', + __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } } return true; @@ -243,7 +248,8 @@ public function validate_to_date( $value, WP_REST_Request $request, string $para return $validated; } - if ( wp_date( 'Y-m-d', strtotime( $value ) ) > current_datetime()->format( 'Y-m-d' ) ) { + $to_timestamp = strtotime( (string) $value ); + if ( false !== $to_timestamp && wp_date( 'Y-m-d', $to_timestamp ) > current_datetime()->format( 'Y-m-d' ) ) { return new WP_Error( 'future_date', __( 'The "to" date cannot be later than today.', 'jetpack-premium-analytics' ), @@ -252,12 +258,16 @@ public function validate_to_date( $value, WP_REST_Request $request, string $para } $from_date = $request->get_param( 'from' ); - if ( $from_date && strtotime( $from_date ) >= strtotime( $value ) ) { - return new WP_Error( - 'invalid_date_range', - __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ), - array( 'status' => 400 ) - ); + if ( $from_date ) { + $from_timestamp = strtotime( (string) $from_date ); + + if ( false !== $from_timestamp && false !== $to_timestamp && $from_timestamp >= $to_timestamp ) { + return new WP_Error( + 'invalid_date_range', + __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ), + array( 'status' => 400 ) + ); + } } return true; @@ -303,7 +313,8 @@ public function validate_compare_to_date( $value, WP_REST_Request $request, stri return $validated; } - if ( wp_date( 'Y-m-d', strtotime( $value ) ) > current_datetime()->format( 'Y-m-d' ) ) { + $compare_to_timestamp = strtotime( (string) $value ); + if ( false !== $compare_to_timestamp && wp_date( 'Y-m-d', $compare_to_timestamp ) > current_datetime()->format( 'Y-m-d' ) ) { return new WP_Error( 'future_date', __( 'The "compare_to" date cannot be later than today.', 'jetpack-premium-analytics' ), @@ -331,7 +342,10 @@ public function validate_compare_to_date( $value, WP_REST_Request $request, stri * @return bool|WP_Error */ private function validate_compare_period( string $compare_from, string $compare_to ) { - if ( strtotime( $compare_from ) >= strtotime( $compare_to ) ) { + $compare_from_timestamp = strtotime( $compare_from ); + $compare_to_timestamp = strtotime( $compare_to ); + + if ( false !== $compare_from_timestamp && false !== $compare_to_timestamp && $compare_from_timestamp >= $compare_to_timestamp ) { return new WP_Error( 'invalid_compare_date_range', __( 'The "compare_from" date must be before the "compare_to" date.', 'jetpack-premium-analytics' ), diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php index 576aa2d73d3f..6890a95544cb 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php @@ -177,6 +177,18 @@ public function test_validate_date_ranges() { $this->assertSame( 'future_date', $error->get_error_code() ); } + public function test_validate_date_ranges_skip_ordering_when_programmatic_dates_do_not_parse() { + $request = new WP_REST_Request(); + $request->set_param( 'to', '2026-01-02T00:00:00' ); + + $this->assertTrue( $this->controller->validate_from_date( 'not-a-date', $request, 'from' ) ); + + $request = new WP_REST_Request(); + $request->set_param( 'from', 'not-a-date' ); + + $this->assertTrue( $this->controller->validate_to_date( '2026-01-02T00:00:00', $request, 'to' ) ); + } + public function test_validate_comparison_date_ranges() { $request = new WP_REST_Request(); $request->set_param( 'compare_to', '2025-01-02T00:00:00' ); @@ -211,6 +223,18 @@ public function test_validate_comparison_date_ranges() { $this->assertSame( 'missing_compare_from', $error->get_error_code() ); } + public function test_validate_comparison_date_ranges_skip_ordering_when_programmatic_dates_do_not_parse() { + $request = new WP_REST_Request(); + $request->set_param( 'compare_to', '2025-01-02T00:00:00' ); + + $this->assertTrue( $this->controller->validate_compare_from_date( 'not-a-date', $request, 'compare_from' ) ); + + $request = new WP_REST_Request(); + $request->set_param( 'compare_from', 'not-a-date' ); + + $this->assertTrue( $this->controller->validate_compare_to_date( '2025-01-02T00:00:00', $request, 'compare_to' ) ); + } + public function test_create_export_returns_registry_error_for_unknown_report() { $request = new WP_REST_Request(); $request->set_param( 'report_type', 'missing-report' ); From bc39e87f51b49ef456ebb219593ae5f5d6c0c9e0 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:18:00 +0200 Subject: [PATCH 10/14] Premium Analytics: stream Stats exports through REST serving --- .../class-stats-csv-export-controller.php | 38 ++++++++++---- .../Export/StatsCSVExportController_Test.php | 52 ++++++++++++++++--- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php index 6a8c17088f52..d53c5ebe59a3 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php @@ -423,18 +423,36 @@ private function generate_download_export( string $report_type, array $params ) return $file_path; } - $streamed = $this->csv_generator->stream_file( $file_path, $filename . '.csv' ); - if ( ! $streamed ) { + $response = new WP_REST_Response( null, 200 ); + $this->attach_download_response_handler( $response, $file_path, $filename . '.csv' ); + + return $response; + } + + /** + * Attach a one-shot REST response handler that streams the generated CSV file. + * + * @param WP_REST_Response $response The response object that should trigger streaming. + * @param string $file_path Generated CSV file path. + * @param string $filename Download filename. + * @return void + */ + private function attach_download_response_handler( WP_REST_Response $response, string $file_path, string $filename ): void { + $handler = null; + $handler = function ( $served, $result ) use ( &$handler, $response, $file_path, $filename ) { + if ( $result !== $response ) { + return $served; + } + + remove_filter( 'rest_pre_serve_request', $handler, 10 ); + + $streamed = $this->csv_generator->stream_file( $file_path, $filename ); $this->csv_generator->delete_file( $file_path ); - return new WP_Error( - 'csv_stream_failed', - __( 'Failed to stream the export file.', 'jetpack-premium-analytics' ), - array( 'status' => 500 ) - ); - } - $this->csv_generator->delete_file( $file_path ); - exit; + return $streamed ? true : $served; + }; + + add_filter( 'rest_pre_serve_request', $handler, 10, 2 ); } /** diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php index 6890a95544cb..bb8f6d29c036 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php @@ -92,6 +92,21 @@ public function test_register_hooks_route_registration() { $this->assertNotFalse( has_action( 'rest_api_init', array( $this->controller, 'register_routes' ) ) ); } + private function serve_response( \WP_REST_Response $response, WP_REST_Request $request ): bool { + $previous_request_method = $_SERVER['REQUEST_METHOD'] ?? null; + $_SERVER['REQUEST_METHOD'] = 'POST'; + + try { + return (bool) apply_filters( 'rest_pre_serve_request', false, $response, $request, null ); + } finally { + if ( null === $previous_request_method ) { + unset( $_SERVER['REQUEST_METHOD'] ); + } else { + $_SERVER['REQUEST_METHOD'] = $previous_request_method; + } + } + } + public function test_register_routes_exposes_expected_endpoint_args() { $this->controller->register(); do_action( 'rest_api_init' ); @@ -155,7 +170,7 @@ public function test_check_permission_allows_manage_options_and_denies_anonymous } public function test_validate_date_ranges() { - $request = new WP_REST_Request(); + $request = new WP_REST_Request( 'POST' ); $request->set_param( 'to', '2026-01-02T00:00:00' ); $this->assertTrue( $this->controller->validate_from_date( '2026-01-01T00:00:00', $request, 'from' ) ); @@ -164,7 +179,7 @@ public function test_validate_date_ranges() { $this->assertInstanceOf( \WP_Error::class, $error ); $this->assertSame( 'invalid_date_range', $error->get_error_code() ); - $request = new WP_REST_Request(); + $request = new WP_REST_Request( 'POST' ); $request->set_param( 'from', '2026-01-01T00:00:00' ); $this->assertTrue( $this->controller->validate_to_date( '2026-01-02T00:00:00', $request, 'to' ) ); @@ -300,19 +315,42 @@ public function test_create_export_email_path_returns_scheduler_error() { $this->assertSame( 'schedule_failed', $result->get_error_code() ); } - public function test_create_export_download_path_returns_stream_error_without_exiting() { + public function test_create_export_download_path_registers_streaming_response_without_exiting() { + $request = new WP_REST_Request( 'POST' ); + $request->set_param( 'report_type', 'stats-top-posts' ); + $request->set_param( 'delivery_method', 'download' ); + $request->set_param( 'from', '2026-01-01T00:00:00' ); + $request->set_param( 'to', '2026-01-03T00:00:00' ); + + $response = $this->controller->create_export( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 0, $this->generator->streams ); + + $served = $this->serve_response( $response, $request ); + + $this->assertTrue( $served ); + $this->assertCount( 1, $this->generator->streams ); + $this->assertSame( 'top-posts-pages-2026-01-01-to-2026-01-03.csv', $this->generator->streams[0]['filename'] ); + } + + public function test_create_export_download_path_returns_unserved_response_when_streaming_fails() { $this->generator->stream_result = false; - $request = new WP_REST_Request(); + $request = new WP_REST_Request( 'POST' ); $request->set_param( 'report_type', 'stats-top-posts' ); $request->set_param( 'delivery_method', 'download' ); $request->set_param( 'from', '2026-01-01T00:00:00' ); $request->set_param( 'to', '2026-01-03T00:00:00' ); - $result = $this->controller->create_export( $request ); + $response = $this->controller->create_export( $request ); - $this->assertInstanceOf( \WP_Error::class, $result ); - $this->assertSame( 'csv_stream_failed', $result->get_error_code() ); + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + + $served = $this->serve_response( $response, $request ); + + $this->assertFalse( $served ); $this->assertCount( 1, $this->generator->streams ); $this->assertSame( 'top-posts-pages-2026-01-01-to-2026-01-03.csv', $this->generator->streams[0]['filename'] ); } From 5068ae78d5ad93af4242b4030d4e2dcf91dd2926 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:18:09 +0200 Subject: [PATCH 11/14] Premium Analytics: clarify Stats export changelog --- .../premium-analytics/changelog/add-pa-stats-wpcron-export | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export b/projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export index 6f46697321bd..1dd559efbe34 100644 --- a/projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export +++ b/projects/packages/premium-analytics/changelog/add-pa-stats-wpcron-export @@ -1,4 +1,4 @@ Significance: minor Type: added -Add a WooCommerce-free server-side CSV export pipeline for Jetpack Stats reports, using WP-Cron for scheduled email delivery and wp_mail for the attachment. +Add CSV exports for Jetpack Stats Top Posts & Pages, with direct download and scheduled email delivery. From 86bd9a304be52892554af3a95e9177f7a8d27024 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:24:13 +0200 Subject: [PATCH 12/14] Premium Analytics: fix Stats export PHPCS warnings --- .../src/Reports/Export/class-wp-cron-export-scheduler.php | 3 +++ .../tests/php/Reports/Export/StatsCSVExportController_Test.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php index 7aefaf15f24b..359ac20b205a 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-wp-cron-export-scheduler.php @@ -205,6 +205,9 @@ public function process_export_job( string $report_type, array $params, int $use __METHOD__ ); + // Ignore the scheduled email argument; derive the recipient from the current user record. + unset( $user_email ); + $user = get_user_by( 'id', $user_id ); if ( ! $user ) { $this->logger->log_error( sprintf( 'Cannot process WP-Cron CSV export for missing user: %d', $user_id ), __METHOD__ ); diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php index bb8f6d29c036..9dcfca267d40 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php @@ -93,7 +93,7 @@ public function test_register_hooks_route_registration() { } private function serve_response( \WP_REST_Response $response, WP_REST_Request $request ): bool { - $previous_request_method = $_SERVER['REQUEST_METHOD'] ?? null; + $previous_request_method = $_SERVER['REQUEST_METHOD'] ?? null; $_SERVER['REQUEST_METHOD'] = 'POST'; try { From e7986d2c346ff77fbcde8b2e70c30512bba06051 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:29:22 +0200 Subject: [PATCH 13/14] Premium Analytics: align Stats export test assignment --- .../tests/php/Reports/Export/StatsCSVExportController_Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php index 9dcfca267d40..402c6d442bc4 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/StatsCSVExportController_Test.php @@ -93,7 +93,7 @@ public function test_register_hooks_route_registration() { } private function serve_response( \WP_REST_Response $response, WP_REST_Request $request ): bool { - $previous_request_method = $_SERVER['REQUEST_METHOD'] ?? null; + $previous_request_method = $_SERVER['REQUEST_METHOD'] ?? null; $_SERVER['REQUEST_METHOD'] = 'POST'; try { From 1b28a099055b811daf05abc0c77168b47e8bff5e Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:39:07 +0200 Subject: [PATCH 14/14] Premium Analytics: fix Stats export Phan issues --- .../Export/Logging/class-error-log-logger.php | 1 - .../class-stats-csv-export-controller.php | 4 +++- .../Export/WPCronExportScheduler_Test.php | 13 ++++++---- .../Reports/Export/WPMailExportEmail_Test.php | 24 ++++++++++++++----- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php b/projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php index 98fcdbd04005..b893a1514990 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php +++ b/projects/packages/premium-analytics/src/Reports/Export/Logging/class-error-log-logger.php @@ -17,7 +17,6 @@ * Logs export pipeline messages through the PHP/WordPress error log. * * @since $$next-version$$ - * @internal */ class Error_Log_Logger implements Logger_Interface { diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php index d53c5ebe59a3..b0aaba1584f3 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-stats-csv-export-controller.php @@ -444,7 +444,9 @@ private function attach_download_response_handler( WP_REST_Response $response, s return $served; } - remove_filter( 'rest_pre_serve_request', $handler, 10 ); + if ( null !== $handler ) { + remove_filter( 'rest_pre_serve_request', $handler, 10 ); + } $streamed = $this->csv_generator->stream_file( $file_path, $filename ); $this->csv_generator->delete_file( $file_path ); diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php index 2060c3f112bc..9e6b532d6d01 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPCronExportScheduler_Test.php @@ -140,8 +140,11 @@ public function test_schedule_export_schedules_single_wp_cron_event() { public function test_schedule_export_treats_duplicate_wp_cron_event_as_success() { $args = array( 'from' => '2026-01-01T00:00:00' ); - $this->assertTrue( $this->scheduler()->schedule_export( 'stats-top-posts', $args, 1, 'admin@example.com' ) ); - $this->assertTrue( $this->scheduler()->schedule_export( 'stats-top-posts', $args, 1, 'admin@example.com' ) ); + $first_result = $this->scheduler()->schedule_export( 'stats-top-posts', $args, 1, 'admin@example.com' ); + $second_result = $this->scheduler()->schedule_export( 'stats-top-posts', $args, 1, 'admin@example.com' ); + + $this->assertTrue( $first_result ); + $this->assertTrue( $second_result ); $this->assertIsInt( wp_next_scheduled( @@ -309,9 +312,11 @@ public function test_process_export_job_logs_failure_sends_error_email_and_does_ $scheduler = $this->scheduler( new \WP_Error( 'boom', 'fetch failed' ), $email, $logger ); $user_id = $this->create_export_user( 'administrator', 'stats-cron-failure@example.com' ); - $error_mail = array(); + $error_mail = array( + 'to' => '', + ); $capture = static function ( $return, $atts ) use ( &$error_mail ) { - $error_mail = $atts; + $error_mail['to'] = (string) ( $atts['to'] ?? '' ); return true; }; diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php index 1248b8cdc3e6..c1c13763dea6 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/WPMailExportEmail_Test.php @@ -50,10 +50,18 @@ public function test_send_export_email_attaches_csv_and_logs_success() { $logger = new Spy_Logger(); $email = new Wp_Mail_Export_Email( $logger ); $file = $this->make_file( "Title,Views\nHello,5\n" ); - $sent = array(); + $sent = array( + 'attachments' => array(), + 'message' => '', + 'to' => '', + ); $capture = static function ( $return, $atts ) use ( &$sent ) { - $sent = $atts; + $sent = array( + 'attachments' => is_array( $atts['attachments'] ?? null ) ? $atts['attachments'] : array(), + 'message' => (string) ( $atts['message'] ?? '' ), + 'to' => (string) ( $atts['to'] ?? '' ), + ); return true; }; @@ -81,10 +89,12 @@ public function test_send_export_email_attaches_csv_and_logs_success() { public function test_send_export_email_formats_request_dates_without_timezone_shift() { $email = new Wp_Mail_Export_Email(); $file = $this->make_file( "Title,Views\nHello,5\n" ); - $sent = array(); + $sent = array( + 'message' => '', + ); $capture = static function ( $return, $atts ) use ( &$sent ) { - $sent = $atts; + $sent['message'] = (string) ( $atts['message'] ?? '' ); return true; }; @@ -110,10 +120,12 @@ public function test_send_export_email_formats_request_dates_without_timezone_sh public function test_send_export_email_omits_date_range_when_request_dates_are_invalid() { $email = new Wp_Mail_Export_Email(); $file = $this->make_file( "Title,Views\nHello,5\n" ); - $sent = array(); + $sent = array( + 'message' => '', + ); $capture = static function ( $return, $atts ) use ( &$sent ) { - $sent = $atts; + $sent['message'] = (string) ( $atts['message'] ?? '' ); return true; };