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.'
+		);
+	}
 }