Commit 6441358dce0 for woocommerce
commit 6441358dce0202d42a7a51d02a2b97f3dca0aeaa
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date: Mon Oct 12 11:14:10 2026 +0300
Fix Analytics report exports losing rows and skipping the email (#68955)
diff --git a/plugins/woocommerce/changelog/fix-66311-analytics-export-out-of-order-batches b/plugins/woocommerce/changelog/fix-66311-analytics-export-out-of-order-batches
new file mode 100644
index 00000000000..7d302c21af1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66311-analytics-export-out-of-order-batches
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Analytics report exports no longer lose rows or skip the download email when their batches finish out of order.
diff --git a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php
index fc9645bd4cd..e3a755c8d24 100644
--- a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php
+++ b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php
@@ -106,6 +106,7 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter {
* Generate the CSV file.
*
* @since 3.1.0
+ * @return bool Whether the page's data was written to the file.
*/
public function generate_file() {
if ( 1 === $this->get_page() ) {
@@ -117,7 +118,8 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter {
$this->get_file();
}
$this->prepare_data_to_export();
- $this->write_csv_data( $this->get_csv_data() );
+
+ return $this->write_csv_data( $this->get_csv_data() );
}
/**
@@ -125,6 +127,7 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter {
*
* @since 3.1.0
* @param string $data Data.
+ * @return bool Whether all of the data was written to the file.
*/
protected function write_csv_data( $data ) {
@@ -154,6 +157,7 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter {
*/
$fopen_mode = apply_filters( 'woocommerce_csv_exporter_fopen_mode', 'a' );
$fp = fopen( $this->get_file_path(), $fopen_mode );
+ $written = false;
if ( $fp ) {
// WP_Filesystem has no streaming write API, so it would mean rewriting the whole export on every batch.
@@ -171,6 +175,8 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter {
),
$log_context
);
+ } else {
+ $written = true;
}
} else {
// fopen() raises a warning when it fails, so the last PHP error usually explains why (permissions, an
@@ -197,6 +203,7 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter {
@file_put_contents( $this->get_headers_row_file_path(), $header ); //phpcs:ignore WordPress.VIP.FileSystemWritesDisallow.file_ops_file_put_contents, Generic.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents
}
+ return $written;
}
/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 3b2201780c3..db0cb0ea4f1 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -18798,12 +18798,6 @@ parameters:
count: 1
path: includes/export/abstract-wc-csv-batch-exporter.php
- -
- message: '#^Method WC_CSV_Batch_Exporter\:\:generate_file\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/export/abstract-wc-csv-batch-exporter.php
-
-
message: '#^Method WC_CSV_Batch_Exporter\:\:get_file\(\) should return string but returns string\|false\.$#'
identifier: return.type
@@ -18822,12 +18816,6 @@ parameters:
count: 1
path: includes/export/abstract-wc-csv-batch-exporter.php
- -
- message: '#^Method WC_CSV_Batch_Exporter\:\:write_csv_data\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/export/abstract-wc-csv-batch-exporter.php
-
-
message: '#^Method WC_CSV_Exporter\:\:export\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Admin/ReportCSVExporter.php b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
index 7f59cc9c6bd..9f4be6678b2 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
@@ -354,6 +354,61 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
return intval( parent::get_percent_complete() );
}
+ /**
+ * Write one page of an export that write_headers_row_file() finishes.
+ *
+ * @internal
+ * @since 11.3.0
+ * @return bool Whether the page's rows were written.
+ */
+ public function write_page() {
+ $written = $this->generate_file();
+
+ $this->delete_headers_row_file();
+
+ return $written;
+ }
+
+ /**
+ * Write the headers row file, which is what marks an export complete and downloadable.
+ *
+ * Not written when the export file is missing or not writable, since the parent skips a page's rows on the same check.
+ *
+ * @internal
+ * @since 11.3.0
+ * @return bool Whether the headers row file was written.
+ */
+ public function write_headers_row_file() {
+ $file_path = $this->get_file_path();
+
+ if ( ! file_exists( $file_path ) || ! wp_is_writable( $file_path ) ) {
+ return false;
+ }
+
+ $header = chr( 239 ) . chr( 187 ) . chr( 191 ) . $this->export_column_headers();
+
+ if ( strlen( $header ) !== @file_put_contents( $this->get_headers_row_file_path(), $header ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $this->delete_headers_row_file();
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Delete the headers row file, so the export is not downloadable.
+ *
+ * @return void
+ */
+ private function delete_headers_row_file() {
+ $headers_path = $this->get_headers_row_file_path();
+
+ if ( file_exists( $headers_path ) ) {
+ wp_delete_file( $headers_path );
+ }
+ }
+
/**
* Get total number of rows in export.
*
diff --git a/plugins/woocommerce/src/Admin/ReportExporter.php b/plugins/woocommerce/src/Admin/ReportExporter.php
index 6fc5f39c899..5e63f55ed59 100644
--- a/plugins/woocommerce/src/Admin/ReportExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportExporter.php
@@ -189,16 +189,12 @@ class ReportExporter {
$batch_size = $exporter->get_limit();
$num_batches = (int) ceil( $total_rows / $batch_size );
- // Create batches, like initial import.
- $report_batch_args = array( $export_id, $report_type, $report_args );
-
if ( 0 < $num_batches ) {
- self::queue_batches( 1, $num_batches, 'export_report', $report_batch_args );
+ // An export queued outside a user session has no user to email, but a filter on the email's recipient can still name one.
+ $email_user_id = $send_email ? get_current_user_id() : false;
- if ( $send_email ) {
- $email_action_args = array( get_current_user_id(), $export_id, $report_type, $report_args );
- self::schedule_action( 'email_report_download_link', $email_action_args );
- }
+ // Only the first page is queued. Each page queues the next one, so the pages run one at a time and in order.
+ self::schedule_action( 'export_report', array( 1, $export_id, $report_type, $report_args, $email_user_id, $num_batches ) );
}
return $total_rows;
@@ -207,20 +203,93 @@ class ReportExporter {
/**
* Process a report export action.
*
- * @param int $page_number Page number for this action.
+ * @param int $page_number Page number for this action.
+ * @param string $export_id Unique ID for report (timestamp expected).
+ * @param string $report_type Report type. E.g. 'customers'.
+ * @param array $report_args Report parameters, passed to data query.
+ * @param int|false $email_user_id Optional. User to email the download link to once the last page is written, or false for no email.
+ * @param int $num_batches Optional. Number of pages in the export. Exports queued before 11.3.0 run without it.
+ * @return void
+ */
+ public static function export_report( $page_number, $export_id, $report_type, $report_args, $email_user_id = false, $num_batches = 0 ) {
+ $exporter = self::get_page_exporter( $page_number, $export_id, $report_type, $report_args );
+
+ // An export queued before 11.3.0 queued all its pages at once, and its email with them.
+ if ( ! $num_batches ) {
+ $exporter->generate_file();
+ self::update_export_percentage_complete( $report_type, $export_id, $exporter->get_percent_complete() );
+ return;
+ }
+
+ $written = $exporter->write_page();
+
+ // Without Action Scheduler the other pages run here, in a loop rather than one nested call per page.
+ /**
+ * This filter is documented in includes/wc-update-functions.php
+ *
+ * @since 4.0.0
+ */
+ if ( ! get_option( 'schema-ActionScheduler_StoreSchema' ) || apply_filters( 'woocommerce_analytics_disable_action_scheduling', false ) ) {
+ while ( $written && $page_number < $num_batches ) {
+ ++$page_number;
+ $exporter = self::get_page_exporter( $page_number, $export_id, $report_type, $report_args );
+ $written = $exporter->write_page();
+ }
+ }
+
+ if ( $written && $page_number < $num_batches ) {
+ self::update_export_percentage_complete( $report_type, $export_id, (int) floor( $page_number / $num_batches * 100 ) );
+ self::queue_next_page( array( $page_number + 1, $export_id, $report_type, $report_args, $email_user_id, $num_batches ) );
+ return;
+ }
+
+ // A page that was not written stops the export here, or it would be marked complete with rows missing.
+ if ( ! $written || ! $exporter->write_headers_row_file() ) {
+ wc_get_logger()->warning(
+ sprintf( 'The %1$s report export %2$s did not finish: its file could not be written on page %3$d of %4$d.', $report_type, $export_id, $page_number, $num_batches ),
+ array( 'source' => 'report-csv-exporter' )
+ );
+
+ return;
+ }
+
+ self::update_export_percentage_complete( $report_type, $export_id, 100 );
+
+ if ( false !== $email_user_id ) {
+ self::schedule_action( 'email_report_download_link', array( (int) $email_user_id, $export_id, $report_type, $report_args ) );
+ }
+ }
+
+ /**
+ * Get the exporter that writes one page of an export to the export file.
+ *
+ * @param int $page_number Page number.
* @param string $export_id Unique ID for report (timestamp expected).
* @param string $report_type Report type. E.g. 'customers'.
* @param array $report_args Report parameters, passed to data query.
- * @return void
+ * @return ReportCSVExporter
*/
- public static function export_report( $page_number, $export_id, $report_type, $report_args ) {
- $report_args['page'] = $page_number;
+ private static function get_page_exporter( $page_number, $export_id, $report_type, $report_args ) {
+ // The arguments come from a scheduled action, so they are whatever it was queued with.
+ $report_args = is_array( $report_args ) ? $report_args : array();
- $exporter = new ReportCSVExporter( $report_type, $report_args );
+ $exporter = new ReportCSVExporter( $report_type, array_merge( $report_args, array( 'page' => $page_number ) ) );
$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() );
+ return $exporter;
+ }
+
+ /**
+ * Queue the next page of an export.
+ *
+ * @param array $args Arguments for export_report().
+ * @return void
+ */
+ private static function queue_next_page( $args ) {
+ // SchedulerTraits types the queue without a leading backslash, so PHPStan cannot resolve it.
+ /** @var \WC_Queue_Interface $queue */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+ $queue = self::queue();
+ $queue->add( (string) self::get_action( 'export_report' ), $args, (string) self::$group );
}
/**
@@ -487,15 +556,20 @@ class ReportExporter {
* @return void
*/
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 ) {
- $download_url = self::get_download_url( $report_type, $export_id, $report_args );
+ if ( 100 !== self::get_export_percentage_complete( $report_type, $export_id ) ) {
+ wc_get_logger()->warning(
+ sprintf( 'Not emailing the %1$s report export %2$s: it never reported itself complete.', $report_type, $export_id ),
+ array( 'source' => 'report-csv-exporter' )
+ );
- \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 );
+ return;
}
+
+ $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/Tools/FakeQueue.php b/plugins/woocommerce/tests/Tools/FakeQueue.php
index cf60f524bf9..fdbd6e68913 100644
--- a/plugins/woocommerce/tests/Tools/FakeQueue.php
+++ b/plugins/woocommerce/tests/Tools/FakeQueue.php
@@ -36,7 +36,7 @@ class FakeQueue implements \WC_Queue_Interface {
// phpcs:disable Squiz.Commenting.FunctionComment.Missing
public function add( $hook, $args = array(), $group = '' ) {
- // TODO: Implement add() method.
+ $this->add_to_methods_called( 'add', $args, $group, array( 'hook' => $hook ) );
}
public function schedule_single( $timestamp, $hook, $args = array(), $group = '' ) {
@@ -73,6 +73,12 @@ class FakeQueue implements \WC_Queue_Interface {
public function search( $args = array(), $return_format = OBJECT ) {
$result = array();
+
+ // Only a search naming both the hook and the exact arguments is matched.
+ if ( ! isset( $args['args'], $args['hook'] ) ) {
+ return $result;
+ }
+
foreach ( $this->methods_called as $method_called ) {
if ( $method_called['args'] === $args['args'] && $method_called['hook'] === $args['hook'] ) {
$result[] = $method_called;
diff --git a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
index 3d9b0c2800b..8b605b4b3d7 100644
--- a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
+++ b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
@@ -317,6 +317,44 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Writing CSV data reports whether the data reached the export file.
+ */
+ public function test_write_csv_data_reports_whether_the_data_was_written(): void {
+ $exporter = new WC_Product_CSV_Exporter();
+ $exporter->set_filename( 'wc-csv-exporter-failed-write-test' );
+ $exporter->get_file();
+
+ $reflected_exporter = new ReflectionClass( WC_Product_CSV_Exporter::class );
+ $write_csv_data = $reflected_exporter->getMethod( 'write_csv_data' );
+ $write_csv_data->setAccessible( true );
+ $get_file_path = $reflected_exporter->getMethod( 'get_file_path' );
+ $get_file_path->setAccessible( true );
+
+ $read_only_mode = static function () {
+ return 'r';
+ };
+
+ try {
+ $this->assertTrue( $write_csv_data->invoke( $exporter, "sku,name\n" ) );
+
+ // fwrite() fails on a read-only handle, and raises a notice.
+ add_filter( 'woocommerce_csv_exporter_fopen_mode', $read_only_mode );
+ set_error_handler( '__return_true', E_NOTICE ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Keeps the notice from failing the test.
+
+ try {
+ $written = $write_csv_data->invoke( $exporter, "sku,name\n" );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertFalse( $written, 'A failed write must be reported, so a caller can stop instead of serving an incomplete file.' );
+ } finally {
+ remove_filter( 'woocommerce_csv_exporter_fopen_mode', $read_only_mode );
+ wp_delete_file( $get_file_path->invoke( $exporter ) );
+ }
+ }
+
/**
* @testdox Exporting a product loaded before its global attribute is deleted leaves the value empty without a warning.
*/
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
index 7b2ced1d22e..ddad110d142 100644
--- a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
@@ -11,6 +11,8 @@ namespace Automattic\WooCommerce\Tests\Admin;
use Automattic\WooCommerce\Admin\ReportCSVExporter;
use Automattic\WooCommerce\Admin\ReportExporter;
+use Automattic\WooCommerce\Testing\Tools\FakeQueue;
+use WC_Helper_Product;
use WC_Unit_Test_Case;
/**
@@ -26,17 +28,153 @@ class ReportExporterTest extends WC_Unit_Test_Case {
private $paths = array();
/**
- * Remove files that the database transaction does not roll back.
+ * Queue the scheduler records its actions in, for the tests that run an export's pages.
+ *
+ * @var FakeQueue|null
+ */
+ private $queue = null;
+
+ /**
+ * Number of the recorded export pages a test has run.
+ *
+ * @var int
+ */
+ private $pages_run = 0;
+
+ /**
+ * Remove files, the scheduler queue and the REST server, which the database transaction does not roll back.
*/
public function tearDown(): void {
- foreach ( $this->paths as $path ) {
- if ( file_exists( $path ) ) {
- wp_delete_file( $path );
+ try {
+ // An export creates the REST server, and its report routes load from hooks that the parent removes after each test.
+ $this->clear_rest_server();
+
+ foreach ( $this->paths as $path ) {
+ if ( file_exists( $path ) ) {
+ wp_delete_file( $path );
+ }
+ }
+ $this->paths = array();
+
+ if ( $this->queue ) {
+ ReportExporter::set_queue( null );
+ $this->queue = null;
}
+ } finally {
+ parent::tearDown();
}
- $this->paths = array();
+ }
+
+ /**
+ * Queue a stock report export, recording its actions instead of scheduling them.
+ *
+ * The report is exported two rows at a time, so seven products span four pages.
+ *
+ * @param string $export_id Export ID.
+ * @param bool $send_email Whether the export is emailed.
+ * @param bool $signed_in Whether a merchant is signed in when the export is queued. The email is addressed to them.
+ * @return void
+ */
+ private function queue_stock_export( string $export_id, bool $send_email = true, bool $signed_in = true ): void {
+ wp_set_current_user( $signed_in ? $this->factory->user->create( array( 'role' => 'administrator' ) ) : 0 );
+
+ for ( $i = 0; $i < 7; $i++ ) {
+ WC_Helper_Product::create_simple_product();
+ }
+
+ add_filter(
+ 'woocommerce_admin_stock_report_export_batch_limit',
+ static function () {
+ return 2;
+ }
+ );
+
+ $this->queue = new FakeQueue();
+ ReportExporter::set_queue( $this->queue );
+
+ ReportExporter::queue_report_export( $export_id, 'stock', array(), $send_email );
+
+ $this->paths[] = $this->export_path( $export_id );
+ $this->paths[] = $this->export_path( $export_id ) . '.headers';
+ }
+
+ /**
+ * Run the export pages that have been queued and not run yet, in the order they were queued.
+ *
+ * @param int $count Number of pages to run, or 0 to run until no queued page is left.
+ * @return void
+ */
+ private function run_queued_pages( int $count = 0 ): void {
+ for ( $run = 0; 0 === $count || $run < $count; $run++ ) {
+ $pages = $this->recorded_actions( ReportExporter::get_action( 'export_report' ) );
+
+ if ( ! isset( $pages[ $this->pages_run ] ) ) {
+ return;
+ }
+
+ ReportExporter::export_report( ...$pages[ $this->pages_run++ ] );
+ }
+ }
+
+ /**
+ * Get the arguments of every action of one hook that the scheduler recorded.
+ *
+ * @param string $hook Action hook name.
+ * @return array[]
+ */
+ private function recorded_actions( string $hook ): array {
+ $recorded = array();
+
+ foreach ( $this->queue->get_methods_called() as $call ) {
+ if ( isset( $call['hook'] ) && $hook === $call['hook'] ) {
+ $recorded[] = $call['args'];
+ }
+ }
+
+ return $recorded;
+ }
+
+ /**
+ * Get the path an export's file is written to.
+ *
+ * @param string $export_id Export ID.
+ * @return string
+ */
+ private function export_path( string $export_id ): string {
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( "wc-stock-report-export-{$export_id}" );
+
+ return ReportCSVExporter::get_reports_directory() . $exporter->get_filename();
+ }
+
+ /**
+ * Read back the SKU of each row an export has written.
+ *
+ * @param string $export_id Export ID.
+ * @return string[]
+ */
+ private function exported_skus( string $export_id ): array {
+ $path = $this->export_path( $export_id );
+ $rows = file_exists( $path ) ? file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ) : array();
+
+ return array_map(
+ static function ( $row ) {
+ return str_getcsv( $row, ',', '"', '\\' )[1];
+ },
+ (array) $rows
+ );
+ }
+
+ /**
+ * Get the SKUs of the stock report, in the order one request to the report returns them.
+ *
+ * @return string[]
+ */
+ private function report_skus(): array {
+ $request = new \WP_REST_Request( 'GET', '/wc-analytics/reports/stock' );
+ $request->set_param( 'per_page', 100 );
- parent::tearDown();
+ return array_column( rest_do_request( $request )->get_data(), 'sku' );
}
/**
@@ -656,4 +794,332 @@ class ReportExporterTest extends WC_Unit_Test_Case {
$this->assertFalse( ReportExporter::get_export_percentage_complete( 'orders', 'unknown' ), 'An export that was never queued has no progress.' );
$this->assertFalse( get_option( ReportExporter::EXPORT_STATUS_OPTION ), 'Progress must not be written to the option every export used to share.' );
}
+
+ /**
+ * @testdox An export queues its pages one at a time, so they run in order and every row is kept in report order.
+ */
+ public function test_pages_run_one_at_a_time_in_report_order(): void {
+ $this->queue_stock_export( 'chain' );
+ $export_hook = ReportExporter::get_action( 'export_report' );
+
+ $this->assertCount( 1, $this->recorded_actions( $export_hook ), 'Only the first page is queued up front.' );
+
+ $progress = array();
+ for ( $page = 1; $page <= 4; $page++ ) {
+ $this->run_queued_pages( 1 );
+ $progress[] = ReportExporter::get_export_percentage_complete( 'stock', 'chain' );
+ }
+
+ $this->assertSame( array( 25, 50, 75, 100 ), $progress );
+ $this->assertCount( 4, $this->recorded_actions( $export_hook ), 'The last page must not queue another one.' );
+ $this->assertSame( $this->report_skus(), $this->exported_skus( 'chain' ), 'Every row must be exported, in report order.' );
+
+ $next_pages = wp_list_filter( $this->queue->get_methods_called(), array( 'hook' => $export_hook ) );
+ $this->assertSame(
+ array( 'schedule_single', 'add', 'add', 'add' ),
+ array_values( wp_list_pluck( $next_pages, 'method' ) ),
+ 'Later pages are queued to run now, so the runner that wrote a page picks up the next one in the same run.'
+ );
+ }
+
+ /**
+ * @testdox The last page makes the export downloadable and schedules the email once.
+ */
+ public function test_last_page_finishes_the_export(): void {
+ $this->queue_stock_export( 'finish' );
+ $email_hook = ReportExporter::get_action( 'email_report_download_link' );
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( 'wc-stock-report-export-finish' );
+
+ $this->run_queued_pages( 3 );
+
+ $this->assertFalse( $exporter->export_file_exists(), 'An export with a page still to run must not be downloadable.' );
+ $this->assertCount( 0, $this->recorded_actions( $email_hook ), 'The email must wait for the last page.' );
+
+ $this->run_queued_pages();
+
+ $this->assertTrue( $exporter->export_file_exists() );
+ $this->assertSame(
+ array( array( get_current_user_id(), 'finish', 'stock', array() ) ),
+ $this->recorded_actions( $email_hook ),
+ 'The email is addressed to the user who asked for the export, with the arguments the report was queued with.'
+ );
+ }
+
+ /**
+ * @testdox An export still finishes when rows are added to the report while its pages run.
+ */
+ public function test_export_finishes_when_rows_are_added_during_the_export(): void {
+ $this->queue_stock_export( 'grew' );
+
+ $this->run_queued_pages( 3 );
+
+ // The last page now counts 9 rows but writes up to row 8, which the parent exporter reads as 88%.
+ WC_Helper_Product::create_simple_product();
+ WC_Helper_Product::create_simple_product();
+
+ $this->run_queued_pages();
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( 'wc-stock-report-export-grew' );
+
+ $this->assertTrue( $exporter->export_file_exists(), 'The last page must write the headers row file whatever the rows add up to.' );
+ $this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'stock', 'grew' ) );
+ $this->assertCount( 1, $this->recorded_actions( ReportExporter::get_action( 'email_report_download_link' ) ) );
+ }
+
+ /**
+ * @testdox An export that was not asked to be emailed schedules no email.
+ */
+ public function test_export_not_asked_to_be_emailed_schedules_no_email(): void {
+ $this->queue_stock_export( 'noemail', false );
+
+ $this->run_queued_pages();
+
+ $this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'stock', 'noemail' ) );
+ $this->assertCount( 0, $this->recorded_actions( ReportExporter::get_action( 'email_report_download_link' ) ) );
+ }
+
+ /**
+ * @testdox An export queued outside a user session is still emailed, to the recipient a filter names.
+ */
+ public function test_export_queued_outside_a_user_session_is_emailed_to_the_filtered_recipient(): void {
+ add_filter( 'woocommerce_email_recipient_admin_report_export_download', fn() => 'reports@example.org' );
+ reset_phpmailer_instance();
+
+ $this->queue_stock_export( 'nouser', true, false );
+ $this->run_queued_pages();
+
+ $emails = $this->recorded_actions( ReportExporter::get_action( 'email_report_download_link' ) );
+
+ $this->assertSame( array( array( 0, 'nouser', 'stock', array() ) ), $emails, 'The email action must still be scheduled, or the recipient filter never runs.' );
+
+ ReportExporter::email_report_download_link( ...$emails[0] );
+
+ $mailer = tests_retrieve_phpmailer_instance();
+
+ $this->assertCount( 1, $mailer->mock_sent );
+ $this->assertSame( 'reports@example.org', $mailer->get_recipient( 'to' )->address );
+ }
+
+ /**
+ * @testdox An export whose file is gone when the last page runs is not marked complete, and is not emailed.
+ */
+ public function test_export_with_a_missing_file_is_not_finished(): void {
+ $logged = array();
+ add_filter(
+ 'woocommerce_logger_log_message',
+ function ( $message, $level ) use ( &$logged ) {
+ $logged[] = $level . ': ' . $message;
+
+ return $message;
+ },
+ 10,
+ 2
+ );
+
+ $this->queue_stock_export( 'lostfile' );
+ $this->run_queued_pages( 3 );
+
+ wp_delete_file( $this->export_path( 'lostfile' ) );
+
+ $this->run_queued_pages();
+
+ $this->assertFileDoesNotExist( $this->export_path( 'lostfile' ) . '.headers', 'The headers row file marks an export downloadable, so it must not be written for rows that could not be.' );
+ $this->assertSame( 75, ReportExporter::get_export_percentage_complete( 'stock', 'lostfile' ) );
+ $this->assertCount( 0, $this->recorded_actions( ReportExporter::get_action( 'email_report_download_link' ) ) );
+ $this->assertNotEmpty(
+ preg_grep( '/^warning: The stock report export lostfile did not finish/', $logged ),
+ 'The unfinished export must be logged, since the scheduler records its last page as complete.'
+ );
+ }
+
+ /**
+ * @testdox An export stops at a page whose rows could not be written, so it is not marked complete with rows missing.
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $inline Whether the pages run in one request, without Action Scheduler.
+ */
+ public function test_export_stops_at_a_page_that_could_not_be_written( bool $inline ): void {
+ if ( $inline ) {
+ add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+ }
+ reset_phpmailer_instance();
+
+ // The report is read once to count its rows when the export is queued, then once per page.
+ // The file is gone when page 2 is written, and back in time for page 3.
+ $path = $this->export_path( 'failedpage' );
+ $reads = 0;
+ add_filter(
+ 'woocommerce_export_report_data_endpoint',
+ static function ( $endpoint ) use ( $path, &$reads ) {
+ ++$reads;
+
+ if ( 3 === $reads ) {
+ wp_delete_file( $path );
+ } elseif ( 4 === $reads ) {
+ file_put_contents( $path, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ }
+
+ return $endpoint;
+ }
+ );
+
+ $this->queue_stock_export( 'failedpage' );
+ $this->run_queued_pages();
+
+ $this->assertFileDoesNotExist( $path, 'No page may run after the one that could not be written.' );
+ $this->assertFileDoesNotExist( $path . '.headers' );
+ $this->assertNotSame( 100, ReportExporter::get_export_percentage_complete( 'stock', 'failedpage' ) );
+ $this->assertCount( 0, $this->recorded_actions( ReportExporter::get_action( 'email_report_download_link' ) ) );
+ $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent );
+ }
+
+ /**
+ * @testdox An export is not downloadable before its last page when rows are removed from the report while its pages run.
+ */
+ public function test_export_is_not_downloadable_early_when_rows_are_removed_during_the_export(): void {
+ $this->queue_stock_export( 'shrank' );
+ $this->run_queued_pages( 1 );
+
+ // Page 2 now ends on the last of 4 rows, which the parent exporter reads as 100%.
+ $removed = wc_get_products(
+ array(
+ 'limit' => 3,
+ 'return' => 'ids',
+ )
+ );
+ foreach ( $removed as $product_id ) {
+ wp_delete_post( $product_id, true );
+ }
+
+ $this->run_queued_pages( 1 );
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( 'wc-stock-report-export-shrank' );
+
+ $this->assertFalse( $exporter->export_file_exists(), 'Only the last page may write the headers row file.' );
+ $this->assertSame( 50, ReportExporter::get_export_percentage_complete( 'stock', 'shrank' ) );
+
+ $this->run_queued_pages();
+
+ $this->assertTrue( $exporter->export_file_exists() );
+ $this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'stock', 'shrank' ) );
+ }
+
+ /**
+ * @testdox A page queued without report arguments is still written.
+ */
+ public function test_page_queued_without_report_args_is_written(): void {
+ $this->paths[] = $this->export_path( 'noargs' );
+ $this->paths[] = $this->export_path( 'noargs' ) . '.headers';
+
+ $this->act_as_reports_user();
+ WC_Helper_Product::create_simple_product();
+
+ ReportExporter::export_report( 1, 'noargs', 'stock', null );
+
+ $this->assertSame( $this->report_skus(), $this->exported_skus( 'noargs' ) );
+ }
+
+ /**
+ * @testdox An export runs every page and sends the email in the same request when Action Scheduler is not used.
+ */
+ public function test_export_runs_inline_without_action_scheduler(): void {
+ add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+ reset_phpmailer_instance();
+
+ $stack_depths = array();
+ add_filter(
+ 'woocommerce_csv_exporter_fopen_mode',
+ static function ( $mode ) use ( &$stack_depths ) {
+ $stack_depths[] = count( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Measures how deep each page is written.
+
+ return $mode;
+ }
+ );
+
+ $this->queue_stock_export( 'inline' );
+
+ $this->assertSame( $this->report_skus(), $this->exported_skus( 'inline' ) );
+ $this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'stock', 'inline' ) );
+ $this->assertCount( 1, tests_retrieve_phpmailer_instance()->mock_sent, 'The email must be sent once the last page is written.' );
+ $this->assertSame( array(), $this->queue->get_methods_called(), 'Nothing is queued when Action Scheduler is not used.' );
+ $this->assertCount( 1, array_unique( $stack_depths ), 'Every page must be written at the same stack depth, or a long export runs out of stack.' );
+ }
+
+ /**
+ * @testdox An email queued before 11.3.0 waits while a page of its export is still pending.
+ */
+ public function test_email_queued_before_pages_were_chained_waits_for_its_pages(): void {
+ $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $email_hook = ReportExporter::get_action( 'email_report_download_link' );
+ $email_args = array( $user_id, 'waiting', 'stock', array() );
+ reset_phpmailer_instance();
+
+ // Before 11.3.0 the pages could run out of order, so a later page can report 100% while page 1 is still pending.
+ ReportExporter::update_export_percentage_complete( 'stock', 'waiting', 100 );
+ as_schedule_single_action( time(), ReportExporter::get_action( 'export_report' ), array( 1, 'waiting', 'stock', array() ), ReportExporter::$group );
+
+ do_action_ref_array( $email_hook, $email_args );
+
+ $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent, 'The email must not go out while a page of its export is pending.' );
+ $this->assertNotFalse( as_next_scheduled_action( $email_hook, $email_args, ReportExporter::$group ), 'The email must be queued again to run after the page.' );
+ }
+
+ /**
+ * @testdox A page of an export queued before 11.3.0 writes its rows and reports its own position, and queues nothing.
+ */
+ public function test_page_of_an_export_queued_before_pages_were_chained(): void {
+ $this->paths[] = $this->export_path( 'preupgrade' );
+ $this->paths[] = $this->export_path( 'preupgrade' ) . '.headers';
+
+ $this->queue = new FakeQueue();
+ ReportExporter::set_queue( $this->queue );
+
+ WC_Helper_Product::create_simple_product();
+
+ // Four arguments, the way the pages of an export queued before 11.3.0 still call it.
+ ReportExporter::export_report( 1, 'preupgrade', 'stock', array() );
+
+ $exporter = new ReportCSVExporter();
+ $exporter->set_filename( 'wc-stock-report-export-preupgrade' );
+
+ $this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'stock', 'preupgrade' ) );
+ $this->assertTrue( $exporter->export_file_exists(), 'The last page must still write the headers row file, or the emailed link is not downloadable.' );
+ $this->assertSame( array(), $this->queue->get_methods_called(), 'Its other pages and its email were queued with it, so it must queue nothing.' );
+ }
+
+ /**
+ * @testdox An export that never finished is not emailed, and says so in the log.
+ */
+ public function test_unfinished_export_is_not_emailed(): void {
+ $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ reset_phpmailer_instance();
+ $mailer = tests_retrieve_phpmailer_instance();
+
+ $logged = array();
+ add_filter(
+ 'woocommerce_logger_log_message',
+ function ( $message, $level ) use ( &$logged ) {
+ $logged[] = $level . ': ' . $message;
+
+ return $message;
+ },
+ 10,
+ 2
+ );
+
+ ReportExporter::update_export_percentage_complete( 'stock', 'unfinished', 40 );
+
+ ReportExporter::email_report_download_link( $user_id, 'unfinished', 'stock', array() );
+
+ $this->assertEmpty( $mailer->mock_sent, 'An export that never finished must not be emailed.' );
+ $this->assertNotEmpty(
+ preg_grep( '/^warning: Not emailing the stock report export unfinished/', $logged ),
+ 'The skipped email must be logged, since the scheduler records the action as complete.'
+ );
+ }
}