Commit 6389d2dad1d for woocommerce
commit 6389d2dad1d3d1ac3fb99fb8c0230f60940b7aa7
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date: Fri Sep 11 11:39:41 2026 +0300
Include the date range in emailed Analytics report exports (#68469)
* Include the date range in emailed Analytics report exports
diff --git a/plugins/woocommerce/changelog/49059-include-dates-in-emailed-analytics-exports b/plugins/woocommerce/changelog/49059-include-dates-in-emailed-analytics-exports
new file mode 100644
index 00000000000..b1b3296ea70
--- /dev/null
+++ b/plugins/woocommerce/changelog/49059-include-dates-in-emailed-analytics-exports
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Include the report's date range in emailed Analytics exports, in the email and in the downloaded file's name.
diff --git a/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php b/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php
index da2c109b7ad..12526f45fb7 100644
--- a/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php
+++ b/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php
@@ -19,6 +19,21 @@ do_action( 'woocommerce_email_header', $email_heading, $email );
echo esc_html( sprintf( __( 'Download your %s Report', 'woocommerce' ), $report_name ) );
?>
</a>
+<?php
+/**
+ * Date range the report covers, passed in by ReportCSVEmail. Empty for reports without one.
+ *
+ * @var string $date_range
+ */
+if ( ! empty( $date_range ) ) :
+ ?>
+<p>
+ <?php
+ /* translators: %s: the date range the report covers, e.g. "June 1, 2025 - June 30, 2025" */
+ echo esc_html( sprintf( __( 'Date range: %s', 'woocommerce' ), $date_range ) );
+ ?>
+</p>
+<?php endif; ?>
<p>
<?php
/**
diff --git a/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php b/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php
index 365bf01bf51..461169b04c4 100644
--- a/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php
+++ b/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php
@@ -16,6 +16,18 @@ echo wp_kses_post( sprintf( __( 'Download your %1$s Report: %2$s', 'woocommerce'
echo "\n\n";
+/**
+ * Date range the report covers, passed in by ReportCSVEmail. Empty for reports without one.
+ *
+ * @var string $date_range
+ */
+if ( ! empty( $date_range ) ) {
+ /* translators: %s: the date range the report covers, e.g. "June 1, 2025 - June 30, 2025" */
+ echo esc_html( sprintf( __( 'Date range: %s', 'woocommerce' ), $date_range ) );
+
+ echo "\n\n";
+}
+
/**
* Length of time the download link stays valid, passed in by ReportCSVEmail.
*
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
index dd4977b1ad6..ebf02a7a06e 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
@@ -255,12 +255,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
// @todo - add thing in the links below instead?
if ( 100 === $percentage ) {
- $query_args = array(
- 'action' => ReportExporter::DOWNLOAD_EXPORT_ACTION,
- 'filename' => "wc-{$report_type}-report-export-{$export_id}",
- );
-
- $result['download_url'] = add_query_arg( $query_args, admin_url() );
+ $result['download_url'] = ReportExporter::get_download_url( $report_type, $export_id );
}
// Wrap the data in a response object.
diff --git a/plugins/woocommerce/src/Admin/ReportCSVEmail.php b/plugins/woocommerce/src/Admin/ReportCSVEmail.php
index 3deebe9301a..68c80692708 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVEmail.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVEmail.php
@@ -42,10 +42,19 @@ class ReportCSVEmail extends \WC_Email {
*/
protected $download_url;
+ /**
+ * Date range the report covers, formatted for display. Empty when the report has no range.
+ *
+ * @var string
+ */
+ protected $report_date_range = '';
+
/**
* Constructor.
*/
public function __construct() {
+ $this->placeholders['{report_date_range}'] = '';
+
$this->id = 'admin_report_export_download';
$this->template_base = WC()->plugin_path() . '/includes/react-admin/emails/';
$this->template_html = 'html-admin-report-export-download.php';
@@ -111,9 +120,16 @@ class ReportCSVEmail extends \WC_Email {
/**
* Get email subject.
*
+ * Says which period the report covers when it has one, so a merchant running the same report
+ * over several date ranges can tell the emails apart without opening them.
+ *
* @return string
*/
public function get_default_subject() {
+ if ( '' !== $this->report_date_range ) {
+ return __( '[{site_title}]: Your {report_name} Report for {report_date_range} is ready', 'woocommerce' );
+ }
+
return __( '[{site_title}]: Your {report_name} Report download is ready', 'woocommerce' );
}
@@ -127,6 +143,7 @@ class ReportCSVEmail extends \WC_Email {
$this->template_html,
array(
'report_name' => $this->report_type,
+ 'date_range' => $this->report_date_range,
'download_url' => $this->download_url,
'email_heading' => $this->get_heading(),
'sent_to_admin' => true,
@@ -149,6 +166,7 @@ class ReportCSVEmail extends \WC_Email {
$this->template_plain,
array(
'report_name' => $this->report_type,
+ 'date_range' => $this->report_date_range,
'download_url' => $this->download_url,
'email_heading' => $this->get_heading(),
'sent_to_admin' => true,
@@ -171,6 +189,21 @@ class ReportCSVEmail extends \WC_Email {
return human_time_diff( 0, ReportExporter::EXPORT_RETENTION_PERIOD );
}
+ /**
+ * Set the date range the report covers, so the email can say which period it is for.
+ *
+ * Call before trigger(). Reports that are not limited to a period, such as Stock, leave it unset.
+ *
+ * @since 11.2.0
+ * @param string $date_range The date range the report covers, formatted for display.
+ * @return void
+ */
+ public function set_report_date_range( $date_range ) {
+ $this->report_date_range = is_string( $date_range ) ? $date_range : '';
+
+ $this->placeholders['{report_date_range}'] = $this->report_date_range;
+ }
+
/**
* Trigger the sending of this email.
*
diff --git a/plugins/woocommerce/src/Admin/ReportCSVExporter.php b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
index a85d26c7a63..68b16fcfa5b 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
@@ -43,6 +43,13 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
*/
protected $controller;
+ /**
+ * Detail appended to the name the export is downloaded as.
+ *
+ * @var string
+ */
+ protected $download_suffix = '';
+
/**
* Constructor.
*
@@ -116,6 +123,56 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
return self::get_reports_directory() . $this->get_filename();
}
+ /**
+ * Get the name the export is downloaded as.
+ *
+ * The stored file keeps get_filename(), which only has to identify the export.
+ *
+ * @since 11.2.0
+ * @return string
+ */
+ public function get_download_filename() {
+ $filename = $this->get_filename();
+
+ if ( '' === $this->download_suffix ) {
+ return $filename;
+ }
+
+ // Stripped and restored the way set_filename() does it, so the name keeps a single .csv.
+ return sanitize_file_name( str_replace( '.csv', '', $filename ) . '-' . $this->download_suffix . '.csv' );
+ }
+
+ /**
+ * Set the export headers.
+ *
+ * Re-sends the parent's Content-Disposition so the download is named after what the report
+ * covers, leaving the stored file's name alone.
+ *
+ * @since 11.2.0
+ * @return void
+ */
+ public function send_headers() {
+ parent::send_headers();
+
+ if ( '' !== $this->download_suffix ) {
+ header( 'Content-Disposition: attachment; filename=' . $this->get_download_filename() );
+ }
+ }
+
+ /**
+ * Add detail to the name the export is downloaded as, leaving the stored file's name alone.
+ *
+ * Lets a download say what the report covers, such as the date range, when the stored name
+ * only identifies the export.
+ *
+ * @since 11.2.0
+ * @param string $suffix Detail to append to the download's name, e.g. a date range.
+ * @return void
+ */
+ public function set_download_suffix( $suffix ) {
+ $this->download_suffix = sanitize_file_name( $suffix );
+ }
+
/**
* Check whether a complete generated export is still on disk.
*
diff --git a/plugins/woocommerce/src/Admin/ReportExporter.php b/plugins/woocommerce/src/Admin/ReportExporter.php
index 2323aeaf1bf..ffd9b087cdb 100644
--- a/plugins/woocommerce/src/Admin/ReportExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportExporter.php
@@ -10,6 +10,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
use Automattic\WooCommerce\Admin\Schedulers\SchedulerTraits;
+use Automattic\WooCommerce\Utilities\TimeUtil;
/**
* ReportExporter Class.
@@ -146,7 +147,7 @@ class ReportExporter {
self::queue_batches( 1, $num_batches, 'export_report', $report_batch_args );
if ( $send_email ) {
- $email_action_args = array( get_current_user_id(), $export_id, $report_type );
+ $email_action_args = array( get_current_user_id(), $export_id, $report_type, $report_args );
self::schedule_action( 'email_report_download_link', $email_action_args );
}
}
@@ -167,7 +168,7 @@ class ReportExporter {
$report_args['page'] = $page_number;
$exporter = new ReportCSVExporter( $report_type, $report_args );
- $exporter->set_filename( "wc-{$report_type}-report-export-{$export_id}" );
+ $exporter->set_filename( self::get_export_filename( $report_type, $export_id ) );
$exporter->generate_file();
self::update_export_percentage_complete( $report_type, $export_id, $exporter->get_percent_complete() );
@@ -220,30 +221,168 @@ class ReportExporter {
}
/**
- * Serve the export file.
+ * Get the name a report export is stored under.
+ *
+ * @param string $report_type Report type. E.g. 'customers'.
+ * @param string $export_id Unique ID for report (timestamp expected).
+ * @return string
*/
- public static function download_export_file() {
- /*
- * A read-only download of a report the requesting user is already allowed to view, gated on
- * the view_woocommerce_reports capability, so a nonce would only prevent nuisance CSRF. The
- * action is compared verbatim against a fixed name, and set_filename() applies
- * sanitize_file_name(), which keeps the path inside the reports directory. A nonce is not an
- * option here either: nonces last 24 hours, and this link is emailed and kept for a week.
- */
- // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ private static function get_export_filename( $report_type, $export_id ) {
+ return "wc-{$report_type}-report-export-{$export_id}";
+ }
+
+ /**
+ * Get the URL a finished report export is downloaded from.
+ *
+ * @since 11.2.0
+ * @param string $report_type Report type. E.g. 'customers'.
+ * @param string $export_id Unique ID for report (timestamp expected).
+ * @param array $report_args Optional. Report parameters the export was queued with. When they name
+ * a date range, the link carries it so the download is named after it.
+ * @return string
+ */
+ public static function get_download_url( $report_type, $export_id, $report_args = array() ) {
+ $query_args = array(
+ 'action' => self::DOWNLOAD_EXPORT_ACTION,
+ 'filename' => self::get_export_filename( $report_type, $export_id ),
+ );
+
+ $date_range = self::get_export_date_range( $report_args );
+ if ( $date_range ) {
+ $query_args['date_range'] = $date_range['after'] . '-to-' . $date_range['before'];
+ }
+
+ return add_query_arg( $query_args, admin_url() );
+ }
+
+ /**
+ * Get the date range a report export covers.
+ *
+ * Reports are not all limited to a period. Stock, for one, has no date range at all.
+ *
+ * @since 11.2.0
+ * @param array $report_args Report parameters, passed to data query.
+ * @return string[] The range's `after` and `before` dates as `Y-m-d`, or an empty array when the report has no range.
+ */
+ public static function get_export_date_range( $report_args ) {
+ if ( ! is_array( $report_args ) ) {
+ return array();
+ }
+
+ $date_range = array();
+
+ foreach ( array( 'after', 'before' ) as $bound ) {
+ // Report args arrive from a REST request, so they hold whatever the caller sent. Take the
+ // date as written rather than converting it: the report reads these as store local time.
+ // The shape alone is not enough, since a date like 2025-06-31 would roll over to July 1.
+ if (
+ empty( $report_args[ $bound ] ) ||
+ ! is_string( $report_args[ $bound ] ) ||
+ ! preg_match( '/^(\d{4}-\d{2}-\d{2})/', $report_args[ $bound ], $matches ) ||
+ ! TimeUtil::is_valid_date( $matches[1], 'Y-m-d' )
+ ) {
+ return array();
+ }
+
+ $date_range[ $bound ] = $matches[1];
+ }
+
+ return $date_range;
+ }
+
+ /**
+ * Get the date range a report export covers, formatted for display.
+ *
+ * @since 11.2.0
+ * @param array $report_args Report parameters, passed to data query.
+ * @return string Date range in the site's date format, or an empty string when the report has no range.
+ */
+ public static function get_export_date_range_label( $report_args ) {
+ $date_range = self::get_export_date_range( $report_args );
+
+ if ( ! $date_range ) {
+ return '';
+ }
+
+ $after = self::format_date_range_bound( $date_range['after'] );
+ $before = self::format_date_range_bound( $date_range['before'] );
+
+ if ( '' === $after || '' === $before ) {
+ return '';
+ }
+
+ if ( $after === $before ) {
+ return $after;
+ }
+
+ /* translators: 1: first day of the period a report covers, 2: last day of that period. */
+ return sprintf( _x( '%1$s - %2$s', 'Report date range: from-to', 'woocommerce' ), $after, $before );
+ }
+
+ /**
+ * Format one end of a report's date range for display.
+ *
+ * @param string $date Date as `Y-m-d`.
+ * @return string The date in the store's date format, or an empty string when it cannot be read.
+ */
+ private static function format_date_range_bound( $date ) {
+ // Read in the store's own timezone, so the date reads back as the merchant picked it and a
+ // date format that names the timezone names theirs rather than UTC. Midday is a safe anchor.
+ $parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $date . ' 12:00:00', wp_timezone() );
+
+ if ( false === $parsed ) {
+ return '';
+ }
+
+ return (string) wp_date( wc_date_format(), $parsed->getTimestamp() );
+ }
+
+ /**
+ * Build the exporter a download request is asking for.
+ *
+ * A read-only download of a report the requesting user is already allowed to view, gated on
+ * the view_woocommerce_reports capability, so a nonce would only prevent nuisance CSRF. The
+ * action is compared verbatim against a fixed name, and set_filename() applies
+ * sanitize_file_name(), which keeps the path inside the reports directory. A nonce is not an
+ * option here either: nonces last 24 hours, and this link is emailed and kept for a week.
+ *
+ * @param array $request Unslashed request parameters, expected to be `$_GET`.
+ * @return ReportCSVExporter|null The exporter for the requested export, or null when the request asks for no export.
+ */
+ private static function get_requested_export( $request ) {
if (
- ! isset( $_GET['action'] ) ||
- self::DOWNLOAD_EXPORT_ACTION !== wp_unslash( $_GET['action'] ) ||
- empty( $_GET['filename'] ) ||
- ! is_string( $_GET['filename'] ) ||
+ ! is_array( $request ) ||
+ ! isset( $request['action'] ) ||
+ self::DOWNLOAD_EXPORT_ACTION !== $request['action'] ||
+ empty( $request['filename'] ) ||
+ ! is_string( $request['filename'] ) ||
! current_user_can( 'view_woocommerce_reports' )
) {
- return;
+ return null;
}
$exporter = new ReportCSVExporter();
- $exporter->set_filename( wp_unslash( $_GET['filename'] ) );
- // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $exporter->set_filename( $request['filename'] );
+
+ // The stored name only identifies the export, so the emailed link carries the report's date
+ // range to name the download after the period it covers. It never reaches the file path.
+ if ( ! empty( $request['date_range'] ) && is_string( $request['date_range'] ) ) {
+ $exporter->set_download_suffix( $request['date_range'] );
+ }
+
+ return $exporter;
+ }
+
+ /**
+ * Serve the export file.
+ */
+ public static function download_export_file() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read in get_requested_export(), which documents why there is no nonce and validates every value it reads.
+ $exporter = self::get_requested_export( wp_unslash( $_GET ) );
+
+ if ( ! $exporter ) {
+ return;
+ }
// Say so rather than serving an empty CSV: the exporter creates a blank file for a path
// that no longer exists, which reads as a report with no results.
@@ -272,20 +411,19 @@ class ReportExporter {
* @param int $user_id User ID that requested the email.
* @param string $export_id Unique ID for report (timestamp expected).
* @param string $report_type Report type. E.g. 'customers'.
+ * @param array $report_args Optional. Report parameters the export was queued with. Exports queued
+ * before WooCommerce 11.2.0 run without them.
* @return void
*/
- public static function email_report_download_link( $user_id, $export_id, $report_type ) {
+ public static function email_report_download_link( $user_id, $export_id, $report_type, $report_args = array() ) {
$percent_complete = self::get_export_percentage_complete( $report_type, $export_id );
if ( 100 === $percent_complete ) {
- $query_args = array(
- 'action' => self::DOWNLOAD_EXPORT_ACTION,
- 'filename' => "wc-{$report_type}-report-export-{$export_id}",
- );
- $download_url = add_query_arg( $query_args, admin_url() );
+ $download_url = self::get_download_url( $report_type, $export_id, $report_args );
\WC_Emails::instance();
$email = new ReportCSVEmail();
+ $email->set_report_date_range( self::get_export_date_range_label( $report_args ) );
$email->trigger( $user_id, $report_type, $download_url );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
index acb24f295de..99f7f784ad9 100644
--- a/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
@@ -21,10 +21,12 @@ class ReportCSVEmailTest extends WC_Unit_Test_Case {
/**
* Build an email addressed at a generated export.
*
+ * @param string $date_range Optional. Date range the report covers, formatted for display.
* @return ReportCSVEmail
*/
- private function create_email(): ReportCSVEmail {
+ private function create_email( string $date_range = '' ): ReportCSVEmail {
$email = new ReportCSVEmail();
+ $email->set_report_date_range( $date_range );
foreach ( array(
'report_type' => 'Orders',
@@ -63,4 +65,102 @@ class ReportCSVEmailTest extends WC_Unit_Test_Case {
'The plain text email should tell the merchant how long the link lasts.'
);
}
+
+ /**
+ * @testdox The email says which period the report covers.
+ *
+ * @testWith ["get_content_html"]
+ * ["get_content_plain"]
+ *
+ * @param string $method Method that renders the email body.
+ */
+ public function test_email_states_the_date_range( string $method ): void {
+ $content = $this->create_email( 'June 1, 2025 - June 30, 2025' )->$method();
+
+ $this->assertStringContainsString(
+ 'Date range: June 1, 2025 - June 30, 2025',
+ $content,
+ 'The email should say which period the report covers.'
+ );
+ }
+
+ /**
+ * @testdox A report without a date range says nothing about one.
+ *
+ * @testWith ["get_content_html"]
+ * ["get_content_plain"]
+ *
+ * @param string $method Method that renders the email body.
+ */
+ public function test_email_omits_an_empty_date_range( string $method ): void {
+ $content = $this->create_email()->$method();
+
+ $this->assertStringNotContainsString(
+ 'Date range:',
+ $content,
+ 'Reports that are not limited to a period should not mention a date range.'
+ );
+ }
+
+ /**
+ * @testdox The subject says which period the report covers, so two exports of the same report can be told apart unopened.
+ */
+ public function test_subject_states_the_date_range(): void {
+ $subject = $this->trigger_and_get_subject( 'June 1, 2025 - June 30, 2025' );
+
+ $this->assertStringContainsString(
+ 'Your Orders Report for June 1, 2025 - June 30, 2025 is ready',
+ $subject,
+ 'The subject should name the period the report covers.'
+ );
+ }
+
+ /**
+ * @testdox A report without a date range keeps the plain subject.
+ */
+ public function test_subject_without_a_date_range(): void {
+ $subject = $this->trigger_and_get_subject();
+
+ $this->assertStringContainsString(
+ 'Your Orders Report download is ready',
+ $subject,
+ 'Reports that are not limited to a period should keep the original subject.'
+ );
+ }
+
+ /**
+ * @testdox trigger() keeps the parameters it shipped with, so a subclass overriding it still loads.
+ */
+ public function test_trigger_keeps_its_original_parameters(): void {
+ $parameters = ( new \ReflectionMethod( ReportCSVEmail::class, 'trigger' ) )->getParameters();
+
+ $this->assertCount(
+ 3,
+ $parameters,
+ 'trigger() is public and overridable. Adding a parameter, even an optional one, fatals every subclass that overrides the original three. Pass anything new through a setter instead.'
+ );
+ }
+
+ /**
+ * Send the email to a user and return the subject it went out with.
+ *
+ * @param string $date_range Optional. Date range the report covers, formatted for display.
+ * @return string
+ */
+ private function trigger_and_get_subject( string $date_range = '' ): string {
+ $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $mailer = tests_retrieve_phpmailer_instance();
+ $email = new ReportCSVEmail();
+
+ $email->set_report_date_range( $date_range );
+ $email->trigger(
+ $user_id,
+ 'orders',
+ 'https://example.org/?action=woocommerce_admin_download_report_csv&filename=wc-orders-report-export'
+ );
+
+ $sent = end( $mailer->mock_sent );
+
+ return $sent ? $sent['subject'] : '';
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
index 171f20ba492..d5660b6524f 100644
--- a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
@@ -148,6 +148,381 @@ class ReportExporterTest extends WC_Unit_Test_Case {
$this->assertFileExists( $reports_dir . 'index.html', 'Cleanup should not touch the directory guards.' );
}
+ /**
+ * @testdox A report's date range is read from the arguments it was exported with.
+ *
+ * @testWith ["2025-06-01T00:00:00", "2025-06-30T23:59:59", "2025-06-01", "2025-06-30"]
+ * ["2025-06-01", "2025-06-01", "2025-06-01", "2025-06-01"]
+ * ["2024-02-01T00:00:00", "2024-02-29T23:59:59", "2024-02-01", "2024-02-29"]
+ *
+ * @param string $after The export's `after` argument.
+ * @param string $before The export's `before` argument.
+ * @param string $expected_after Expected first day of the range.
+ * @param string $expected_before Expected last day of the range.
+ */
+ public function test_date_range_is_read_from_report_args( string $after, string $before, string $expected_after, string $expected_before ): void {
+ $this->assertSame(
+ array(
+ 'after' => $expected_after,
+ 'before' => $expected_before,
+ ),
+ ReportExporter::get_export_date_range(
+ array(
+ 'after' => $after,
+ 'before' => $before,
+ )
+ ),
+ 'The range should be the dates the report was run for, as written.'
+ );
+ }
+
+ /**
+ * @testdox Arguments without a usable date range produce no range.
+ *
+ * A date that does not exist counts as unusable. Left alone it would roll over, so an export
+ * run for June 31 would be labelled and named July 1.
+ *
+ * @testWith [{}]
+ * [{"after": "2025-06-01T00:00:00"}]
+ * [{"after": "2025-06-01T00:00:00", "before": ""}]
+ * [{"after": "2025-06-01T00:00:00", "before": "last month"}]
+ * [{"after": "2025-06-01T00:00:00", "before": ["2025-06-30"]}]
+ * [{"after": "2025-06-31T00:00:00", "before": "2025-06-30T23:59:59"}]
+ * [{"after": "2025-06-01T00:00:00", "before": "2025-13-45T00:00:00"}]
+ * [{"after": "2025-02-29T00:00:00", "before": "2025-03-01T00:00:00"}]
+ *
+ * @param array $report_args Report parameters the export was queued with.
+ */
+ public function test_report_args_without_a_date_range( array $report_args ): void {
+ $this->assertSame(
+ array(),
+ ReportExporter::get_export_date_range( $report_args ),
+ 'A report that is not limited to a period should report no date range.'
+ );
+ }
+
+ /**
+ * @testdox The date range is labelled in the store's date format.
+ */
+ public function test_date_range_label_uses_the_store_date_format(): void {
+ update_option( 'date_format', 'F j, Y' );
+
+ $this->assertSame(
+ 'June 1, 2025 - June 30, 2025',
+ ReportExporter::get_export_date_range_label(
+ array(
+ 'after' => '2025-06-01T00:00:00',
+ 'before' => '2025-06-30T23:59:59',
+ )
+ ),
+ 'The label should read as the merchant picked the range.'
+ );
+ }
+
+ /**
+ * @testdox The date range is labelled in the store's timezone, not in UTC.
+ *
+ * A date format that names the timezone should name the merchant's own, and the date itself
+ * should read the same whichever timezone the store keeps.
+ *
+ * @testWith ["Europe/Sofia", "Y-m-d T", "2025-06-01 EEST"]
+ * ["America/Los_Angeles", "Y-m-d T", "2025-06-01 PDT"]
+ * ["Pacific/Kiritimati", "F j, Y", "June 1, 2025"]
+ * ["Pacific/Midway", "F j, Y", "June 1, 2025"]
+ *
+ * @param string $timezone Store timezone.
+ * @param string $format Store date format.
+ * @param string $expected Expected label for a one day report.
+ */
+ public function test_date_range_label_uses_the_store_timezone( string $timezone, string $format, string $expected ): void {
+ update_option( 'timezone_string', $timezone );
+ update_option( 'date_format', $format );
+
+ $this->assertSame(
+ $expected,
+ ReportExporter::get_export_date_range_label(
+ array(
+ 'after' => '2025-06-01T00:00:00',
+ 'before' => '2025-06-01T23:59:59',
+ )
+ ),
+ 'The label should read in the store timezone rather than UTC.'
+ );
+ }
+
+ /**
+ * @testdox The date range label goes through the WooCommerce date format, so a store can filter it.
+ */
+ public function test_date_range_label_uses_the_woocommerce_date_format(): void {
+ update_option( 'date_format', 'F j, Y' );
+ add_filter( 'woocommerce_date_format', fn() => 'd/m/Y' );
+
+ $this->assertSame(
+ '01/06/2025 - 30/06/2025',
+ ReportExporter::get_export_date_range_label(
+ array(
+ 'after' => '2025-06-01T00:00:00',
+ 'before' => '2025-06-30T23:59:59',
+ )
+ ),
+ 'The label should honour woocommerce_date_format like the rest of WooCommerce date output.'
+ );
+ }
+
+ /**
+ * @testdox A single day range is labelled as one date rather than a range.
+ */
+ public function test_single_day_date_range_label(): void {
+ update_option( 'date_format', 'F j, Y' );
+
+ $this->assertSame(
+ 'June 1, 2025',
+ ReportExporter::get_export_date_range_label(
+ array(
+ 'after' => '2025-06-01T00:00:00',
+ 'before' => '2025-06-01T23:59:59',
+ )
+ ),
+ 'A one day report should not repeat the same date twice.'
+ );
+ }
+
+ /**
+ * @testdox An export is downloaded under a name that says which period it covers.
+ */
+ public function test_download_is_named_after_the_period_it_covers(): void {
+ $filename = $this->create_export( 'wc-products-report-export-1234567890' );
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( $filename );
+ $exporter->set_download_suffix( '2025-06-01-to-2025-06-30' );
+
+ $this->assertSame(
+ 'wc-products-report-export-1234567890-2025-06-01-to-2025-06-30.csv',
+ $exporter->get_download_filename(),
+ 'The download should be named after the period the report covers.'
+ );
+ $this->assertSame(
+ $filename,
+ $exporter->get_filename(),
+ 'Naming the download should leave the stored export name alone.'
+ );
+ $this->assertTrue(
+ $exporter->export_file_exists(),
+ 'The stored export should still be found under the name it was written with.'
+ );
+ }
+
+ /**
+ * @testdox A download link that names a date range is served under a name that says so.
+ */
+ public function test_download_request_names_the_download_after_the_date_range(): void {
+ $this->act_as_reports_user();
+
+ $exporter = $this->request_export( $this->download_request( array( 'date_range' => '2025-06-01-to-2025-06-30' ) ) );
+
+ $this->assertNotNull( $exporter, 'A valid download request should be served.' );
+ $this->assertSame(
+ 'wc-products-report-export-1234567890-2025-06-01-to-2025-06-30.csv',
+ $exporter->get_download_filename(),
+ 'The date range on the link should reach the name the export is downloaded as.'
+ );
+ $this->assertSame(
+ 'wc-products-report-export-1234567890.csv',
+ $exporter->get_filename(),
+ 'The date range on the link should never change the name the export is stored under.'
+ );
+ }
+
+ /**
+ * @testdox A download link without a date range keeps the stored export name.
+ */
+ public function test_download_request_without_a_date_range(): void {
+ $this->act_as_reports_user();
+
+ $exporter = $this->request_export( $this->download_request() );
+
+ $this->assertNotNull( $exporter, 'A valid download request should be served.' );
+ $this->assertSame(
+ 'wc-products-report-export-1234567890.csv',
+ $exporter->get_download_filename(),
+ 'A link that names no period should download under the stored name, as it did before.'
+ );
+ }
+
+ /**
+ * @testdox A hostile date range cannot escape the download name or the reports directory.
+ *
+ * @testWith ["../../../../etc/passwd", "wc-products-report-export-1234567890-etcpasswd.csv"]
+ * ["a\r\nX-Injected: 1", "wc-products-report-export-1234567890-a-X-Injected-1.csv"]
+ * ["setup.bat", "wc-products-report-export-1234567890-setup.bat_.csv"]
+ *
+ * @param string $date_range Date range as it arrives on the link.
+ * @param string $expected Expected download name.
+ */
+ public function test_download_request_sanitises_the_date_range( string $date_range, string $expected ): void {
+ $this->act_as_reports_user();
+
+ $exporter = $this->request_export( $this->download_request( array( 'date_range' => $date_range ) ) );
+
+ $this->assertNotNull( $exporter, 'A valid download request should be served.' );
+ $this->assertSame(
+ $expected,
+ $exporter->get_download_filename(),
+ 'The date range only names the download, so it must not carry separators or a second extension.'
+ );
+ $this->assertSame(
+ 'wc-products-report-export-1234567890.csv',
+ $exporter->get_filename(),
+ 'The date range must never reach the path the export is read from.'
+ );
+ }
+
+ /**
+ * @testdox A download request is refused without the reports capability.
+ */
+ public function test_download_request_requires_the_reports_capability(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $this->assertNull(
+ $this->request_export( $this->download_request() ),
+ 'A user who cannot view reports should not be served an export.'
+ );
+ }
+
+ /**
+ * @testdox Requests that do not ask for an export are left alone.
+ *
+ * @testWith [{}]
+ * [{"action": "edit", "filename": "wc-products-report-export-1234567890"}]
+ * [{"action": "woocommerce_admin_download_report_csv"}]
+ * [{"action": "woocommerce_admin_download_report_csv", "filename": ""}]
+ * [{"action": "woocommerce_admin_download_report_csv", "filename": ["x"]}]
+ *
+ * @param array $request Request parameters, as the download handler reads them.
+ */
+ public function test_requests_that_do_not_ask_for_an_export( array $request ): void {
+ $this->act_as_reports_user();
+
+ $this->assertNull(
+ $this->request_export( $request ),
+ 'The download handler should leave requests that are not report downloads alone.'
+ );
+ }
+
+ /**
+ * Sign in as a user allowed to view reports.
+ *
+ * @return void
+ */
+ private function act_as_reports_user(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+ }
+
+ /**
+ * Build the parameters of a valid download link.
+ *
+ * @param array $extra Parameters to add to the request.
+ * @return array
+ */
+ private function download_request( array $extra = array() ): array {
+ return array_merge(
+ array(
+ 'action' => ReportExporter::DOWNLOAD_EXPORT_ACTION,
+ 'filename' => 'wc-products-report-export-1234567890',
+ ),
+ $extra
+ );
+ }
+
+ /**
+ * Build the exporter that a download request would be served by.
+ *
+ * Reaches the handler's own reading of the request, so that dropping the date range on the way
+ * from the link to the download's name fails a test.
+ *
+ * @param array $request Request parameters, as the download handler reads them from `$_GET`.
+ * @return ReportCSVExporter|null
+ */
+ private function request_export( array $request ) {
+ $method = new \ReflectionMethod( ReportExporter::class, 'get_requested_export' );
+ $method->setAccessible( true );
+
+ return $method->invoke( null, $request );
+ }
+
+ /**
+ * @testdox The emailed download link names the period the export covers.
+ */
+ public function test_emailed_link_carries_the_date_range(): void {
+ $sent = $this->email_completed_export(
+ array(
+ array(
+ 'after' => '2025-06-01T00:00:00',
+ 'before' => '2025-06-30T23:59:59',
+ ),
+ )
+ );
+
+ $this->assertStringContainsString(
+ 'date_range=2025-06-01-to-2025-06-30',
+ $sent['body'],
+ 'The emailed link should name the period the export covers.'
+ );
+ }
+
+ /**
+ * @testdox An export queued before the date range was added still emails a working link.
+ */
+ public function test_emailed_link_for_an_export_queued_without_report_args(): void {
+ // Exports queued by an earlier release carry three arguments, not four.
+ $sent = $this->email_completed_export( array() );
+
+ $this->assertStringContainsString(
+ 'Your Products Report download is ready',
+ $sent['subject'],
+ 'An export queued without report arguments should keep the original subject.'
+ );
+ $this->assertStringContainsString(
+ 'action=woocommerce_admin_download_report_csv',
+ $sent['body'],
+ 'An export queued without report arguments should still be emailed a download link.'
+ );
+ $this->assertStringNotContainsString(
+ 'date_range=',
+ $sent['body'],
+ 'An export with no known date range should not claim one.'
+ );
+ }
+
+ /**
+ * Email the download link for a finished export and return the message that went out.
+ *
+ * Dispatched through the hook Action Scheduler fires, so the number of arguments a queued
+ * action carries is what decides how the callback is reached.
+ *
+ * @param array $queued_args Arguments the queued action carries after the report type.
+ * @return array The sent message.
+ */
+ private function email_completed_export( array $queued_args ): array {
+ $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $export_id = (string) microtime( true );
+ $hook = ReportExporter::get_action( 'email_report_download_link' );
+ $mailer = tests_retrieve_phpmailer_instance();
+
+ ReportExporter::update_export_percentage_complete( 'products', $export_id, 100 );
+
+ $this->assertNotFalse( has_action( $hook ), 'The export email action should be registered.' );
+
+ do_action_ref_array( $hook, array_merge( array( $user_id, $export_id, 'products' ), $queued_args ) );
+
+ $sent = end( $mailer->mock_sent );
+
+ $this->assertIsArray( $sent, 'A finished export should be emailed to the user who asked for it.' );
+
+ return $sent;
+ }
+
/**
* @testdox Cleanup runs from the daily WooCommerce Admin event.
*/