Commit 6c6e79ed6e0 for woocommerce

commit 6c6e79ed6e0154d98aa8deb1632539120ce9071c
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Thu Nov 5 22:10:54 2026 +0300

    Add fulfillment data to personal data export and erasure (#69796)

diff --git a/plugins/woocommerce/changelog/69796-wooplug-7977-fulfillment-privacy-export-erase b/plugins/woocommerce/changelog/69796-wooplug-7977-fulfillment-privacy-export-erase
new file mode 100644
index 00000000000..956bdc5c3bf
--- /dev/null
+++ b/plugins/woocommerce/changelog/69796-wooplug-7977-fulfillment-privacy-export-erase
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Include fulfillment tracking data in WordPress personal data exports, and redact it when an order is anonymized.
diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
index b3ea6d7b19e..054db2053ca 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
@@ -710,6 +710,32 @@ function customize_provider_label( $label, $fulfillment ) {
 }
 ```

+### Privacy Filters
+
+#### `woocommerce_privacy_remove_fulfillment_personal_data_meta`
+
+Filters the fulfillment meta keys cleared when an order is anonymized through the WordPress personal-data eraser or the automatic retention cleanup. It also runs on a store where the fulfillments feature has since been disabled, as long as its tables were created while it was on, since any tracking data they hold is still stored.
+
+**File:** `src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php`
+
+**Parameters:**
+
+-   `$meta_keys` (string[]) - The meta keys to clear. Defaults to `_tracking_number` and `_tracking_url`
+-   `$order` (WC_Order) - The order being anonymized
+
+**Return Value:** The list of meta keys to clear.
+
+**Purpose:** Add extension-stored personal data (for example a shipping label URL or an internal shipment ID) to the keys redacted during erasure.
+
+```php
+add_filter( 'woocommerce_privacy_remove_fulfillment_personal_data_meta', 'redact_custom_fulfillment_meta', 10, 2 );
+
+function redact_custom_fulfillment_meta( $meta_keys, $order ) {
+    $meta_keys[] = '_shipping_label_url';
+    return $meta_keys;
+}
+```
+
 ## Best Practices

 ### Hook Priority and Execution Order
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
index 3723b1ddf15..016eb6499ca 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
@@ -22,6 +22,7 @@ class FulfillmentsController {
 		FulfillmentsRenderer::class,
 		FulfillmentsSettings::class,
 		OrderFulfillmentsRestController::class,
+		FulfillmentsPrivacy::class,
 	);

 	/**
@@ -52,7 +53,11 @@ class FulfillmentsController {
 		// because the woocommerce_data_stores filter can fire before the 'init' action
 		// (e.g. from WC Payments during 'plugins_loaded'), and feature_is_enabled() would
 		// trigger translation loading too early, causing _load_textdomain_just_in_time warnings.
-		if ( 'yes' !== get_option( 'woocommerce_feature_fulfillments_enabled', 'no' ) ) {
+		$feature_enabled = 'yes' === get_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+
+		// A store that created fulfillments and then disabled the feature still holds that
+		// data, so the store stays registered for the privacy export and eraser to read it.
+		if ( ! $feature_enabled && ! get_option( 'woocommerce_fulfillments_db_tables_created', false ) ) {
 			return $data_stores;
 		}

@@ -69,6 +74,12 @@ class FulfillmentsController {

 		// If fulfillments feature is not enabled, do not add the DB tables, and don't register the controller.
 		if ( ! $features_controller->feature_is_enabled( 'fulfillments' ) ) {
+			// A store that created fulfillments before the feature was disabled still holds
+			// tracking data that can resolve to a delivery address, so both export and
+			// erasure keep working.
+			if ( get_option( 'woocommerce_fulfillments_db_tables_created', false ) ) {
+				$container->get( FulfillmentsPrivacy::class )->register();
+			}
 			return;
 		}

diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php
new file mode 100644
index 00000000000..d3a2f9dc5ff
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php
@@ -0,0 +1,332 @@
+<?php declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
+use WC_Data_Store;
+use WC_Order;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Wires fulfillment (shipment tracking) data into the WordPress personal-data
+ * export and erasure framework.
+ *
+ * Export appends the customer's fulfillment records onto each order in their
+ * personal-data export, through the core order exporter.
+ * Erasure redacts the tracking meta whenever an order is anonymized, which covers
+ * both the admin "Erase Personal Data" tool and the automatic retention cleanup,
+ * since both route through {@see \WC_Privacy_Erasers::remove_order_personal_data()}.
+ *
+ * Redaction writes directly to the core fulfillment tables, and the export asks the data
+ * store for soft-deleted records, which FulfillmentsDataStoreInterface does not declare. So a
+ * store swapped in through the `woocommerce_data_stores` filter owns its own erasure and
+ * whatever it returns to the export.
+ *
+ * @since 11.4.0
+ */
+class FulfillmentsPrivacy {
+
+	/**
+	 * Register the privacy hooks.
+	 *
+	 * @since 11.4.0
+	 */
+	public function register(): void {
+		add_filter( 'woocommerce_privacy_export_order_personal_data', array( $this, 'export_order_fulfillment_data' ), 10, 2 );
+		add_action( 'woocommerce_privacy_remove_order_personal_data', array( $this, 'remove_fulfillment_personal_data' ) );
+	}
+
+	/**
+	 * Whether this store ever created the fulfillment tables.
+	 *
+	 * Reads the marker option rather than probing with SHOW TABLES, which carries no table
+	 * name for a db-routing drop-in to route on and so can be answered by the wrong dataset.
+	 *
+	 * @return bool
+	 */
+	private function tables_exist(): bool {
+		return (bool) get_option( 'woocommerce_fulfillments_db_tables_created', false );
+	}
+
+	/**
+	 * Append an order's fulfillment tracking data to its personal-data export entry.
+	 *
+	 * Hooked onto the core order exporter rather than registered as a standalone exporter,
+	 * and each shipment is emitted as a single row. Core reloads and rewrites the whole
+	 * accumulated export on every page, so both the item count and the row count are paid
+	 * again on each remaining page, and a large order history exhausts memory.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed $personal_data Name/value rows the core exporter built for the order.
+	 * @param mixed $order         The order being exported.
+	 * @return array
+	 */
+	public function export_order_fulfillment_data( $personal_data, $order ): array {
+		$personal_data = is_array( $personal_data ) ? $personal_data : array();
+
+		if ( ! $order instanceof WC_Order ) {
+			return $personal_data;
+		}
+
+		// Nothing to read without the tables, and on a store with the feature off the data
+		// store is registered only because they exist.
+		if ( ! $this->tables_exist() ) {
+			return $personal_data;
+		}
+
+		// A read failure here must not abort the customer's whole personal-data export, which
+		// spans every extension's order data, so it is logged and this order's fulfillment rows
+		// are skipped. Core reports the request complete either way, so the log line is the
+		// only signal a failure leaves.
+		try {
+			$fulfillments = $this->read_order_fulfillments( $order );
+
+			$shipments = array();
+
+			foreach ( $fulfillments as $fulfillment ) {
+				// Defensive: a fulfillment saved through the data store always has a valid
+				// status, so it always yields at least one field. A malformed stored row
+				// might not, and must not take a shipment number with it.
+				$rows = $this->get_fulfillment_personal_data( $fulfillment, $order );
+				if ( empty( $rows ) ) {
+					continue;
+				}
+
+				// Values are joined with plain punctuation rather than a translatable format
+				// string: a locale that lost a placeholder would delete exported data.
+				$fields = array();
+				foreach ( $rows as $row ) {
+					$fields[] = $row['name'] . ': ' . $row['value'];
+				}
+
+				$shipments[] = implode( '; ', $fields );
+			}
+
+			// Several shipments share the one order entry, so number them to tell them apart,
+			// off the shipments that produced fields so the export shows neither a gap nor a
+			// number implying one it left out.
+			$numbered      = count( $shipments ) > 1;
+			$shipment_rows = array();
+
+			foreach ( $shipments as $index => $shipment ) {
+				$shipment_rows[] = array(
+					'name'  => $numbered
+						/* translators: %s: shipment number within the order. */
+						? sprintf( _x( 'Shipment %s', 'fulfillment export field', 'woocommerce' ), number_format_i18n( $index + 1 ) )
+						: _x( 'Shipment', 'fulfillment export field', 'woocommerce' ),
+					'value' => $shipment,
+				);
+			}
+
+			// Appended in one go, so a failure part way through cannot leave the order half
+			// exported while the request still reports it complete.
+			$personal_data = array_merge( $personal_data, $shipment_rows );
+		} catch ( \Throwable $e ) {
+			wc_get_logger()->error(
+				'Failed to export fulfillment personal data for order ' . $order->get_id() . ': ' . $e->getMessage(),
+				array( 'source' => 'fulfillments' )
+			);
+		}
+
+		return $personal_data;
+	}
+
+	/**
+	 * Read every fulfillment of an order, including soft-deleted ones.
+	 *
+	 * Soft-deleted records are included because their tracking meta is retained and must be
+	 * covered by the access request, mirroring what the eraser clears.
+	 *
+	 * @param WC_Order $order The order being exported.
+	 * @return Fulfillment[]
+	 * @throws \Exception If the read left a database error behind.
+	 */
+	private function read_order_fulfillments( WC_Order $order ): array {
+		/**
+		 * Fulfillments data store.
+		 *
+		 * @var FulfillmentsDataStore $datastore
+		 */
+		$datastore = WC_Data_Store::load( 'order-fulfillment' );
+
+		global $wpdb;
+
+		// A replacement store can answer without querying, which would leave an unrelated
+		// error in place and drop this order's rows for no reason. flush() is what wpdb runs
+		// at the start of every query anyway.
+		$wpdb->flush();
+
+		$fulfillments = $datastore->read_fulfillments( WC_Order::class, (string) $order->get_id(), true );
+
+		// get_results() returns an empty array on a failed query, so a database error is
+		// otherwise indistinguishable from "no fulfillments" and would be reported as a
+		// complete export. wpdb resets the error on every query, so this sees the fulfillment
+		// read and the last meta read, not an earlier meta read a later one masked.
+		if ( '' !== (string) $wpdb->last_error ) {
+			throw new \Exception( esc_html( 'Failed to read fulfillment data: ' . $wpdb->last_error ) );
+		}
+
+		return $fulfillments;
+	}
+
+	/**
+	 * Build the exportable fields for a single fulfillment.
+	 *
+	 * The order's items cover everything purchased, but an order can be split across
+	 * several fulfillments, so the shipment-level breakdown (which items shipped where,
+	 * and with what tracking) is not otherwise in the export. The labels are read inside
+	 * a row already named for the shipment, so they stay short.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment.
+	 * @param WC_Order    $order       The order the fulfillment belongs to.
+	 * @return array<int, array{name: string, value: string}>
+	 */
+	private function get_fulfillment_personal_data( Fulfillment $fulfillment, WC_Order $order ): array {
+		$data = array();
+
+		$status = (string) $fulfillment->get_status();
+		if ( '' !== $status ) {
+			$statuses = FulfillmentUtils::get_fulfillment_statuses();
+			$label    = $statuses[ $status ]['label'] ?? $status;
+			$data[]   = array(
+				'name'  => _x( 'Status', 'fulfillment export field', 'woocommerce' ),
+				'value' => is_scalar( $label ) ? (string) $label : $status,
+			);
+		}
+
+		$item_names = array();
+		foreach ( $fulfillment->get_items() as $item ) {
+			if ( ! is_array( $item ) ) {
+				continue;
+			}
+			$item_id    = isset( $item['item_id'] ) ? (int) $item['item_id'] : 0;
+			$order_item = $item_id > 0 ? $order->get_item( $item_id, false ) : false;
+			if ( $order_item ) {
+				$quantity     = isset( $item['qty'] ) ? (int) $item['qty'] : 0;
+				$item_names[] = $order_item->get_name() . ' x ' . $quantity;
+			}
+		}
+		if ( ! empty( $item_names ) ) {
+			$data[] = array(
+				'name'  => _x( 'Items', 'fulfillment export field', 'woocommerce' ),
+				'value' => implode( ', ', $item_names ),
+			);
+		}
+
+		$tracking_number = $fulfillment->get_tracking_number();
+		if ( ! empty( $tracking_number ) ) {
+			$data[] = array(
+				'name'  => _x( 'Tracking number', 'fulfillment export field', 'woocommerce' ),
+				'value' => $tracking_number,
+			);
+		}
+
+		$provider = FulfillmentUtils::get_provider_display_name( $fulfillment );
+		if ( '' !== $provider ) {
+			$data[] = array(
+				'name'  => _x( 'Provider', 'fulfillment export field', 'woocommerce' ),
+				'value' => $provider,
+			);
+		}
+
+		$tracking_url = $fulfillment->get_tracking_url();
+		if ( ! empty( $tracking_url ) ) {
+			$data[] = array(
+				'name'  => _x( 'Tracking URL', 'fulfillment export field', 'woocommerce' ),
+				'value' => $tracking_url,
+			);
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Redact fulfillment tracking data for an order being anonymized.
+	 *
+	 * Includes soft-deleted fulfillments, since their tracking meta is retained in
+	 * the database and a tracking number can be resolved to a delivery address.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed $order The order being anonymized.
+	 */
+	public function remove_fulfillment_personal_data( $order ): void {
+		if ( ! $order instanceof WC_Order ) {
+			return;
+		}
+
+		// Nearly every store with the feature off never created these tables, so skip those
+		// instead of failing a query for each order anonymized.
+		if ( ! $this->tables_exist() ) {
+			return;
+		}
+
+		/**
+		 * Filters the fulfillment meta keys redacted during personal-data erasure.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param string[] $meta_keys The meta keys to clear.
+		 * @param WC_Order $order     The order being anonymized.
+		 */
+		$meta_keys = apply_filters(
+			'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+			array( '_tracking_number', '_tracking_url' ),
+			$order
+		);
+
+		if ( ! is_array( $meta_keys ) ) {
+			return;
+		}
+
+		$meta_keys = array_values(
+			array_unique(
+				array_filter(
+					array_map( 'strval', array_filter( $meta_keys, 'is_scalar' ) ),
+					function ( string $key ): bool {
+						return '' !== $key;
+					}
+				)
+			)
+		);
+		if ( empty( $meta_keys ) ) {
+			return;
+		}
+
+		global $wpdb;
+
+		// Redact the tracking meta for every fulfillment of the order, including soft-deleted
+		// records. The data store keeps deleted fulfillments immutable, but their retained
+		// tracking number can still be resolved to a delivery address, so it must be cleared
+		// directly. Values are stored JSON-encoded, so an empty string is written as `""`.
+		$placeholders = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
+		$params       = array_merge(
+			array( wp_json_encode( '' ), WC_Order::class, (string) $order->get_id() ),
+			$meta_keys
+		);
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+		$result = $wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_order_fulfillment_meta AS m
+				INNER JOIN {$wpdb->prefix}wc_order_fulfillments AS f ON f.fulfillment_id = m.fulfillment_id
+				SET m.meta_value = %s
+				WHERE f.entity_type = %s AND f.entity_id = %s AND m.meta_key IN ( {$placeholders} )",
+				$params
+			)
+		);
+		// phpcs:enable
+
+		// The erasure action cannot report failure back to the request, so a failed redaction
+		// would otherwise leave tracking in place while the order reads as anonymized. Zero rows
+		// is not a failure, so only false is logged.
+		if ( false === $result ) {
+			wc_get_logger()->error(
+				'Failed to redact fulfillment tracking for order ' . $order->get_id() . ': ' . $wpdb->last_error,
+				array( 'source' => 'fulfillments' )
+			);
+		}
+	}
+}
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 c94d5ba20e4..5753488de64 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
@@ -5,6 +5,8 @@ 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\Internal\Features\FeaturesController;
 use WC_Unit_Test_Case;

 /**
@@ -26,12 +28,20 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
 	 */
 	private $original_fulfillments_flag;

+	/**
+	 * Original value of the fulfillment tables marker.
+	 *
+	 * @var mixed
+	 */
+	private $original_tables_created;
+
 	/**
 	 * Set up test fixtures.
 	 */
 	public function setUp(): void {
 		parent::setUp();
 		$this->original_fulfillments_flag = get_option( 'woocommerce_feature_fulfillments_enabled' );
+		$this->original_tables_created    = get_option( 'woocommerce_fulfillments_db_tables_created' );
 		$this->sut                        = wc_get_container()->get( FulfillmentsController::class );
 	}

@@ -44,6 +54,11 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
 		} else {
 			update_option( 'woocommerce_feature_fulfillments_enabled', $this->original_fulfillments_flag );
 		}
+		if ( false === $this->original_tables_created ) {
+			delete_option( 'woocommerce_fulfillments_db_tables_created' );
+		} else {
+			update_option( 'woocommerce_fulfillments_db_tables_created', $this->original_tables_created );
+		}
 		parent::tearDown();
 	}

@@ -60,16 +75,34 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should not register the order-fulfillment data store when feature is disabled.
+	 * @testdox Should not register the order-fulfillment data store when the feature is disabled and the tables were never created.
 	 */
 	public function test_register_data_stores_when_feature_disabled(): void {
 		update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+		delete_option( 'woocommerce_fulfillments_db_tables_created' );

 		$result = $this->sut->register_data_stores( array() );

 		$this->assertArrayNotHasKey( 'order-fulfillment', $result, 'Data store should not be registered when feature is disabled' );
 	}

+	/**
+	 * @testdox Should register the order-fulfillment data store when the feature is disabled but its tables exist.
+	 */
+	public function test_register_data_stores_when_feature_disabled_but_tables_exist(): void {
+		update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+		update_option( 'woocommerce_fulfillments_db_tables_created', true );
+
+		$result = $this->sut->register_data_stores( array() );
+
+		$this->assertArrayHasKey(
+			'order-fulfillment',
+			$result,
+			'Fulfillment data left behind by the disabled feature still has to be readable for the privacy export'
+		);
+		$this->assertSame( FulfillmentsDataStore::class, $result['order-fulfillment'] );
+	}
+
 	/**
 	 * @testdox Should preserve existing data stores when feature is enabled.
 	 */
@@ -89,6 +122,7 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
 	 */
 	public function test_register_data_stores_preserves_existing_stores_when_disabled(): void {
 		update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+		delete_option( 'woocommerce_fulfillments_db_tables_created' );
 		$existing = array( 'some-store' => 'SomeStoreClass' );

 		$result = $this->sut->register_data_stores( $existing );
@@ -97,6 +131,68 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'order-fulfillment', $result );
 	}

+	/**
+	 * @testdox Should keep both privacy hooks registered when the feature is disabled.
+	 */
+	public function test_initialize_fulfillments_registers_privacy_hooks_when_feature_disabled(): void {
+		update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+		update_option( 'woocommerce_fulfillments_db_tables_created', true );
+
+		// Both branches of initialize_fulfillments() register these hooks, so confirm which one
+		// is under test rather than letting the enabled branch satisfy the assertions.
+		$this->assertFalse(
+			wc_get_container()->get( FeaturesController::class )->feature_is_enabled( 'fulfillments' ),
+			'This test covers the disabled branch.'
+		);
+
+		// The container instance is shared, so clear both hooks before asserting that the
+		// disabled path puts them back.
+		$privacy = wc_get_container()->get( FulfillmentsPrivacy::class );
+		remove_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) );
+		remove_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) );
+
+		$this->sut->initialize_fulfillments();
+
+		$this->assertNotFalse(
+			has_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) ),
+			'Tracking data left behind by the disabled feature should still be erasable'
+		);
+		$this->assertNotFalse(
+			has_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) ),
+			'An access request should still return tracking data the disabled feature left behind'
+		);
+	}
+
+	/**
+	 * @testdox Should register no privacy hooks when the feature is disabled and the tables were never created.
+	 */
+	public function test_initialize_fulfillments_registers_no_privacy_hooks_without_tables(): void {
+		update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+		delete_option( 'woocommerce_fulfillments_db_tables_created' );
+
+		// Without the marker the enabled branch would drop and recreate the fulfillment tables,
+		// and DDL commits implicitly, so the base class transaction could not roll it back.
+		$this->assertFalse(
+			wc_get_container()->get( FeaturesController::class )->feature_is_enabled( 'fulfillments' ),
+			'This test covers the disabled branch.'
+		);
+
+		$privacy = wc_get_container()->get( FulfillmentsPrivacy::class );
+		remove_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) );
+		remove_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) );
+
+		$this->sut->initialize_fulfillments();
+
+		$this->assertFalse(
+			has_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) ),
+			'A store that never created the tables has no fulfillment data to erase'
+		);
+		$this->assertFalse(
+			has_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) ),
+			'A store that never created the tables has no fulfillment data to export'
+		);
+	}
+
 	/**
 	 * @testdox Should return non-array input unchanged.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsPrivacyTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsPrivacyTest.php
new file mode 100644
index 00000000000..95bdff0c82a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsPrivacyTest.php
@@ -0,0 +1,623 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsPrivacy;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
+use WC_Data_Store;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the FulfillmentsPrivacy class.
+ */
+class FulfillmentsPrivacyTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var FulfillmentsPrivacy
+	 */
+	private $sut;
+
+	/**
+	 * Original value of the fulfillments feature flag.
+	 *
+	 * @var mixed
+	 */
+	private static $original_fulfillments_flag;
+
+	/**
+	 * Enable the fulfillments feature and register its data store.
+	 */
+	public static function setUpBeforeClass(): void {
+		parent::setUpBeforeClass();
+		self::$original_fulfillments_flag = get_option( 'woocommerce_feature_fulfillments_enabled' );
+		update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
+		$controller = wc_get_container()->get( \Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class );
+		$controller->register();
+		$controller->initialize_fulfillments();
+	}
+
+	/**
+	 * Restore the fulfillments feature flag.
+	 */
+	public static function tearDownAfterClass(): void {
+		if ( false === self::$original_fulfillments_flag ) {
+			delete_option( 'woocommerce_feature_fulfillments_enabled' );
+		} else {
+			update_option( 'woocommerce_feature_fulfillments_enabled', self::$original_fulfillments_flag );
+		}
+		parent::tearDownAfterClass();
+	}
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new FulfillmentsPrivacy();
+	}
+
+	/**
+	 * Create an order with a fulfillment carrying the given tracking meta.
+	 *
+	 * @param array $meta Fulfillment meta.
+	 * @return array{0: WC_Order, 1: Fulfillment}
+	 */
+	private function create_order_with_fulfillment( array $meta ): array {
+		$order       = OrderHelper::create_order();
+		$order_items = $order->get_items();
+		$order_item  = reset( $order_items );
+		$item_id     = $order_item ? $order_item->get_id() : 1;
+
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id'    => (string) $order->get_id(),
+				'status'       => 'fulfilled',
+				'is_fulfilled' => true,
+			),
+			array_merge(
+				array(
+					'_items' => array(
+						array(
+							'item_id' => $item_id,
+							'qty'     => 1,
+						),
+					),
+				),
+				$meta
+			)
+		);
+
+		return array( $order, $fulfillment );
+	}
+
+	/**
+	 * Reload a fulfillment by ID, including soft-deleted records.
+	 *
+	 * @param int $order_id       The order ID.
+	 * @param int $fulfillment_id The fulfillment ID.
+	 * @return Fulfillment|null
+	 */
+	private function reload_fulfillment( int $order_id, int $fulfillment_id ): ?Fulfillment {
+		$datastore    = WC_Data_Store::load( 'order-fulfillment' );
+		$fulfillments = $datastore->read_fulfillments( WC_Order::class, (string) $order_id, true );
+
+		foreach ( $fulfillments as $fulfillment ) {
+			if ( $fulfillment->get_id() === $fulfillment_id ) {
+				return $fulfillment;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * @testdox register() hooks the order exporter and the order anonymization eraser.
+	 */
+	public function test_register_hooks_order_exporter_and_eraser(): void {
+		$this->sut->register();
+
+		$this->assertNotFalse(
+			has_filter( 'woocommerce_privacy_export_order_personal_data', array( $this->sut, 'export_order_fulfillment_data' ) ),
+			'The order exporter filter should be hooked.'
+		);
+		$this->assertNotFalse(
+			has_action( 'woocommerce_privacy_remove_order_personal_data', array( $this->sut, 'remove_fulfillment_personal_data' ) ),
+			'The eraser should hook order anonymization.'
+		);
+	}
+
+	/**
+	 * @testdox The order export does not fake a complete export when the fulfillment read fails.
+	 */
+	public function test_export_skips_order_when_read_fails(): void {
+		$order = new WC_Order();
+		$order->save();
+
+		// Swap in a data store whose read throws. Extends the real store so it passes
+		// WC_Data_Store validation.
+		add_filter(
+			'woocommerce_order-fulfillment_data_store',
+			function () {
+				return new class() extends FulfillmentsDataStore {
+					/**
+					 * Fail the read the exporter performs.
+					 *
+					 * @param string $entity_type  The entity type.
+					 * @param string $entity_id    The entity ID.
+					 * @param bool   $with_deleted Whether to include deleted fulfillments.
+					 * @return array
+					 * @throws \Exception When deleted records are requested, as the exporter does.
+					 */
+					public function read_fulfillments( string $entity_type, string $entity_id, bool $with_deleted = false ): array {
+						if ( $with_deleted ) {
+							throw new \Exception( 'Simulated read failure.' );
+						}
+						return array();
+					}
+				};
+			}
+		);
+
+		$existing = array(
+			array(
+				'name'  => 'Order number',
+				'value' => (string) $order->get_id(),
+			),
+		);
+
+		$result = $this->sut->export_order_fulfillment_data( $existing, $order );
+
+		// The order's own rows survive and no fulfillment rows are appended from the failed read.
+		$this->assertSame( $existing, $result );
+	}
+
+	/**
+	 * @testdox The order export does not fake a complete export when the fulfillment query errors.
+	 */
+	public function test_export_skips_order_when_the_query_errors(): void {
+		$order = new WC_Order();
+		$order->save();
+
+		// get_results() returns an empty array on a failed query, so mimic that rather than a
+		// throw: the error only shows up in $wpdb->last_error.
+		add_filter(
+			'woocommerce_order-fulfillment_data_store',
+			function () {
+				return new class() extends FulfillmentsDataStore {
+					/**
+					 * Return nothing, leaving a DB error behind as a failed query does.
+					 *
+					 * @param string $entity_type  The entity type.
+					 * @param string $entity_id    The entity ID.
+					 * @param bool   $with_deleted Whether to include deleted fulfillments.
+					 * @return array
+					 */
+					public function read_fulfillments( string $entity_type, string $entity_id, bool $with_deleted = false ): array {
+						global $wpdb;
+						$wpdb->last_error = 'Table \'wp_wc_order_fulfillment_meta\' doesn\'t exist';
+						return array();
+					}
+				};
+			}
+		);
+
+		$existing = array(
+			array(
+				'name'  => 'Order number',
+				'value' => (string) $order->get_id(),
+			),
+		);
+
+		global $wpdb;
+		try {
+			$result = $this->sut->export_order_fulfillment_data( $existing, $order );
+		} finally {
+			$wpdb->last_error = '';
+		}
+
+		$this->assertSame( $existing, $result, 'No fulfillment rows should be appended from an errored read.' );
+	}
+
+	/**
+	 * @testdox The order export includes the customer's fulfillment tracking data.
+	 */
+	public function test_export_includes_fulfillment_tracking_data(): void {
+		list( $order ) = $this->create_order_with_fulfillment(
+			array(
+				'_tracking_number'   => 'TRACK123456',
+				'_tracking_url'      => 'https://example.com/track/TRACK123456',
+				'_shipment_provider' => 'fedex',
+			)
+		);
+
+		$rows = $this->sut->export_order_fulfillment_data( array(), $order );
+
+		$this->assertCount( 1, $rows, 'A fulfillment should add one row, not one row per field.' );
+		$this->assertSame( 'Shipment', $rows[0]['name'] );
+		$this->assertStringContainsString( 'TRACK123456', $rows[0]['value'], 'The export should include the tracking number.' );
+		$this->assertStringContainsString( 'https://example.com/track/TRACK123456', $rows[0]['value'], 'The export should include the tracking URL.' );
+	}
+
+	/**
+	 * @testdox The order export includes the fulfillment status and the items shipped in it.
+	 */
+	public function test_export_includes_status_and_items(): void {
+		list( $order ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'TRACK123456' )
+		);
+
+		$rows = $this->sut->export_order_fulfillment_data( array(), $order );
+
+		$this->assertCount( 1, $rows );
+		$this->assertStringContainsString( 'Status: Fulfilled', $rows[0]['value'], 'The export should include the fulfillment status.' );
+		$this->assertMatchesRegularExpression( '/Items: .+ x 1/', $rows[0]['value'], 'The export should list the items shipped, with their quantity.' );
+	}
+
+	/**
+	 * @testdox The order export includes soft-deleted fulfillments, matching what the eraser clears.
+	 */
+	public function test_export_includes_soft_deleted_fulfillment(): void {
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'TRACK123456' )
+		);
+
+		$datastore = WC_Data_Store::load( 'order-fulfillment' );
+		$datastore->delete( $fulfillment );
+
+		$values = implode( ' ', wp_list_pluck( $this->sut->export_order_fulfillment_data( array(), $order ), 'value' ) );
+		$this->assertStringContainsString( 'TRACK123456', $values, 'A soft-deleted fulfillment should still appear in the export.' );
+	}
+
+	/**
+	 * @testdox The order export leaves the existing rows unchanged for an order without fulfillments.
+	 */
+	public function test_export_leaves_order_without_fulfillments_unchanged(): void {
+		$order    = OrderHelper::create_order();
+		$existing = array(
+			array(
+				'name'  => 'Order Number',
+				'value' => (string) $order->get_id(),
+			),
+		);
+
+		$this->assertSame( $existing, $this->sut->export_order_fulfillment_data( $existing, $order ) );
+	}
+
+	/**
+	 * @testdox Several shipments on one order each add one numbered row.
+	 */
+	public function test_export_numbers_multiple_shipments(): void {
+		$order       = OrderHelper::create_order();
+		$order_items = $order->get_items();
+		$order_item  = reset( $order_items );
+		$item_id     = $order_item ? $order_item->get_id() : 1;
+
+		foreach ( array( 'FIRST111', 'SECOND22' ) as $tracking ) {
+			FulfillmentsHelper::create_fulfillment(
+				array(
+					'entity_id'    => (string) $order->get_id(),
+					'status'       => 'fulfilled',
+					'is_fulfilled' => true,
+				),
+				array(
+					'_items'           => array(
+						array(
+							'item_id' => $item_id,
+							'qty'     => 1,
+						),
+					),
+					'_tracking_number' => $tracking,
+				)
+			);
+		}
+
+		$rows = $this->sut->export_order_fulfillment_data( array(), $order );
+
+		// One row per shipment keeps the accumulated export blob small, which core rewrites
+		// in full on every export page.
+		$this->assertCount( 2, $rows, 'Each shipment should add exactly one row.' );
+		$this->assertSame( array( 'Shipment 1', 'Shipment 2' ), wp_list_pluck( $rows, 'name' ) );
+
+		// read_fulfillments() does not order its query, so assert on the set of values.
+		$values = implode( ' ', wp_list_pluck( $rows, 'value' ) );
+		$this->assertStringContainsString( 'FIRST111', $values );
+		$this->assertStringContainsString( 'SECOND22', $values );
+	}
+
+	/**
+	 * @testdox The order export resolves the shipment provider to its display name, not its slug.
+	 */
+	public function test_export_uses_provider_display_name(): void {
+		list( $order ) = $this->create_order_with_fulfillment(
+			array(
+				'_tracking_number'   => 'TRACK123456',
+				'_shipment_provider' => 'dhl',
+			)
+		);
+
+		// get_provider_display_name() applies this label filter; resolve_provider_name() does not,
+		// so a hit here proves the export resolves the display name rather than the raw slug.
+		add_filter(
+			'woocommerce_fulfillment_shipment_provider_label',
+			function () {
+				return 'DHL Express';
+			}
+		);
+
+		$values = implode( ' ', wp_list_pluck( $this->sut->export_order_fulfillment_data( array(), $order ), 'value' ) );
+		$this->assertStringContainsString( 'DHL Express', $values, 'The export should use the provider display name.' );
+	}
+
+	/**
+	 * @testdox Running the core order exporter attaches the customer's fulfillment rows and excludes other customers.
+	 */
+	public function test_core_order_exporter_includes_fulfillment_rows(): void {
+		$this->sut->register();
+
+		list( $order ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'MINE1111' )
+		);
+
+		$other_order = OrderHelper::create_order();
+		$other_order->set_customer_id( 0 );
+		$other_order->set_billing_email( 'someone-else@example.com' );
+		$other_order->save();
+		FulfillmentsHelper::create_fulfillment(
+			array( 'entity_id' => (string) $other_order->get_id() ),
+			array(
+				'_items'           => array(
+					array(
+						'item_id' => 1,
+						'qty'     => 1,
+					),
+				),
+				'_tracking_number' => 'THEIRS2222',
+			)
+		);
+
+		$export = \WC_Privacy_Exporters::order_data_exporter( $order->get_billing_email(), 1 );
+
+		$values = array();
+		foreach ( $export['data'] as $item ) {
+			$values = array_merge( $values, wp_list_pluck( $item['data'], 'value' ) );
+		}
+		$values = implode( ' ', $values );
+		$this->assertStringContainsString( 'MINE1111', $values, 'The requesting customer tracking number should be exported.' );
+		$this->assertStringNotContainsString( 'THEIRS2222', $values, 'Another customer tracking number must not be exported.' );
+	}
+
+	/**
+	 * @testdox The export adds no rows on a store that never created the fulfillment tables.
+	 */
+	public function test_export_skips_order_when_tables_were_never_created(): void {
+		list( $order ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'TRACK123456' )
+		);
+
+		// The exporter is registered for every store with the feature off, so it has to skip
+		// the ones that never created the tables rather than ask for an absent data store.
+		$original = get_option( 'woocommerce_fulfillments_db_tables_created' );
+		delete_option( 'woocommerce_fulfillments_db_tables_created' );
+		try {
+			$rows = $this->sut->export_order_fulfillment_data( array(), $order );
+		} finally {
+			if ( false !== $original ) {
+				update_option( 'woocommerce_fulfillments_db_tables_created', $original );
+			}
+		}
+
+		$this->assertSame( array(), $rows, 'The export should add no fulfillment rows.' );
+	}
+
+	/**
+	 * @testdox The eraser stops before redacting on a store that never created the fulfillment tables.
+	 */
+	public function test_eraser_skips_redaction_when_tables_were_never_created(): void {
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'TRACK123456' )
+		);
+
+		$filter_ran = false;
+		add_filter(
+			'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+			function ( $meta_keys ) use ( &$filter_ran ) {
+				$filter_ran = true;
+				return $meta_keys;
+			}
+		);
+
+		// The eraser is registered for every store with the feature off, so it has to skip the
+		// ones that never created the tables.
+		$original = get_option( 'woocommerce_fulfillments_db_tables_created' );
+		delete_option( 'woocommerce_fulfillments_db_tables_created' );
+		try {
+			$this->sut->remove_fulfillment_personal_data( $order );
+		} finally {
+			if ( false !== $original ) {
+				update_option( 'woocommerce_fulfillments_db_tables_created', $original );
+			}
+		}
+
+		$this->assertFalse( $filter_ran, 'Redaction should stop before building the query.' );
+		$this->assertSame(
+			'TRACK123456',
+			$this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() )->get_tracking_number(),
+			'The tracking number should be left untouched.'
+		);
+	}
+
+	/**
+	 * @testdox The eraser redacts the tracking number and URL of an active fulfillment.
+	 */
+	public function test_eraser_redacts_active_fulfillment(): void {
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array(
+				'_tracking_number'   => 'TRACK123456',
+				'_tracking_url'      => 'https://example.com/track/TRACK123456',
+				'_shipment_provider' => 'fedex',
+			)
+		);
+
+		$this->sut->remove_fulfillment_personal_data( $order );
+
+		$reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+		$this->assertNull( $reloaded->get_tracking_number(), 'The tracking number should be cleared.' );
+		$this->assertNull( $reloaded->get_tracking_url(), 'The tracking URL should be cleared.' );
+		$this->assertSame( 'fedex', $reloaded->get_shipment_provider(), 'The provider is not personal data and should be retained.' );
+	}
+
+	/**
+	 * @testdox The eraser redacts a soft-deleted fulfillment, whose meta the data store keeps immutable.
+	 */
+	public function test_eraser_redacts_soft_deleted_fulfillment(): void {
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array(
+				'_tracking_number' => 'TRACK123456',
+				'_tracking_url'    => 'https://example.com/track/TRACK123456',
+			)
+		);
+
+		$fulfillment_id = $fulfillment->get_id();
+		$datastore      = WC_Data_Store::load( 'order-fulfillment' );
+		$datastore->delete( $fulfillment );
+
+		$deleted = $this->reload_fulfillment( $order->get_id(), $fulfillment_id );
+		$this->assertNotNull( $deleted->get_date_deleted(), 'The fulfillment should be soft-deleted.' );
+
+		$this->sut->remove_fulfillment_personal_data( $order );
+
+		$reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment_id );
+		$this->assertNull( $reloaded->get_tracking_number(), 'A soft-deleted fulfillment should still be redacted.' );
+		$this->assertNull( $reloaded->get_tracking_url(), 'A soft-deleted fulfillment URL should still be redacted.' );
+	}
+
+	/**
+	 * @testdox The eraser honors the meta-key filter.
+	 */
+	public function test_eraser_respects_meta_keys_filter(): void {
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array(
+				'_tracking_number' => 'TRACK123456',
+				'_tracking_url'    => 'https://example.com/track/TRACK123456',
+			)
+		);
+
+		add_filter(
+			'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+			function () {
+				return array( '_tracking_number' );
+			}
+		);
+
+		$this->sut->remove_fulfillment_personal_data( $order );
+
+		$reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+		$this->assertNull( $reloaded->get_tracking_number(), 'The filtered meta key should be redacted.' );
+		$this->assertSame( 'https://example.com/track/TRACK123456', $reloaded->get_tracking_url(), 'An unfiltered meta key should be retained.' );
+	}
+
+	/**
+	 * @testdox Order anonymization through WC_Privacy_Erasers redacts the fulfillment tracking.
+	 */
+	public function test_order_anonymization_redacts_fulfillment(): void {
+		$this->sut->register();
+
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array(
+				'_tracking_number' => 'TRACK123456',
+				'_tracking_url'    => 'https://example.com/track/TRACK123456',
+			)
+		);
+
+		\WC_Privacy_Erasers::remove_order_personal_data( $order );
+
+		$reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+		$this->assertNull( $reloaded->get_tracking_number(), 'Anonymizing the order should redact the fulfillment tracking number.' );
+	}
+
+	/**
+	 * @testdox The scheduled retention cleanup redacts fulfillment tracking on anonymized orders.
+	 */
+	public function test_retention_cleanup_redacts_fulfillment(): void {
+		$this->sut->register();
+
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array(
+				'_tracking_number' => 'TRACK123456',
+				'_tracking_url'    => 'https://example.com/track/TRACK123456',
+			)
+		);
+
+		// Make the order eligible for the completed-order retention sweep.
+		$order->set_status( 'completed' );
+		$order->set_date_created( time() - 2 * DAY_IN_SECONDS );
+		$order->save();
+
+		$original = get_option( 'woocommerce_anonymize_completed_orders' );
+		update_option(
+			'woocommerce_anonymize_completed_orders',
+			array(
+				'number' => 1,
+				'unit'   => 'days',
+			)
+		);
+
+		\WC_Privacy::anonymize_completed_orders();
+
+		if ( false === $original ) {
+			delete_option( 'woocommerce_anonymize_completed_orders' );
+		} else {
+			update_option( 'woocommerce_anonymize_completed_orders', $original );
+		}
+
+		$reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+		$this->assertNull( $reloaded->get_tracking_number(), 'The retention cleanup should redact the fulfillment tracking number.' );
+	}
+
+	/**
+	 * @testdox The eraser only redacts the fulfillments of the given order.
+	 */
+	public function test_eraser_only_redacts_the_given_order(): void {
+		list( $order_a, $fulfillment_a ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'AAAA1111' )
+		);
+		list( $order_b, $fulfillment_b ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'BBBB2222' )
+		);
+
+		$this->sut->remove_fulfillment_personal_data( $order_a );
+
+		$reloaded_a = $this->reload_fulfillment( $order_a->get_id(), $fulfillment_a->get_id() );
+		$reloaded_b = $this->reload_fulfillment( $order_b->get_id(), $fulfillment_b->get_id() );
+		$this->assertNull( $reloaded_a->get_tracking_number(), 'The erased order should be redacted.' );
+		$this->assertSame( 'BBBB2222', $reloaded_b->get_tracking_number(), 'Another order should be left untouched.' );
+	}
+
+	/**
+	 * @testdox The eraser ignores non-scalar meta keys returned by the filter.
+	 */
+	public function test_eraser_ignores_non_scalar_filter_keys(): void {
+		list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+			array( '_tracking_number' => 'TRACK123456' )
+		);
+
+		add_filter(
+			'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+			function () {
+				return array( '_tracking_number', array( 'unexpected' ) );
+			}
+		);
+
+		$this->sut->remove_fulfillment_personal_data( $order );
+
+		$reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+		$this->assertNull( $reloaded->get_tracking_number(), 'The valid key should be redacted and the non-scalar entry ignored.' );
+	}
+}