Commit b5a4e384d3f for woocommerce
commit b5a4e384d3f42f852ee1e3ac1ac96493a1449590
Author: Abdalsalaam Halawa <abdalsalaamnafez@gmail.com>
Date: Fri Nov 13 09:13:16 2026 +0200
[Order Fulfillments] Add the fulfillments import session and its cleanup (#70566)
* Add the fulfillments import session and its cleanup
ImportSession is the per-user transient that holds the staged CSV and the progress fields the chunked import reads. It schedules an Action Scheduler cleanup that removes an abandoned upload once the session expires; the listener is attached regardless of the feature flag.
(cherry picked from commit b1a21e1297edbd1c472ec2dd862d618d80a3ed37)
* Limit the cleanup to the wc-imports directory
is_staged_path() now accepts files under the CSVUploadHelper import directory only, so a stale cleanup payload cannot delete media elsewhere in uploads. The session test fixtures move there, the redundant per-test session deletion goes, and the controller test assertions get messages.
(cherry picked from commit a46a868c951fcba165aaaf7b198436bdc6df92e7)
* Drop the manual attachment cleanup from the mismatched-attachment test
The per-test transaction rolls the attachment post back and tearDown removes the file, so the try/finally only repeated that.
(cherry picked from commit cb4bfe0c2948ad523a400c76543ca188c7f6a9d5)
* Check the import directory before deleting an attachment for a missing file
The staging check only ran when the file still existed, so a stale cleanup payload naming a missing path and an attachment stored at that path could delete the attachment post and its sizes. is_staged_path() now resolves a missing file through its directory and the cleanup refuses anything outside wc-imports before touching the attachment.
(cherry picked from commit 67dd582d3ce2fcf890bf3833380d4b48c1f5977f)
* Accept staged paths on a stream wrapper uploads directory
realpath() returns false for a stream wrapper path, so is_staged_path()
refused every file on an S3-backed uploads directory and the cleanup
never deleted an abandoned CSV there. Stream paths are now normalized
instead of resolved, and refused when they contain a dot segment.
(cherry picked from commit 68d3cd01feaa039f23b7c2f1d4cfc8f473559976)
* Slide the session payload TTL on every persist
persist() skipped the payload write when the payload was unchanged, so
only the index pointer's TTL slid, against what the docblocks said.
The payload is now written every time; set_transient() returns false
for an unchanged value although it still extends the timeout, so a
false result is confirmed by reading the transient back.
(cherry picked from commit ce7908f54331875f04b69199c9ce94148dcb8c25)
* Document that the cleanup action also fires for live sessions
The hooks doc example logged every firing as an abandoned import, but
the action also fires while the session is live and the default
handler reschedules it. The doc and the hook docblock now say so, and
the example skips a session that ImportSession::load() still returns.
(cherry picked from commit b544f29fb2ef5a14edd2bcd19b9cd4165098bfeb)
* Refuse a directory path and a stale cache form of a stream path
A stream path with a trailing slash passed is_staged_path() for the
import directory itself, which the local branch refuses. An empty
segment is now refused like a dot segment. The stream path is also
normalized here instead of by wp_normalize_path(), which caches by the
raw string and collapses the scheme separator when the wrapper was not
registered at the time.
(cherry picked from commit f13494fd59c4ee24dd471212954febcd2d6cee11)
* Share one staged-file delete and drop a session that fails to persist
The containment rule for deleting a staged CSV lived only inside the
cleanup action handler, so the prepare endpoint had to carry its own
copy, and that copy skipped the check when the file was already gone.
delete_staged_file() is now the one place a path read back from stored
state is deleted: it refuses anything outside wc-imports, for a missing
file too, and only removes an attachment that still points at the path.
The cleanup handler calls it.
create() now removes the session record and index pointer when the
payload write fails as well, not only when the cleanup action cannot be
scheduled. persist() writes the index pointer before it knows whether
the payload was stored, so a failed session used to leave a pointer to
a token with no payload until the caller cleaned it up.
total is described as the number of non-blank data rows, which is what
the importer counts.
* Cast the cleanup hook arguments in the doc example
Action Scheduler stores the action arguments, so the user ID can come
back as a string. ImportSession::load() takes an int and a string, and
an extension that copies the example into a file with strict_types on
would get a TypeError inside the Action Scheduler run.
diff --git a/docs/features/fulfillments/hooks.md b/docs/features/fulfillments/hooks.md
index 50cfc0d1a33..3fd85e7955a 100644
--- a/docs/features/fulfillments/hooks.md
+++ b/docs/features/fulfillments/hooks.md
@@ -5,7 +5,7 @@ sidebar_label: Hooks
# Order fulfillments hooks
-WooCommerce exposes lifecycle hooks, status filters, provider filters, notification actions, email template hooks, and a CSV importer filter for order fulfillments. These hooks let extensions validate fulfillment data, synchronize external systems, customize tracking behavior, adjust merchant or customer-facing output, and accept header names from warehouse exports.
+WooCommerce exposes lifecycle hooks, status filters, provider filters, notification actions, email template hooks, and CSV importer hooks for order fulfillments. These hooks let extensions validate fulfillment data, synchronize external systems, customize tracking behavior, adjust merchant or customer-facing output, accept header names from warehouse exports, and react when a staged import file is cleaned up.
## Data lifecycle filters
@@ -168,3 +168,25 @@ add_filter( 'woocommerce_fulfillments_csv_importer_column_aliases', function( $a
return $aliases;
} );
```
+
+## CSV importer actions
+
+The import wizard stages the uploaded CSV and schedules its cleanup through Action Scheduler. The action fires after the import session's lifetime (one hour plus a five-minute grace period) so the staged file is removed when the wizard never finished. It also fires while the session is still live, because a later write to the session extends its lifetime after the action was scheduled. The default handler, `ImportSession::handle_cleanup_hook()`, then leaves the file alone and schedules the action again; once the session has expired it deletes the file and its attachment post. Listeners that treat the session as abandoned must check it first: `ImportSession::load( $user_id, $token )` returns `null` once the session is gone. The action is only scheduled while the `fulfillments` feature is enabled, but the handler is attached regardless of the flag so a pending cleanup still runs after the feature is turned off.
+
+| Hook | Parameters | Purpose |
+| --- | --- | --- |
+| `woocommerce_fulfillments_import_session_cleanup` | `int $user_id`, `string $token`, `string $file`, `int $attachment_id` | React when an abandoned import upload is cleaned up, for example to log it or to remove data stored alongside the session. Skip live sessions, or a running import loses that data. `$file` is the absolute path of the staged CSV and `$attachment_id` is the attachment post created for it, or `0`. |
+
+```php
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Importer\ImportSession;
+
+add_action( 'woocommerce_fulfillments_import_session_cleanup', 'log_abandoned_import', 10, 4 );
+
+function log_abandoned_import( $user_id, $token, $file, $attachment_id ) {
+ // Action Scheduler stores the arguments, so cast them before calling a typed method.
+ if ( ImportSession::load( (int) $user_id, (string) $token ) ) {
+ return; // Still live: the default handler reschedules the cleanup.
+ }
+ error_log( sprintf( 'Import session %s of user %d was abandoned', $token, $user_id ) );
+}
+```
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
index 016eb6499ca..a4f09a14e11 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
@@ -3,6 +3,7 @@
namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Importer\ImportSession;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
@@ -28,11 +29,16 @@ class FulfillmentsController {
/**
* Initialize the controller.
*
+ * The import session cleanup listener is attached regardless of the feature flag, so a
+ * staged CSV left behind when the flag is turned off is still removed when its
+ * scheduled cleanup fires.
+ *
* @return void
*/
public function register() {
add_filter( 'woocommerce_data_stores', array( $this, 'register_data_stores' ) );
add_action( 'init', array( $this, 'initialize_fulfillments' ), 10, 0 );
+ add_action( ImportSession::CLEANUP_HOOK, array( ImportSession::class, 'handle_cleanup_hook' ), 10, 4 );
}
/**
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/Importer/ImportSession.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/Importer/ImportSession.php
new file mode 100644
index 00000000000..ec627b99637
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/Importer/ImportSession.php
@@ -0,0 +1,783 @@
+<?php
+/**
+ * Per-user import session backed by a WordPress transient.
+ *
+ * @package Automattic\WooCommerce\Admin\Features\Fulfillments\Importer
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\Features\Fulfillments\Importer;
+
+use Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper;
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Per-user import session stored in a transient.
+ *
+ * The prepare endpoint creates it with the staged file path, attachment ID, delimiter,
+ * headers and row total, and schedules the cleanup of the staged file. The progress
+ * fields (processed, byte_offset, mapping, counts, seen_tracking_pairs) are stored here
+ * for the import run that lands next. One session is active per user at a time.
+ *
+ * @internal
+ * @since 11.4.0
+ */
+final class ImportSession {
+
+ /**
+ * Transient TTL for an in-flight import session.
+ */
+ private const TTL = HOUR_IN_SECONDS;
+
+ /**
+ * Action Scheduler hook fired to clean up an abandoned staged CSV file.
+ */
+ public const CLEANUP_HOOK = 'woocommerce_fulfillments_import_session_cleanup';
+
+ /**
+ * Grace period (in seconds) added to TTL before the cleanup action fires.
+ */
+ private const CLEANUP_GRACE = 5 * MINUTE_IN_SECONDS;
+
+ /**
+ * Prefix for session payload transients.
+ */
+ private const PREFIX = 'wc_fulfillment_import_';
+
+ /**
+ * Prefix for the per-user active-token pointer transient.
+ */
+ private const INDEX_PREFIX = 'wc_fulfillment_import_active_';
+
+ /**
+ * Owning user ID.
+ *
+ * @var int
+ */
+ private int $user_id;
+
+ /**
+ * Session token (also embedded in the transient key).
+ *
+ * @var string
+ */
+ private string $token;
+
+ /**
+ * Stored payload.
+ *
+ * @var array<string, mixed>
+ */
+ private array $data;
+
+ /**
+ * Whether the last write of the payload transient succeeded.
+ *
+ * @var bool
+ */
+ private bool $persisted = true;
+
+ /**
+ * Session this one replaced when it was created, if any.
+ *
+ * @var self|null
+ */
+ private ?self $replaced = null;
+
+ /**
+ * Constructor.
+ *
+ * @param int $user_id User who owns the session.
+ * @param string $token Opaque session token.
+ * @param array<string, mixed> $data Stored payload (see ::create()).
+ */
+ private function __construct( int $user_id, string $token, array $data ) {
+ $this->user_id = $user_id;
+ $this->token = $token;
+ $this->data = $data;
+ }
+
+ /**
+ * Create a fresh import session for a user, replacing any existing one.
+ *
+ * The session active at this moment is retired here (transient and cleanup action
+ * dropped) and exposed through replaced(), so the caller deletes the staged file and
+ * attachment of exactly that session. When the new session cannot be stored, or its
+ * cleanup cannot be scheduled, its record and index pointer are removed again and
+ * persisted() reports false; the caller then removes the new staged file as well.
+ *
+ * @since 11.4.0
+ *
+ * @param int $user_id User ID.
+ * @param string $file Absolute path to the staged CSV file.
+ * @param string $delimiter Effective delimiter (resolved by parse_headers()).
+ * @param array<int, string> $headers Header row.
+ * @param int $total Number of non-blank data rows after the header.
+ * @param bool $notify Whether to fire customer notifications during the import run.
+ * @param bool $update Whether to update existing fulfillments on tracking-number match.
+ * @param int $attachment_id Attachment post created for the staged CSV by the upload handler.
+ * @return self
+ */
+ public static function create( int $user_id, string $file, string $delimiter, array $headers, int $total, bool $notify, bool $update, int $attachment_id = 0 ): self {
+ $replaced = self::active_for_user( $user_id );
+ if ( $replaced instanceof self ) {
+ $replaced->delete();
+ }
+
+ $token = self::generate_token();
+ $data = array(
+ 'file' => $file,
+ 'attachment_id' => max( 0, $attachment_id ),
+ 'file_size' => file_exists( $file ) ? (int) filesize( $file ) : 0,
+ 'file_mtime' => file_exists( $file ) ? (int) filemtime( $file ) : 0,
+ 'file_head_hash' => self::hash_file_head( $file ),
+ 'delimiter' => $delimiter,
+ 'headers' => array_values( array_map( 'strval', $headers ) ),
+ 'total' => max( 0, $total ),
+ 'processed' => 0,
+ 'byte_offset' => 0,
+ 'notify_customer' => $notify,
+ 'update_existing' => $update,
+ 'seen_tracking_pairs' => array(),
+ 'counts' => array(
+ 'created' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ 'failed' => 0,
+ 'notified' => 0,
+ ),
+ );
+
+ $session = new self( $user_id, $token, $data );
+ $session->replaced = $replaced;
+
+ // A staged file without a scheduled cleanup would stay behind forever, so a session
+ // that cannot be stored, or whose cleanup cannot be scheduled, is not kept. persist()
+ // writes the index pointer even when the payload write fails, so delete() runs for both.
+ if ( ! $session->persist() || ! self::schedule_cleanup( $user_id, $token, $file, max( 0, $attachment_id ), self::TTL ) ) {
+ $session->delete();
+ $session->persisted = false;
+ }
+
+ return $session;
+ }
+
+ /**
+ * Whether the session is stored and its cleanup is scheduled.
+ *
+ * @since 11.4.0
+ *
+ * @return bool
+ */
+ public function persisted(): bool {
+ return $this->persisted;
+ }
+
+ /**
+ * Session that was active for the user when this one was created.
+ *
+ * Its record and cleanup action are already gone; its staged file and attachment are
+ * left for the caller to delete.
+ *
+ * @since 11.4.0
+ *
+ * @return self|null Null when the user had no active session.
+ */
+ public function replaced(): ?self {
+ return $this->replaced;
+ }
+
+ /**
+ * Schedule the Action Scheduler action that removes the staged file if the session is
+ * abandoned (transient expires without the wizard ever completing the import).
+ *
+ * @param int $user_id User ID.
+ * @param string $token Session token.
+ * @param string $file Absolute path to the staged CSV.
+ * @param int $attachment_id Attachment post created for the staged CSV.
+ * @param int $ttl Seconds the session transient can still live; the action
+ * fires after that plus the grace period.
+ * @return bool Whether the action is scheduled. True for an empty path, which has nothing to clean up.
+ */
+ private static function schedule_cleanup( int $user_id, string $token, string $file, int $attachment_id, int $ttl ): bool {
+ if ( '' === $file ) {
+ return true;
+ }
+ if ( ! function_exists( 'as_schedule_single_action' ) ) {
+ wc_get_logger()->error(
+ sprintf( 'Action Scheduler is unavailable; the cleanup of the staged fulfillments import file %s cannot be scheduled.', $file ),
+ array( 'source' => 'fulfillments-importer' )
+ );
+ return false;
+ }
+
+ /**
+ * Fires (via Action Scheduler) after a fulfillments import session's TTL plus a small grace
+ * window to clean up the staged CSV when the wizard never finishes the import.
+ *
+ * The action also fires while the session is still live, when a later write extended
+ * its TTL; the default handler, {@see ImportSession::handle_cleanup_hook()}, then
+ * schedules it again. Listeners that act on an abandoned session check
+ * {@see ImportSession::load()} first, which returns null once the session is gone.
+ * Action Scheduler fires the hook, so this docblock sits on the scheduling call rather
+ * than on a do_action().
+ *
+ * @since 11.4.0
+ *
+ * @param int $user_id User who owned the session.
+ * @param string $token Session token.
+ * @param string $file Absolute path to the staged CSV.
+ * @param int $attachment_id Attachment post created for the staged CSV.
+ */
+ $action_id = as_schedule_single_action(
+ time() + max( 0, $ttl ) + self::CLEANUP_GRACE,
+ self::CLEANUP_HOOK,
+ array( $user_id, $token, $file, $attachment_id ),
+ 'woocommerce-fulfillments-importer'
+ );
+
+ if ( 0 === $action_id ) {
+ wc_get_logger()->error(
+ sprintf( 'Could not schedule the cleanup of the staged fulfillments import file %s.', $file ),
+ array( 'source' => 'fulfillments-importer' )
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Hook callback for the cleanup action.
+ *
+ * The arguments arrive from a persisted Action Scheduler payload and from anything else
+ * that fires the hook, so they are coerced before reaching the typed method below.
+ *
+ * @see ImportSession::schedule_cleanup() for the hook docblock.
+ *
+ * @since 11.4.0
+ *
+ * @param mixed $user_id User the session belongs to.
+ * @param mixed $token Session token.
+ * @param mixed $file Absolute path to the staged CSV.
+ * @param mixed $attachment_id Attachment post created for the staged CSV.
+ */
+ public static function handle_cleanup_hook( $user_id = 0, $token = '', $file = '', $attachment_id = 0 ): void {
+ if ( ! is_scalar( $token ) || ! is_scalar( $file ) ) {
+ return;
+ }
+ self::cleanup_abandoned_file(
+ is_numeric( $user_id ) ? (int) $user_id : 0,
+ (string) $token,
+ (string) $file,
+ is_numeric( $attachment_id ) ? (int) $attachment_id : 0
+ );
+ }
+
+ /**
+ * Delete the staged CSV of a session that was never finished.
+ *
+ * Only deletes the file when the matching session transient has expired. While the
+ * session is still live (persist() slides its TTL), the cleanup is scheduled again for
+ * the remaining TTL plus the grace period.
+ *
+ * @since 11.4.0
+ *
+ * @param int $user_id User the session belongs to.
+ * @param string $token Session token.
+ * @param string $file Absolute path to the staged CSV.
+ * @param int $attachment_id Attachment post created for the staged CSV.
+ */
+ public static function cleanup_abandoned_file( int $user_id, string $token, string $file, int $attachment_id = 0 ): void {
+ if ( '' === $file ) {
+ return;
+ }
+ $payload_key = self::PREFIX . $user_id . '_' . $token;
+ if ( false !== get_transient( $payload_key ) ) {
+ self::schedule_cleanup( $user_id, $token, $file, $attachment_id, self::remaining_ttl( $payload_key ) );
+ return;
+ }
+
+ self::delete_staged_file( $file, $attachment_id );
+ }
+
+ /**
+ * Delete a staged CSV and the attachment post created for it.
+ *
+ * This is the one place a staged file read back from stored state (a session payload or
+ * an Action Scheduler argument) is deleted, so the containment rule lives here: the path
+ * must resolve inside the wc-imports directory, and the check runs for a missing file too
+ * because the attachment delete does not need the file. An attachment is only deleted
+ * while it still points at that path, so a tampered ID cannot remove unrelated media.
+ *
+ * @since 11.4.0
+ *
+ * @param string $file Absolute path to the staged CSV.
+ * @param int $attachment_id Attachment post created for the staged CSV; 0 when none.
+ * @return bool False when the path was refused, true otherwise.
+ */
+ public static function delete_staged_file( string $file, int $attachment_id = 0 ): bool {
+ if ( '' === $file || ( ! file_exists( $file ) && $attachment_id <= 0 ) ) {
+ return true;
+ }
+
+ if ( ! self::is_staged_path( $file ) ) {
+ wc_get_logger()->warning(
+ sprintf( 'Refusing to delete a staged fulfillments import file outside the import directory: %s', $file ),
+ array( 'source' => 'fulfillments-importer' )
+ );
+ return false;
+ }
+
+ if ( $attachment_id > 0 && get_attached_file( $attachment_id ) === $file ) {
+ wp_delete_attachment( $attachment_id, true );
+ }
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ return true;
+ }
+
+ /**
+ * Seconds left before a live session transient expires.
+ *
+ * The timeout option only exists without a persistent object cache; otherwise the
+ * full TTL is assumed, which is the latest the transient can expire.
+ *
+ * @param string $payload_key Session payload transient key.
+ * @return int
+ */
+ private static function remaining_ttl( string $payload_key ): int {
+ if ( wp_using_ext_object_cache() ) {
+ return self::TTL;
+ }
+ $timeout = (int) get_option( '_transient_timeout_' . $payload_key, 0 );
+ if ( $timeout <= 0 ) {
+ return self::TTL;
+ }
+ return max( 0, min( self::TTL, $timeout - time() ) );
+ }
+
+ /**
+ * Whether a path resolves inside the wc-imports directory CSVUploadHelper stages files in.
+ *
+ * The uploads directory itself holds media the cleanup must never touch, so the check
+ * is against the import subdirectory only. A path whose file is already gone is
+ * resolved through its directory, so the cleanup can still refuse it before deleting
+ * an attachment that claims the same path. Both paths are canonicalized the same way,
+ * so an uploads directory served by a stream wrapper (S3-backed sites) is checked too.
+ *
+ * @since 11.4.0
+ *
+ * @param string $path Absolute path to check.
+ * @return bool
+ */
+ public static function is_staged_path( string $path ): bool {
+ if ( '' === $path ) {
+ return false;
+ }
+
+ try {
+ $staging_dir = self::canonical_path( wc_get_container()->get( CSVUploadHelper::class )->get_import_dir( false ) );
+ } catch ( \Exception $e ) {
+ return false;
+ }
+ $resolved = self::canonical_path( $path );
+ if ( null === $staging_dir || null === $resolved ) {
+ return false;
+ }
+
+ return 0 === strpos( $resolved, trailingslashit( $staging_dir ) );
+ }
+
+ /**
+ * Canonical form of a path for the staged-path check.
+ *
+ * A local path goes through realpath(), so a symlink or a dot segment cannot make a path
+ * outside wc-imports look like one inside it; a missing file is resolved through its
+ * directory. realpath() returns false for a stream wrapper path (an uploads directory on
+ * S3 reads "s3://bucket/uploads"), so that path is only normalized, and refused when it
+ * contains a dot or empty segment because nothing resolves one for a stream. The
+ * normalization is done here rather than by wp_normalize_path(), which caches its result
+ * by the raw string and collapses the "://" of a scheme whose wrapper was not registered
+ * at the time.
+ *
+ * @param string $path Absolute path or stream wrapper URL.
+ * @return string|null Path with forward slashes, or null when it cannot be resolved.
+ */
+ private static function canonical_path( string $path ): ?string {
+ if ( wp_is_stream( $path ) ) {
+ list( $scheme, $rest ) = explode( '://', $path, 2 );
+
+ $rest = ltrim( (string) preg_replace( '#/+#', '/', str_replace( '\\', '/', $rest ) ), '/' );
+ $segments = explode( '/', $rest );
+ if ( in_array( '.', $segments, true ) || in_array( '..', $segments, true ) || in_array( '', $segments, true ) ) {
+ return null;
+ }
+ return $scheme . '://' . $rest;
+ }
+
+ $resolved = realpath( $path );
+ if ( false === $resolved ) {
+ $name = wp_basename( $path );
+ $dir = realpath( dirname( $path ) );
+ if ( false === $dir || '' === $name || '.' === $name || '..' === $name ) {
+ return null;
+ }
+ $resolved = trailingslashit( $dir ) . $name;
+ }
+
+ return wp_normalize_path( $resolved );
+ }
+
+ /**
+ * Load whichever session is currently active for a user, if any.
+ *
+ * @since 11.4.0
+ *
+ * @param int $user_id User ID.
+ * @return self|null
+ */
+ public static function active_for_user( int $user_id ): ?self {
+ $token = get_transient( self::INDEX_PREFIX . $user_id );
+ if ( ! is_string( $token ) || '' === $token ) {
+ return null;
+ }
+ return self::load( $user_id, $token );
+ }
+
+ /**
+ * Load an existing session belonging to a user.
+ *
+ * @since 11.4.0
+ *
+ * @param int $user_id User ID.
+ * @param string $token Token previously returned by ::create()->token().
+ * @return self|null Null when the token is missing, expired, or owned by a different user.
+ */
+ public static function load( int $user_id, string $token ): ?self {
+ if ( '' === $token ) {
+ return null;
+ }
+ $payload = get_transient( self::PREFIX . $user_id . '_' . $token );
+ if ( ! is_array( $payload ) ) {
+ return null;
+ }
+ return new self( $user_id, $token, $payload );
+ }
+
+ /**
+ * Delete the session, its index pointer and its pending cleanup action.
+ *
+ * The staged file is left in place; the caller decides whether it is still needed.
+ *
+ * @since 11.4.0
+ */
+ public function delete(): void {
+ delete_transient( self::PREFIX . $this->user_id . '_' . $this->token );
+
+ // Only clear the index pointer if it still points at this session; a newer session may have replaced it.
+ $current = get_transient( self::INDEX_PREFIX . $this->user_id );
+ if ( $current === $this->token ) {
+ delete_transient( self::INDEX_PREFIX . $this->user_id );
+ }
+
+ if ( function_exists( 'as_unschedule_action' ) ) {
+ as_unschedule_action(
+ self::CLEANUP_HOOK,
+ array( $this->user_id, $this->token, $this->file(), $this->attachment_id() ),
+ 'woocommerce-fulfillments-importer'
+ );
+ }
+ }
+
+ /**
+ * Session token.
+ *
+ * @since 11.4.0
+ *
+ * @return string
+ */
+ public function token(): string {
+ return $this->token;
+ }
+
+ /**
+ * Absolute path to the staged CSV file.
+ *
+ * @since 11.4.0
+ *
+ * @return string
+ */
+ public function file(): string {
+ return (string) ( $this->data['file'] ?? '' );
+ }
+
+ /**
+ * Attachment post created for the staged CSV by the upload handler.
+ *
+ * @since 11.4.0
+ *
+ * @return int Attachment ID; 0 when the file was staged without one.
+ */
+ public function attachment_id(): int {
+ return max( 0, (int) ( $this->data['attachment_id'] ?? 0 ) );
+ }
+
+ /**
+ * Size of the staged CSV when the session was created, in bytes.
+ *
+ * @since 11.4.0
+ *
+ * @return int
+ */
+ public function file_size(): int {
+ return (int) ( $this->data['file_size'] ?? 0 );
+ }
+
+ /**
+ * Modification time of the staged CSV when the session was created.
+ *
+ * @since 11.4.0
+ *
+ * @return int Unix timestamp; 0 when unknown.
+ */
+ public function file_mtime(): int {
+ return (int) ( $this->data['file_mtime'] ?? 0 );
+ }
+
+ /**
+ * Hash of the first bytes of the staged CSV when the session was created.
+ *
+ * @since 11.4.0
+ *
+ * @return string Hash string; empty when the file could not be read.
+ */
+ public function file_head_hash(): string {
+ return (string) ( $this->data['file_head_hash'] ?? '' );
+ }
+
+ /**
+ * Hash the first 4 KB of a file, closing the size and mtime blind spot in
+ * the staged-file integrity check.
+ *
+ * @since 11.4.0
+ *
+ * @param string $file Absolute file path.
+ * @return string Hash string; empty when the file could not be read.
+ */
+ public static function hash_file_head( string $file ): string {
+ if ( '' === $file || ! is_readable( $file ) ) {
+ return '';
+ }
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading a staged local file head for integrity hashing.
+ $handle = fopen( $file, 'rb' );
+ if ( false === $handle ) {
+ return '';
+ }
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- See above.
+ $head = fread( $handle, 4096 );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- See above.
+ fclose( $handle );
+ return false === $head ? '' : md5( $head );
+ }
+
+ /**
+ * Effective CSV delimiter for this session.
+ *
+ * @since 11.4.0
+ *
+ * @return string
+ */
+ public function delimiter(): string {
+ $delimiter = (string) ( $this->data['delimiter'] ?? ',' );
+ return '' === $delimiter ? ',' : $delimiter;
+ }
+
+ /**
+ * Header row as parsed at prepare time.
+ *
+ * @since 11.4.0
+ *
+ * @return array<int, string>
+ */
+ public function headers(): array {
+ return array_values( array_map( 'strval', (array) ( $this->data['headers'] ?? array() ) ) );
+ }
+
+ /**
+ * Number of non-blank data rows after the header, as counted at prepare time.
+ *
+ * @since 11.4.0
+ *
+ * @return int
+ */
+ public function total(): int {
+ return (int) ( $this->data['total'] ?? 0 );
+ }
+
+ /**
+ * Cumulative processed-row count.
+ *
+ * @since 11.4.0
+ *
+ * @return int
+ */
+ public function processed(): int {
+ return (int) ( $this->data['processed'] ?? 0 );
+ }
+
+ /**
+ * Whether customer notifications should fire during the import run.
+ *
+ * @since 11.4.0
+ *
+ * @return bool
+ */
+ public function notify_customer(): bool {
+ return ! empty( $this->data['notify_customer'] );
+ }
+
+ /**
+ * Whether existing fulfillments should be updated on tracking-number match.
+ *
+ * @since 11.4.0
+ *
+ * @return bool
+ */
+ public function update_existing(): bool {
+ return ! empty( $this->data['update_existing'] );
+ }
+
+ /**
+ * Column mapping stored by the import run, or null until it stores one.
+ *
+ * @since 11.4.0
+ *
+ * @return array<int, string>|null CSV column index => canonical column key.
+ */
+ public function frozen_mapping(): ?array {
+ $mapping = $this->data['mapping'] ?? null;
+ if ( ! is_array( $mapping ) ) {
+ return null;
+ }
+ $out = array();
+ foreach ( $mapping as $col => $canonical ) {
+ $out[ (int) $col ] = (string) $canonical;
+ }
+ return $out;
+ }
+
+ /**
+ * Byte offset in the CSV reached by the import run.
+ *
+ * @since 11.4.0
+ *
+ * @return int
+ */
+ public function byte_offset(): int {
+ return (int) ( $this->data['byte_offset'] ?? 0 );
+ }
+
+ /**
+ * Cumulative result counts recorded by the import run.
+ *
+ * @since 11.4.0
+ *
+ * @return array{created:int, updated:int, skipped:int, failed:int, notified:int}
+ */
+ public function counts(): array {
+ $counts = is_array( $this->data['counts'] ?? null ) ? $this->data['counts'] : array();
+ return array(
+ 'created' => (int) ( $counts['created'] ?? 0 ),
+ 'updated' => (int) ( $counts['updated'] ?? 0 ),
+ 'skipped' => (int) ( $counts['skipped'] ?? 0 ),
+ 'failed' => (int) ( $counts['failed'] ?? 0 ),
+ 'notified' => (int) ( $counts['notified'] ?? 0 ),
+ );
+ }
+
+ /**
+ * The stored counts plus an empty rows list, in the shape the import run reports.
+ *
+ * Per-row results are not persisted, so rows is always empty here.
+ *
+ * @since 11.4.0
+ *
+ * @return array<string, mixed>
+ */
+ public function summary(): array {
+ return array_merge( $this->counts(), array( 'rows' => array() ) );
+ }
+
+ /**
+ * Tracking-number pairs already seen by the import run, used for deduplication.
+ *
+ * @since 11.4.0
+ *
+ * @return array<string, true>
+ */
+ public function seen_tracking_pairs(): array {
+ $seen = $this->data['seen_tracking_pairs'] ?? array();
+ return is_array( $seen ) ? $seen : array();
+ }
+
+ /**
+ * Owning user ID.
+ *
+ * @since 11.4.0
+ *
+ * @return int
+ */
+ public function user_id(): int {
+ return $this->user_id;
+ }
+
+ /**
+ * Persist the current payload back to its transient.
+ *
+ * The payload and the index pointer are both rewritten on every call, so their TTLs
+ * slide together. set_transient() returns false when the stored value is unchanged
+ * even though it still extends the timeout, so a false result is confirmed by reading
+ * the transient back before it is treated as a lost write.
+ *
+ * @return bool Whether the payload is stored.
+ */
+ private function persist(): bool {
+ $payload_key = self::PREFIX . $this->user_id . '_' . $this->token;
+
+ $stored = set_transient( $payload_key, $this->data, self::TTL );
+ if ( ! $stored ) {
+ $stored = get_transient( $payload_key ) === $this->data;
+ }
+
+ set_transient( self::INDEX_PREFIX . $this->user_id, $this->token, self::TTL );
+
+ if ( ! $stored ) {
+ wc_get_logger()->error(
+ sprintf(
+ 'Fulfillments import session %s could not be persisted.',
+ $this->token
+ ),
+ array( 'source' => 'fulfillments-importer' )
+ );
+ }
+
+ $this->persisted = (bool) $stored;
+ return $this->persisted;
+ }
+
+ /**
+ * Generate an opaque session token.
+ *
+ * @return string
+ */
+ private static function generate_token(): string {
+ try {
+ return bin2hex( random_bytes( 16 ) );
+ } catch ( \Throwable $e ) {
+ return (string) wp_generate_uuid4();
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
index 5753488de64..ed5172a9861 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController;
use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsPrivacy;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Importer\ImportSession;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use WC_Unit_Test_Case;
@@ -62,6 +63,19 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
parent::tearDown();
}
+ /**
+ * @testdox register() attaches the import session cleanup handler even while the feature flag is off.
+ */
+ public function test_register_attaches_cleanup_handler_with_feature_disabled(): void {
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ remove_action( ImportSession::CLEANUP_HOOK, array( ImportSession::class, 'handle_cleanup_hook' ), 10 );
+ $this->assertFalse( has_action( ImportSession::CLEANUP_HOOK, array( ImportSession::class, 'handle_cleanup_hook' ) ), 'The cleanup handler must be detached before register() runs' );
+
+ $this->sut->register();
+
+ $this->assertSame( 10, has_action( ImportSession::CLEANUP_HOOK, array( ImportSession::class, 'handle_cleanup_hook' ) ), 'register() must attach the cleanup handler at priority 10 while the feature is off' );
+ }
+
/**
* @testdox Should register the order-fulfillment data store when feature is enabled.
*/
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/Importer/ImportSessionTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/Importer/ImportSessionTest.php
new file mode 100644
index 00000000000..fd301d8f296
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/Importer/ImportSessionTest.php
@@ -0,0 +1,584 @@
+<?php declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Importer;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Importer\ImportSession;
+use Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper;
+use Automattic\WooCommerce\Tests\Internal\TransientFiles\TransientFilesTestStreamWrapper;
+
+/**
+ * Tests for the ImportSession value object.
+ */
+class ImportSessionTest extends \WC_Unit_Test_Case {
+
+ /**
+ * Action Scheduler group the session schedules its cleanup in.
+ */
+ private const CLEANUP_GROUP = 'woocommerce-fulfillments-importer';
+
+ /**
+ * Scheme the stream wrapper tests register their uploads directory under.
+ */
+ private const STREAM_SCHEME = 'wcfulfillmentstest';
+
+ /**
+ * Temporary files created by tests; removed in tearDown.
+ *
+ * @var array<int, string>
+ */
+ private array $temp_files = array();
+
+ /**
+ * Clean up files created during a test. Transients and scheduled actions are
+ * rolled back with the test transaction.
+ */
+ public function tearDown(): void {
+ foreach ( $this->temp_files as $path ) {
+ if ( file_exists( $path ) ) {
+ wp_delete_file( $path );
+ }
+ }
+ $this->temp_files = array();
+ parent::tearDown();
+ }
+
+ /**
+ * Create a session with fixed headers and totals.
+ *
+ * @param int $user_id User ID.
+ * @param string $file Staged file path to store.
+ * @return ImportSession
+ */
+ private function make_session( int $user_id, string $file = '/tmp/sample.csv' ): ImportSession {
+ return ImportSession::create(
+ $user_id,
+ $file,
+ ',',
+ array( 'order_number', 'tracking_number', 'shipment_provider' ),
+ 42,
+ false,
+ true
+ );
+ }
+
+ /**
+ * Write a small CSV inside the wc-imports directory and track it for tearDown.
+ *
+ * @return string Absolute path.
+ */
+ private function make_staged_file(): string {
+ $dir = wc_get_container()->get( CSVUploadHelper::class )->get_import_dir();
+ $file = trailingslashit( $dir ) . 'wc-fulfillments-import-' . wp_generate_uuid4() . '.csv';
+ file_put_contents( $file, "a,b,c\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+ $this->temp_files[] = $file;
+ return $file;
+ }
+
+ /**
+ * Action Scheduler arguments the session schedules its cleanup with.
+ *
+ * @param ImportSession $session Session.
+ * @return array{0:int, 1:string, 2:string, 3:int}
+ */
+ private function cleanup_args( ImportSession $session ): array {
+ return array( $session->user_id(), $session->token(), $session->file(), $session->attachment_id() );
+ }
+
+ /**
+ * Run a test with the uploads directory served by a stream wrapper, as on an S3-backed site.
+ *
+ * @param callable $test Receives the wrapper path of the uploads directory, e.g. "wcfulfillmentstest://uploads".
+ * @param string $uploads_dir Name of the uploads directory under the wrapper root.
+ */
+ private function run_with_stream_uploads( callable $test, string $uploads_dir = 'uploads' ): void {
+ $root = sys_get_temp_dir() . '/wc-fulfillments-stream-' . wp_generate_uuid4();
+ $uploads = self::STREAM_SCHEME . '://' . $uploads_dir;
+ wp_mkdir_p( $root . '/' . $uploads_dir . '/wc-imports' );
+ TransientFilesTestStreamWrapper::register( self::STREAM_SCHEME, $root );
+
+ $upload_dir_filter = static function ( $upload_dir ) use ( $uploads ) {
+ $upload_dir['basedir'] = $uploads;
+ $upload_dir['path'] = $uploads;
+ return $upload_dir;
+ };
+ add_filter( 'upload_dir', $upload_dir_filter );
+
+ try {
+ $test( $uploads );
+ } finally {
+ remove_filter( 'upload_dir', $upload_dir_filter );
+ TransientFilesTestStreamWrapper::unregister( self::STREAM_SCHEME );
+ self::remove_directory( $root );
+ }
+ }
+
+ /**
+ * Remove a fixture directory and everything in it.
+ *
+ * @param string $dir Absolute path.
+ */
+ private static function remove_directory( string $dir ): void {
+ $entries = scandir( $dir );
+ foreach ( false === $entries ? array() : $entries as $entry ) {
+ if ( '.' === $entry || '..' === $entry ) {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ if ( is_dir( $path ) ) {
+ self::remove_directory( $path );
+ } else {
+ wp_delete_file( $path );
+ }
+ }
+ rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removing a test fixture directory.
+ }
+
+ /**
+ * @testdox create() persists the payload and load() returns it back unchanged.
+ */
+ public function test_create_then_load_roundtrip(): void {
+ $user_id = 7;
+ $session = $this->make_session( $user_id );
+ $token = $session->token();
+
+ $this->assertNotEmpty( $token );
+
+ $loaded = ImportSession::load( $user_id, $token );
+ $this->assertNotNull( $loaded );
+ $this->assertSame( '/tmp/sample.csv', $loaded->file() );
+ $this->assertSame( ',', $loaded->delimiter() );
+ $this->assertSame( array( 'order_number', 'tracking_number', 'shipment_provider' ), $loaded->headers() );
+ $this->assertSame( 42, $loaded->total() );
+ $this->assertSame( 0, $loaded->processed() );
+ $this->assertFalse( $loaded->notify_customer() );
+ $this->assertTrue( $loaded->update_existing() );
+ $this->assertSame( array(), $loaded->seen_tracking_pairs() );
+ }
+
+ /**
+ * @testdox create() stores the staged file's real size and mtime for the staged-file integrity check.
+ */
+ public function test_create_stores_file_size_and_mtime(): void {
+ $path = wp_tempnam( 'wc-fulfillments-session-' );
+ $this->temp_files[] = $path;
+ file_put_contents( $path, "order_number,tracking_number,shipment_provider\n1,TRK-1,ups\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+
+ $session = $this->make_session( 31, $path );
+
+ $loaded = ImportSession::load( 31, $session->token() );
+ $this->assertNotNull( $loaded );
+ $this->assertSame( (int) filesize( $path ), $loaded->file_size() );
+ $this->assertGreaterThan( 0, $loaded->file_size() );
+ $this->assertSame( (int) filemtime( $path ), $loaded->file_mtime() );
+ }
+
+ /**
+ * @testdox A missing transient yields a null load result.
+ */
+ public function test_load_returns_null_for_unknown_token(): void {
+ $this->assertNull( ImportSession::load( 9, 'no-such-token' ) );
+ }
+
+ /**
+ * @testdox load() respects user scoping; another user cannot load someone else's session.
+ */
+ public function test_load_is_user_scoped(): void {
+ $session = $this->make_session( 11 );
+ $token = $session->token();
+
+ $this->assertNotNull( ImportSession::load( 11, $token ) );
+ $this->assertNull( ImportSession::load( 12, $token ) );
+ }
+
+ /**
+ * @testdox active_for_user() returns the current session, or null if none.
+ */
+ public function test_active_for_user_returns_open_session(): void {
+ $this->assertNull( ImportSession::active_for_user( 51 ) );
+
+ $session = $this->make_session( 51 );
+ $active = ImportSession::active_for_user( 51 );
+ $this->assertNotNull( $active );
+ $this->assertSame( $session->token(), $active->token() );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() refuses a readable path under ABSPATH that is outside the uploads directory.
+ */
+ public function test_cleanup_abandoned_file_refuses_path_under_abspath(): void {
+ $file = ABSPATH . 'wc-fulfillments-import-' . wp_generate_uuid4() . '.csv';
+ $this->temp_files[] = $file;
+ file_put_contents( $file, "a,b,c\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+
+ ImportSession::cleanup_abandoned_file( 75, 'ghost', $file );
+
+ $this->assertFileExists( $file, 'Only files staged in the import directory may be cleaned up' );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() refuses a path in the system temp directory.
+ */
+ public function test_cleanup_abandoned_file_refuses_path_in_system_temp_dir(): void {
+ $file = '/tmp/wc-fulfillments-not-in-uploads-' . wp_generate_uuid4() . '.csv';
+ $this->temp_files[] = $file;
+ file_put_contents( $file, "a,b,c\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write outside uploads on purpose.
+
+ ImportSession::cleanup_abandoned_file( 91, 'no-such-token', $file );
+
+ $this->assertFileExists( $file );
+ }
+
+ /**
+ * @testdox The cleanup hook callback coerces loosely typed arguments instead of fataling.
+ */
+ public function test_cleanup_hook_callback_coerces_arguments(): void {
+ $file = $this->make_staged_file();
+
+ add_action( ImportSession::CLEANUP_HOOK, array( ImportSession::class, 'handle_cleanup_hook' ), 10, 4 );
+
+ try {
+ // Action Scheduler payloads are persisted, so the user ID can come back as a string.
+ do_action( ImportSession::CLEANUP_HOOK, '73', 'ghost', $file, '0' );
+ } finally {
+ remove_action( ImportSession::CLEANUP_HOOK, array( ImportSession::class, 'handle_cleanup_hook' ), 10 );
+ }
+
+ $this->assertFileDoesNotExist( $file );
+ }
+
+ /**
+ * @testdox The cleanup hook callback ignores a non-scalar file argument.
+ */
+ public function test_cleanup_hook_callback_ignores_non_scalar_args(): void {
+ $file = $this->make_staged_file();
+
+ ImportSession::handle_cleanup_hook( 74, 'ghost', array( $file ) );
+
+ $this->assertFileExists( $file, 'A non-scalar file argument must not be coerced into a path' );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() deletes the staged file when the session transient is gone.
+ */
+ public function test_cleanup_abandoned_file_deletes_when_session_is_gone(): void {
+ $file = $this->make_staged_file();
+
+ // Fake an abandoned session: no transient is set for token "ghost".
+ ImportSession::cleanup_abandoned_file( 71, 'ghost', $file );
+
+ $this->assertFileDoesNotExist( $file );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() leaves the file alone and reschedules itself while the session is still active.
+ */
+ public function test_cleanup_abandoned_file_reschedules_for_live_session(): void {
+ $file = $this->make_staged_file();
+ $session = $this->make_session( 81, $file );
+ $args = $this->cleanup_args( $session );
+
+ // Drop the action create() scheduled so the one asserted below can only come from the cleanup.
+ as_unschedule_all_actions( ImportSession::CLEANUP_HOOK, $args, self::CLEANUP_GROUP );
+ $this->assertFalse( as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $args, self::CLEANUP_GROUP ) );
+
+ ImportSession::cleanup_abandoned_file( 81, $session->token(), $file );
+
+ $this->assertFileExists( $file );
+ $next = as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $args, self::CLEANUP_GROUP );
+ $this->assertIsInt( $next, 'A live session must get its cleanup scheduled again' );
+ $this->assertGreaterThan( time() + 4 * MINUTE_IN_SECONDS, $next, 'The retry must wait at least the grace period' );
+ $this->assertLessThanOrEqual( time() + HOUR_IN_SECONDS + 5 * MINUTE_IN_SECONDS, $next, 'The retry must not wait longer than TTL plus the grace period' );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() also deletes the attachment post created for the staged file.
+ */
+ public function test_cleanup_abandoned_file_deletes_attachment(): void {
+ $file = $this->make_staged_file();
+
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Fulfillments import test CSV',
+ 'post_mime_type' => 'text/csv',
+ ),
+ $file
+ );
+ $this->assertGreaterThan( 0, $attachment_id );
+
+ ImportSession::cleanup_abandoned_file( 72, 'ghost', $file, (int) $attachment_id );
+
+ $this->assertFileDoesNotExist( $file );
+ $this->assertNull( get_post( $attachment_id ), 'The attachment post must not be left behind' );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() ignores an attachment ID that no longer points at the staged file.
+ */
+ public function test_cleanup_abandoned_file_ignores_mismatched_attachment(): void {
+ $file = $this->make_staged_file();
+ $other = $this->make_staged_file();
+
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Unrelated attachment',
+ 'post_mime_type' => 'text/csv',
+ ),
+ $other
+ );
+ $this->assertGreaterThan( 0, $attachment_id );
+
+ ImportSession::cleanup_abandoned_file( 73, 'ghost', $file, (int) $attachment_id );
+
+ $this->assertFileDoesNotExist( $file );
+ $this->assertNotNull( get_post( $attachment_id ), 'An attachment for a different file must not be deleted' );
+ }
+
+ /**
+ * @testdox is_staged_path() accepts files in wc-imports only, not the rest of the uploads directory.
+ */
+ public function test_is_staged_path_is_limited_to_the_import_directory(): void {
+ $staged = $this->make_staged_file();
+ $media = trailingslashit( wp_upload_dir()['basedir'] ) . 'wc-fulfillments-import-' . wp_generate_uuid4() . '.csv';
+ file_put_contents( $media, "a,b,c\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+ $this->temp_files[] = $media;
+
+ $this->assertTrue( ImportSession::is_staged_path( $staged ), 'A file in wc-imports is staged' );
+ $this->assertFalse( ImportSession::is_staged_path( $media ), 'A file elsewhere in uploads is not staged' );
+ $this->assertFalse( ImportSession::is_staged_path( '' ), 'An empty path is not staged' );
+
+ $gone_staged = dirname( $staged ) . '/wc-fulfillments-import-gone.csv';
+ $gone_media = dirname( $media ) . '/wc-fulfillments-import-gone.csv';
+ $this->assertFileDoesNotExist( $gone_staged );
+ $this->assertTrue( ImportSession::is_staged_path( $gone_staged ), 'A missing file in wc-imports is still a staged path' );
+ $this->assertFalse( ImportSession::is_staged_path( $gone_media ), 'A missing file elsewhere in uploads is not staged' );
+ $this->assertFalse( ImportSession::is_staged_path( dirname( $staged ) . '/../wc-fulfillments-import-gone.csv' ), 'A missing path that climbs out of wc-imports is not staged' );
+ $this->assertFalse( ImportSession::is_staged_path( dirname( $staged ) . '/missing-dir/wc-fulfillments-import-gone.csv' ), 'A missing path in a missing directory is not staged' );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() keeps an attachment whose missing file is outside wc-imports.
+ */
+ public function test_cleanup_abandoned_file_keeps_attachment_for_missing_file_outside_import_dir(): void {
+ $media = trailingslashit( wp_upload_dir()['basedir'] ) . 'wc-fulfillments-import-' . wp_generate_uuid4() . '.jpg';
+ $this->assertFileDoesNotExist( $media );
+
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Media with a missing original',
+ 'post_mime_type' => 'image/jpeg',
+ ),
+ $media
+ );
+ $this->assertGreaterThan( 0, $attachment_id );
+
+ ImportSession::cleanup_abandoned_file( 74, 'ghost', $media, (int) $attachment_id );
+
+ $this->assertNotNull( get_post( $attachment_id ), 'An attachment outside wc-imports must not be deleted even when its file is gone' );
+ }
+
+ /**
+ * @testdox delete_staged_file() removes a staged file and the attachment that points at it.
+ */
+ public function test_delete_staged_file_removes_file_and_matching_attachment(): void {
+ $file = $this->make_staged_file();
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Fulfillments import test CSV',
+ 'post_mime_type' => 'text/csv',
+ ),
+ $file
+ );
+ $this->assertGreaterThan( 0, $attachment_id );
+
+ $this->assertTrue( ImportSession::delete_staged_file( $file, (int) $attachment_id ) );
+
+ $this->assertFileDoesNotExist( $file );
+ $this->assertNull( get_post( $attachment_id ), 'The attachment post must go with the file' );
+ $this->assertTrue( ImportSession::delete_staged_file( $file, (int) $attachment_id ), 'A second call for a file that is already gone is a no-op' );
+ }
+
+ /**
+ * @testdox delete_staged_file() refuses a path outside wc-imports and keeps its attachment, even when the file is missing.
+ */
+ public function test_delete_staged_file_refuses_path_outside_import_dir(): void {
+ $media = trailingslashit( wp_upload_dir()['basedir'] ) . 'wc-fulfillments-import-' . wp_generate_uuid4() . '.jpg';
+ file_put_contents( $media, 'not a csv' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+ $this->temp_files[] = $media;
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Media the importer must not touch',
+ 'post_mime_type' => 'image/jpeg',
+ ),
+ $media
+ );
+ $this->assertGreaterThan( 0, $attachment_id );
+
+ $this->assertFalse( ImportSession::delete_staged_file( $media, (int) $attachment_id ) );
+ $this->assertFileExists( $media, 'A file outside wc-imports must not be deleted' );
+ $this->assertNotNull( get_post( $attachment_id ), 'Its attachment must not be deleted' );
+
+ wp_delete_file( $media );
+ $this->assertFalse( ImportSession::delete_staged_file( $media, (int) $attachment_id ), 'The containment check must run for a missing file too' );
+ $this->assertNotNull( get_post( $attachment_id ), 'An attachment outside wc-imports must survive even when its file is gone' );
+ }
+
+ /**
+ * @testdox delete() removes the session record, its index pointer and its cleanup action.
+ */
+ public function test_delete_removes_session_and_cleanup_action(): void {
+ $session = $this->make_session( 33 );
+ $token = $session->token();
+ $args = $this->cleanup_args( $session );
+
+ $timeout = (int) get_option( '_transient_timeout_wc_fulfillment_import_33_' . $token );
+ $this->assertGreaterThan( time(), $timeout );
+ $this->assertLessThanOrEqual( time() + HOUR_IN_SECONDS, $timeout, 'A session must expire within an hour' );
+ $this->assertIsInt( as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $args, self::CLEANUP_GROUP ) );
+
+ $session->delete();
+
+ $this->assertNull( ImportSession::load( 33, $token ) );
+ $this->assertNull( ImportSession::active_for_user( 33 ), 'The index pointer must be cleared' );
+ $this->assertFalse( as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $args, self::CLEANUP_GROUP ), 'The cleanup action must be unscheduled' );
+ }
+
+ /**
+ * @testdox persist() extends the payload TTL even when the payload is unchanged, so it keeps pace with the index pointer.
+ */
+ public function test_persist_slides_payload_ttl_for_unchanged_payload(): void {
+ $session = $this->make_session( 48, $this->make_staged_file() );
+ $timeout_option = '_transient_timeout_wc_fulfillment_import_48_' . $session->token();
+ $shortened_timeout = time() + 10 * MINUTE_IN_SECONDS;
+ update_option( $timeout_option, $shortened_timeout );
+
+ $persist = new \ReflectionMethod( ImportSession::class, 'persist' );
+ $persist->setAccessible( true );
+
+ $this->assertTrue( $persist->invoke( $session ), 'An unchanged payload still counts as stored' );
+ $this->assertGreaterThanOrEqual( time() + HOUR_IN_SECONDS - MINUTE_IN_SECONDS, (int) get_option( $timeout_option ), 'The payload TTL must be a full hour again after persist(), even when the payload is unchanged' );
+ }
+
+ /**
+ * @testdox Creating a second session for the same user invalidates the first.
+ */
+ public function test_create_replaces_prior_session_for_same_user(): void {
+ $first = $this->make_session( 44 );
+ $first_token = $first->token();
+
+ $second = $this->make_session( 44 );
+ $this->assertNotSame( $first_token, $second->token() );
+
+ $this->assertNull( ImportSession::load( 44, $first_token ), 'Prior session should be invalidated.' );
+ $this->assertNotNull( ImportSession::load( 44, $second->token() ) );
+ }
+
+ /**
+ * @testdox create() reports the session it replaced, so overlapping prepares each learn which session they retired.
+ */
+ public function test_create_reports_replaced_session(): void {
+ $first = $this->make_session( 46, $this->make_staged_file() );
+ $this->assertNull( $first->replaced(), 'The first session of a user replaces nothing' );
+
+ $second = $this->make_session( 46, $this->make_staged_file() );
+ $third = $this->make_session( 46, $this->make_staged_file() );
+
+ $this->assertNotNull( $second->replaced() );
+ $this->assertSame( $first->token(), $second->replaced()->token() );
+ $this->assertSame( $first->file(), $second->replaced()->file() );
+ $this->assertNotNull( $third->replaced() );
+ $this->assertSame( $second->token(), $third->replaced()->token(), 'The replaced session is the one active at create() time, not an earlier read' );
+ $this->assertNull( ImportSession::load( 46, $second->token() ), 'The replaced session record must be gone' );
+ }
+
+ /**
+ * @testdox create() drops the session and reports it as not persisted when the cleanup cannot be scheduled.
+ */
+ public function test_create_drops_session_when_cleanup_cannot_be_scheduled(): void {
+ $file = $this->make_staged_file();
+ $prior = $this->make_session( 47, $this->make_staged_file() );
+
+ $fail_schedule = static function () {
+ return 0;
+ };
+ add_filter( 'pre_as_schedule_single_action', $fail_schedule );
+ try {
+ $session = $this->make_session( 47, $file );
+ } finally {
+ remove_filter( 'pre_as_schedule_single_action', $fail_schedule );
+ }
+
+ $this->assertFalse( $session->persisted() );
+ $this->assertNull( ImportSession::load( 47, $session->token() ), 'A session without a cleanup owner must not be stored' );
+ $this->assertNull( ImportSession::active_for_user( 47 ) );
+ $this->assertFalse( as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $this->cleanup_args( $session ), self::CLEANUP_GROUP ) );
+ $this->assertNotNull( $session->replaced() );
+ $this->assertSame( $prior->token(), $session->replaced()->token(), 'The prior session was retired before the schedule failed, so the caller must still clean its file up' );
+ $this->assertFileExists( $file, 'The session API leaves file deletion to the caller' );
+ }
+
+ /**
+ * @testdox Creating a second session for the same user unschedules the first session's cleanup action.
+ */
+ public function test_create_unschedules_prior_session_cleanup_action(): void {
+ $first = $this->make_session( 45, $this->make_staged_file() );
+ $first_args = $this->cleanup_args( $first );
+ $this->assertIsInt( as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $first_args, self::CLEANUP_GROUP ) );
+
+ $second = $this->make_session( 45, $this->make_staged_file() );
+
+ $this->assertFalse( as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $first_args, self::CLEANUP_GROUP ), 'The replaced session must not keep a cleanup action' );
+ $this->assertIsInt( as_next_scheduled_action( ImportSession::CLEANUP_HOOK, $this->cleanup_args( $second ), self::CLEANUP_GROUP ) );
+ }
+
+ /**
+ * @testdox is_staged_path() accepts a file in wc-imports when the uploads directory is a stream wrapper path.
+ */
+ public function test_is_staged_path_accepts_stream_wrapper_import_directory(): void {
+ $this->run_with_stream_uploads(
+ function ( string $uploads ) {
+ $staged = $uploads . '/wc-imports/wc-fulfillments-import-' . wp_generate_uuid4() . '.csv';
+ file_put_contents( $staged, "a,b,c\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+
+ $this->assertTrue( ImportSession::is_staged_path( $staged ), 'A file in wc-imports on a stream wrapper is staged' );
+ $this->assertTrue( ImportSession::is_staged_path( $uploads . '/wc-imports/wc-fulfillments-import-gone.csv' ), 'A missing file in wc-imports on a stream wrapper is still a staged path' );
+ $this->assertFalse( ImportSession::is_staged_path( $uploads . '/wc-fulfillments-import-gone.csv' ), 'A file elsewhere in the stream uploads directory is not staged' );
+ $this->assertFalse( ImportSession::is_staged_path( $uploads . '/wc-imports/../wc-fulfillments-import-gone.csv' ), 'A stream path that climbs out of wc-imports is not staged' );
+ $this->assertFalse( ImportSession::is_staged_path( $uploads . '/wc-imports/' ), 'The stream import directory itself is not a staged file' );
+ }
+ );
+ }
+
+ /**
+ * @testdox is_staged_path() on a stream wrapper does not depend on what wp_normalize_path() cached before the wrapper existed.
+ */
+ public function test_is_staged_path_ignores_stale_wp_normalize_path_cache_for_stream_wrapper(): void {
+ // Before the wrapper is registered, wp_normalize_path() collapses the "://" and caches that form.
+ wp_normalize_path( self::STREAM_SCHEME . '://stale-uploads/wc-imports' );
+
+ $this->run_with_stream_uploads(
+ function ( string $uploads ) {
+ $staged = $uploads . '/wc-imports/wc-fulfillments-import-' . wp_generate_uuid4() . '.csv';
+ file_put_contents( $staged, "a,b,c\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+
+ $this->assertTrue( ImportSession::is_staged_path( $staged ), 'A stale wp_normalize_path() cache entry must not break the stream path check' );
+ },
+ 'stale-uploads'
+ );
+ }
+
+ /**
+ * @testdox cleanup_abandoned_file() deletes the staged file when the uploads directory is a stream wrapper path.
+ */
+ public function test_cleanup_abandoned_file_deletes_stream_wrapper_file_when_session_is_gone(): void {
+ $this->run_with_stream_uploads(
+ function ( string $uploads ) {
+ $staged = $uploads . '/wc-imports/wc-fulfillments-import-' . wp_generate_uuid4() . '.csv';
+ file_put_contents( $staged, "a,b,c\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture write.
+
+ ImportSession::cleanup_abandoned_file( 76, 'ghost', $staged );
+
+ $this->assertFileDoesNotExist( $staged, 'An abandoned file on a stream wrapper must be deleted like a local one' );
+ }
+ );
+ }
+}