Commit d35e045b715 for woocommerce
commit d35e045b71595ae0e3f9ff8fbe2a9ca2b1ad4d54
Author: Ján Mikláš <neosinner@gmail.com>
Date: Fri Sep 4 15:47:44 2026 +0200
Keep emailed Analytics report downloads available for retry (#68312)
* Keep emailed Analytics report downloads available for retry
WC_CSV_Batch_Exporter::export() unlinks the CSV while streaming it, so the
download link WooCommerce emails to the merchant works exactly once. Email
security scanners and link prefetchers that fetch the URL consume the export,
and the merchant's own click then returns an empty report.
ReportCSVExporter now streams the export without deleting it, and reads the
body with readfile() rather than pulling the whole file into memory - exports
are emailed precisely when the report is too large for one request. A download
for an export that is no longer on disk says so instead of serving a CSV that
looks like a report with no results.
Generated exports had no cleanup at all before this: one that nobody downloaded
stayed on disk forever. They are now removed a week after they were written, by
a handler on the existing wc_admin_daily event. Serving and expiring stay
registered when Analytics is switched off, so links that were already emailed
keep working and old exports still expire, while generation and email handlers
remain disabled.
Fixes WOOA7S-1857.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169i1KYNcjwBdCCUZ2cpX8h
* Treat an unfinished export as unavailable
WC_CSV_Batch_Exporter only writes the `.headers` companion once the export
reaches 100%, so a body without one is an export that stopped part way. The
download endpoint builds its exporter from the filename alone, so the header
fallback in get_headers_row_file() resolves to nothing and the merchant gets a
BOM, a blank line, and partial rows. Require both files so an incomplete export
returns the same 404 as a missing one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQuqFGRsLMyceRFW1KY3qF
* Tell the merchant how long the export link lasts
The retention period was only stated on the 404 page, which the merchant reaches
after the link has already stopped working. Say it in the email that carries the
link instead, sourced from ReportExporter::EXPORT_RETENTION_PERIOD so the two
cannot drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQuqFGRsLMyceRFW1KY3qF
* Give the template @var docblocks a short description
phpcs Generic.Commenting.DocComment.MissingShort rejects the one-line form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQuqFGRsLMyceRFW1KY3qF
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooa7s-1857-repeatable-report-downloads b/plugins/woocommerce/changelog/wooa7s-1857-repeatable-report-downloads
new file mode 100644
index 00000000000..0953941cdcd
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooa7s-1857-repeatable-report-downloads
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep emailed Analytics report exports downloadable for a week instead of deleting them on the first request.
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 519cbce0edc..da2c109b7ad 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,17 @@ do_action( 'woocommerce_email_header', $email_heading, $email );
echo esc_html( sprintf( __( 'Download your %s Report', 'woocommerce' ), $report_name ) );
?>
</a>
+<p>
+ <?php
+ /**
+ * Length of time the download link stays valid, passed in by ReportCSVEmail.
+ *
+ * @var string $retention
+ */
+ /* translators: %s: length of time the download link stays valid, e.g. "1 week" */
+ echo esc_html( sprintf( __( 'This link is available for %s.', 'woocommerce' ), $retention ) );
+ ?>
+</p>
<?php
/*
* @hooked WC_Emails::email_footer() Output the email footer
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 4b4de3ec62b..365bf01bf51 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
@@ -14,6 +14,16 @@ echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
/* translators: %1$s: report name, %2$s: download URL */
echo wp_kses_post( sprintf( __( 'Download your %1$s Report: %2$s', 'woocommerce' ), $report_name, $download_url ) );
+echo "\n\n";
+
+/**
+ * Length of time the download link stays valid, passed in by ReportCSVEmail.
+ *
+ * @var string $retention
+ */
+/* translators: %s: length of time the download link stays valid, e.g. "1 week" */
+echo esc_html( sprintf( __( 'This link is available for %s.', 'woocommerce' ), $retention ) );
+
echo "\n\n----------------------------------------\n\n";
echo wp_kses_post( apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ) );
diff --git a/plugins/woocommerce/src/Admin/ReportCSVEmail.php b/plugins/woocommerce/src/Admin/ReportCSVEmail.php
index 5a7f7846e3c..3deebe9301a 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVEmail.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVEmail.php
@@ -132,6 +132,7 @@ class ReportCSVEmail extends \WC_Email {
'sent_to_admin' => true,
'plain_text' => false,
'email' => $this,
+ 'retention' => $this->get_retention_period(),
),
'',
$this->template_base
@@ -153,12 +154,23 @@ class ReportCSVEmail extends \WC_Email {
'sent_to_admin' => true,
'plain_text' => true,
'email' => $this,
+ 'retention' => $this->get_retention_period(),
),
'',
$this->template_base
);
}
+ /**
+ * Get how long the emailed download link stays valid, for display.
+ *
+ * @since 11.2.0
+ * @return string Human readable length of time, e.g. "1 week".
+ */
+ protected function get_retention_period() {
+ return human_time_diff( 0, ReportExporter::EXPORT_RETENTION_PERIOD );
+ }
+
/**
* Trigger the sending of this email.
*
diff --git a/plugins/woocommerce/src/Admin/ReportCSVExporter.php b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
index cafe730af05..a85d26c7a63 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
@@ -116,6 +116,48 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
return self::get_reports_directory() . $this->get_filename();
}
+ /**
+ * Check whether a complete generated export is still on disk.
+ *
+ * The `.headers` companion is only written once the export reaches 100%, so a body without one
+ * is an export that never finished and must not be served.
+ *
+ * @since 11.2.0
+ * @return bool
+ */
+ public function export_file_exists() {
+ return file_exists( $this->get_file_path() ) && file_exists( $this->get_headers_row_file_path() );
+ }
+
+ /**
+ * Write the export to the output, leaving the file in place. Send the download headers first.
+ *
+ * @since 11.2.0
+ * @return void
+ */
+ public function stream_export_file() {
+ $this->send_content( $this->get_headers_row_file() );
+
+ // Stream the body instead of reading it into memory. Exports are emailed precisely when the
+ // report is too large to return in one request, so it can be far larger than the memory limit.
+ readfile( $this->get_file_path() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile, WordPress.Security.EscapeOutput.OutputNotEscaped -- Streaming a generated CSV download.
+ }
+
+ /**
+ * Serve the export file.
+ *
+ * Unlike the parent, this keeps the file so that the download link WooCommerce emails to the
+ * merchant works more than once. ReportExporter::delete_expired_exports() removes it later.
+ *
+ * @since 11.2.0
+ * @return void
+ */
+ public function export() {
+ $this->send_headers();
+ $this->stream_export_file();
+ die();
+ }
+
/**
* Setter for report type.
diff --git a/plugins/woocommerce/src/Admin/ReportExporter.php b/plugins/woocommerce/src/Admin/ReportExporter.php
index 2b80668cb1f..2323aeaf1bf 100644
--- a/plugins/woocommerce/src/Admin/ReportExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportExporter.php
@@ -39,6 +39,11 @@ class ReportExporter {
*/
const DOWNLOAD_EXPORT_ACTION = 'woocommerce_admin_download_report_csv';
+ /**
+ * How long a generated export stays available for download.
+ */
+ const EXPORT_RETENTION_PERIOD = WEEK_IN_SECONDS;
+
/**
* Get all available scheduling actions.
* Used to determine action hook names and clear events.
@@ -70,8 +75,51 @@ class ReportExporter {
// Initialize scheduled action handlers.
self::scheduler_init();
- // Report download handler.
+ self::init_export_files();
+ }
+
+ /**
+ * Hook in the handlers that serve and expire already generated exports.
+ *
+ * These stay registered when Analytics is switched off, so a link that was already emailed
+ * keeps working and old exports still get cleaned up, even though no new ones are generated.
+ *
+ * @internal
+ * @since 11.2.0
+ * @return void
+ */
+ public static function init_export_files() {
add_action( 'admin_init', array( __CLASS__, 'download_export_file' ) );
+ add_action( 'wc_admin_daily', array( __CLASS__, 'delete_expired_exports' ) );
+ }
+
+ /**
+ * Delete generated exports that are past the retention period.
+ *
+ * @internal
+ * @since 11.2.0
+ * @return void
+ */
+ public static function delete_expired_exports() {
+ $expired_before = time() - self::EXPORT_RETENTION_PERIOD;
+
+ // Both the report body and its `.headers` companion, but never the directory's
+ // .htaccess and index.html guards.
+ $paths = glob( ReportCSVExporter::get_reports_directory() . '*.csv*' );
+ if ( ! $paths ) {
+ return;
+ }
+
+ foreach ( $paths as $path ) {
+ if ( ! is_file( $path ) ) {
+ continue;
+ }
+
+ $modified = filemtime( $path );
+ if ( $modified && $modified < $expired_before ) {
+ wp_delete_file( $path );
+ }
+ }
}
/**
@@ -175,18 +223,47 @@ class ReportExporter {
* Serve the export file.
*/
public static function download_export_file() {
- // @todo - add nonce? (nonces are good for 24 hours)
+ /*
+ * 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
if (
- isset( $_GET['action'] ) &&
- ! empty( $_GET['filename'] ) &&
- is_string( $_GET['filename'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Download of a report file generated for the requesting user and deleted once served; gated on the view_woocommerce_reports capability, so a nonce would only prevent nuisance CSRF.
- self::DOWNLOAD_EXPORT_ACTION === wp_unslash( $_GET['action'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Download of a report file generated for the requesting user and deleted once served; gated on the view_woocommerce_reports capability, so a nonce would only prevent nuisance CSRF. The value is only compared verbatim against a fixed action name.
- current_user_can( 'view_woocommerce_reports' )
+ ! isset( $_GET['action'] ) ||
+ self::DOWNLOAD_EXPORT_ACTION !== wp_unslash( $_GET['action'] ) ||
+ empty( $_GET['filename'] ) ||
+ ! is_string( $_GET['filename'] ) ||
+ ! current_user_can( 'view_woocommerce_reports' )
) {
- $exporter = new ReportCSVExporter();
- $exporter->set_filename( wp_unslash( $_GET['filename'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Download of a report file generated for the requesting user and deleted once served; gated on the view_woocommerce_reports capability, so a nonce would only prevent nuisance CSRF. set_filename() applies sanitize_file_name(), which keeps the path inside the reports directory.
- $exporter->export();
+ return;
}
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( wp_unslash( $_GET['filename'] ) );
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ // 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.
+ if ( ! $exporter->export_file_exists() ) {
+ wp_die(
+ esc_html(
+ sprintf(
+ /* translators: %s: length of time an export is kept, e.g. "1 week". */
+ __( 'This report export is no longer available. Exports are kept for %s, so please request a new one.', 'woocommerce' ),
+ human_time_diff( 0, self::EXPORT_RETENTION_PERIOD )
+ )
+ ),
+ esc_html__( 'Report export unavailable', 'woocommerce' ),
+ array( 'response' => 404 )
+ );
+ }
+
+ $exporter->send_headers();
+ $exporter->stream_export_file();
+ exit;
}
/**
diff --git a/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php b/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
index aedd47243e8..4da922b912b 100644
--- a/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
+++ b/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
@@ -179,6 +179,9 @@ class FeaturePlugin {
CategoryLookup::instance()->init();
// Initialize Reports exporter.
ReportExporter::init();
+ } else {
+ // Already generated exports still need serving and expiring.
+ ReportExporter::init_export_files();
}
// Admin note providers.
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
new file mode 100644
index 00000000000..acb24f295de
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Tests for the Analytics report export email.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin;
+
+use Automattic\WooCommerce\Admin\ReportCSVEmail;
+use Automattic\WooCommerce\Admin\ReportExporter;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the emailed report download link.
+ */
+class ReportCSVEmailTest extends WC_Unit_Test_Case {
+
+ /**
+ * Build an email addressed at a generated export.
+ *
+ * @return ReportCSVEmail
+ */
+ private function create_email(): ReportCSVEmail {
+ $email = new ReportCSVEmail();
+
+ foreach ( array(
+ 'report_type' => 'Orders',
+ 'download_url' => 'https://example.org/?action=woocommerce_admin_download_report_csv&filename=wc-orders-report-export',
+ ) as $name => $value ) {
+ $property = new \ReflectionProperty( ReportCSVEmail::class, $name );
+ $property->setAccessible( true );
+ $property->setValue( $email, $value );
+ }
+
+ return $email;
+ }
+
+ /**
+ * @testdox The HTML email says how long the download link stays valid.
+ */
+ public function test_html_email_states_the_retention_period(): void {
+ $content = $this->create_email()->get_content_html();
+
+ $this->assertStringContainsString(
+ 'This link is available for ' . human_time_diff( 0, ReportExporter::EXPORT_RETENTION_PERIOD ) . '.',
+ $content,
+ 'The HTML email should tell the merchant how long the link lasts.'
+ );
+ }
+
+ /**
+ * @testdox The plain text email says how long the download link stays valid.
+ */
+ public function test_plain_email_states_the_retention_period(): void {
+ $content = $this->create_email()->get_content_plain();
+
+ $this->assertStringContainsString(
+ 'This link is available for ' . human_time_diff( 0, ReportExporter::EXPORT_RETENTION_PERIOD ) . '.',
+ $content,
+ 'The plain text email should tell the merchant how long the link lasts.'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
new file mode 100644
index 00000000000..171f20ba492
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * Tests for the Analytics report exporter.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin;
+
+use Automattic\WooCommerce\Admin\ReportCSVExporter;
+use Automattic\WooCommerce\Admin\ReportExporter;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for retained report exports.
+ */
+class ReportExporterTest extends WC_Unit_Test_Case {
+
+ /**
+ * Paths written by a test, removed in tear down.
+ *
+ * @var string[]
+ */
+ private $paths = array();
+
+ /**
+ * Remove files that the database transaction does not roll back.
+ */
+ public function tearDown(): void {
+ foreach ( $this->paths as $path ) {
+ if ( file_exists( $path ) ) {
+ wp_delete_file( $path );
+ }
+ }
+ $this->paths = array();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Write an export to the reports directory.
+ *
+ * @param string $filename Filename without the extension.
+ * @param string $body Export body.
+ * @param int $age How long ago the export was written, in seconds.
+ * @return string Resolved filename, as the download handler and cleanup see it.
+ */
+ private function create_export( string $filename, string $body = "1,2\n", int $age = 0 ): string {
+ ReportCSVExporter::maybe_create_directory();
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( $filename );
+ $resolved = $exporter->get_filename();
+ $path = ReportCSVExporter::get_reports_directory() . $resolved;
+
+ $this->write_file( $path, $body, $age );
+ $this->write_file( $path . '.headers', "id,total\n", $age );
+
+ return $resolved;
+ }
+
+ /**
+ * Write one file and backdate it.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @param int $age How long ago the file was written, in seconds.
+ * @return void
+ */
+ private function write_file( string $path, string $contents, int $age ): void {
+ file_put_contents( $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ touch( $path, time() - $age ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
+
+ $this->paths[] = $path;
+ }
+
+ /**
+ * @testdox Downloading an export streams it without consuming it, so the link can be reused.
+ */
+ public function test_streaming_an_export_leaves_it_in_place(): void {
+ $filename = $this->create_export( 'wc-orders-report-export-repeatable', "42,7\n" );
+ $path = ReportCSVExporter::get_reports_directory() . $filename;
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( $filename );
+
+ ob_start();
+ $exporter->stream_export_file();
+ $first = ob_get_clean();
+
+ $this->assertStringContainsString( '42,7', $first, 'The export body should be streamed to the client.' );
+ $this->assertStringContainsString( 'id,total', $first, 'The stored header row should be streamed ahead of the body.' );
+ $this->assertFileExists( $path, 'Serving an export should not delete it.' );
+
+ ob_start();
+ $exporter->stream_export_file();
+ $second = ob_get_clean();
+
+ $this->assertSame( $first, $second, 'A second download should return the same export.' );
+ }
+
+ /**
+ * @testdox An export that is gone reports itself as missing instead of serving an empty report.
+ */
+ public function test_missing_export_is_reported_as_missing(): void {
+ $filename = $this->create_export( 'wc-orders-report-export-missing' );
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( $filename );
+
+ $this->assertTrue( $exporter->export_file_exists(), 'A generated export should be reported as available.' );
+
+ wp_delete_file( ReportCSVExporter::get_reports_directory() . $filename );
+
+ $this->assertFalse( $exporter->export_file_exists(), 'A deleted export should be reported as unavailable.' );
+ }
+
+ /**
+ * @testdox An export that never finished writing reports itself as missing.
+ */
+ public function test_incomplete_export_is_reported_as_missing(): void {
+ $filename = $this->create_export( 'wc-orders-report-export-incomplete' );
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( $filename );
+
+ // A body with no `.headers` companion is an export that stopped before reaching 100%.
+ wp_delete_file( ReportCSVExporter::get_reports_directory() . $filename . '.headers' );
+
+ $this->assertFalse( $exporter->export_file_exists(), 'An unfinished export should be reported as unavailable.' );
+ }
+
+ /**
+ * @testdox Daily cleanup deletes only exports past the retention period.
+ */
+ public function test_cleanup_deletes_only_expired_exports(): void {
+ $reports_dir = ReportCSVExporter::get_reports_directory();
+ $expired = $this->create_export( 'wc-orders-report-export-expired', "1,2\n", ReportExporter::EXPORT_RETENTION_PERIOD + HOUR_IN_SECONDS );
+ $fresh = $this->create_export( 'wc-orders-report-export-fresh', "3,4\n", ReportExporter::EXPORT_RETENTION_PERIOD - HOUR_IN_SECONDS );
+
+ ReportExporter::delete_expired_exports();
+
+ $this->assertFileDoesNotExist( $reports_dir . $expired, 'An expired export should be deleted.' );
+ $this->assertFileDoesNotExist( $reports_dir . $expired . '.headers', 'An expired export header row should be deleted too.' );
+ $this->assertFileExists( $reports_dir . $fresh, 'An export inside the retention period should be kept.' );
+ $this->assertFileExists( $reports_dir . $fresh . '.headers', 'An export header row inside the retention period should be kept.' );
+ $this->assertFileExists( $reports_dir . '.htaccess', 'Cleanup should not touch the directory guards.' );
+ $this->assertFileExists( $reports_dir . 'index.html', 'Cleanup should not touch the directory guards.' );
+ }
+
+ /**
+ * @testdox Cleanup runs from the daily WooCommerce Admin event.
+ */
+ public function test_cleanup_is_hooked_to_the_daily_event(): void {
+ $this->assertSame(
+ 10,
+ has_action( 'wc_admin_daily', array( ReportExporter::class, 'delete_expired_exports' ) ),
+ 'Expired exports should be cleaned up by the daily event rather than on each request.'
+ );
+ $this->assertSame(
+ 10,
+ has_action( 'admin_init', array( ReportExporter::class, 'download_export_file' ) ),
+ 'The download handler should be registered.'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/FeaturePluginTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/FeaturePluginTest.php
index 2764d4a9b4f..ca7ef17659f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/FeaturePluginTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/FeaturePluginTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Admin;
+use Automattic\WooCommerce\Admin\ReportExporter;
use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Internal\Admin\FeaturePlugin;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
@@ -142,6 +143,35 @@ class FeaturePluginTest extends WC_Unit_Test_Case {
$this->assertSame( 0, $translation_count, 'The bootstrap gate should not translate WooCommerce feature definitions.' );
}
+ /**
+ * @testdox Serving and expiring existing exports stays enabled when Analytics is disabled.
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_export_files_are_still_handled_when_analytics_is_disabled(): void {
+ $export_hook = ReportExporter::get_action( 'export_report' );
+ $scheduler_hook = ReportExporter::get_action( 'schedule_action' );
+ $scheduler_callback = array( ReportExporter::class, 'do_action_or_reschedule' );
+ $download_callback = array( ReportExporter::class, 'download_export_file' );
+ $cleanup_callback = array( ReportExporter::class, 'delete_expired_exports' );
+
+ $this->assertIsString( $export_hook, 'The export hook should be defined.' );
+ $this->assertIsString( $scheduler_hook, 'The scheduler hook should be defined.' );
+
+ remove_action( $export_hook, $scheduler_callback, 10 );
+ remove_action( $scheduler_hook, $scheduler_callback, 10 );
+ remove_action( 'admin_init', $download_callback, 10 );
+ remove_action( 'wc_admin_daily', $cleanup_callback, 10 );
+ update_option( Analytics::TOGGLE_OPTION_NAME, 'no' );
+
+ FeaturePlugin::instance()->includes();
+
+ $this->assertSame( 10, has_action( 'admin_init', $download_callback ), 'Links that were already emailed should keep working.' );
+ $this->assertSame( 10, has_action( 'wc_admin_daily', $cleanup_callback ), 'Existing exports should still expire.' );
+ $this->assertFalse( has_action( $export_hook, $scheduler_callback ), 'Export generation should stay disabled.' );
+ $this->assertFalse( has_action( $scheduler_hook, $scheduler_callback ), 'Export scheduling should stay disabled.' );
+ }
+
/**
* Disable Analytics in the legacy feature list.
*