Commit 2f3c9ce5af2 for woocommerce

commit 2f3c9ce5af2978ba8591b448a07cc67826ff302d
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Tue Jun 9 13:56:12 2026 +0100

    [RSM] Shopper lists: register GDPR privacy exporter and eraser (#65251)

diff --git a/plugins/woocommerce/changelog/add-shopper-lists-privacy-exporter-eraser b/plugins/woocommerce/changelog/add-shopper-lists-privacy-exporter-eraser
new file mode 100644
index 00000000000..cac67fe0164
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-lists-privacy-exporter-eraser
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Register a GDPR privacy exporter and eraser for shopper lists so admin Export/Erase Personal Data tools cover saved-for-later and wishlist data.
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/Privacy/Privacy.php b/plugins/woocommerce/src/Internal/ShopperLists/Privacy/Privacy.php
new file mode 100644
index 00000000000..3da8ee9019b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/Privacy/Privacy.php
@@ -0,0 +1,196 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists\Privacy;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+
+/**
+ * GDPR/CCPA privacy exporter and eraser for shopper lists.
+ *
+ * @internal Just for internal use.
+ */
+class Privacy extends \WC_Abstract_Privacy {
+
+	/**
+	 * Identifier used to register both the exporter and the eraser with WP.
+	 */
+	private const REGISTRATION_ID = 'woocommerce-shopper-lists';
+
+	/**
+	 * Prefix for the per-list-type WP data group IDs.
+	 */
+	private const GROUP_ID_PREFIX = 'woocommerce-shopper-lists-';
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		parent::__construct();
+
+		add_action( 'init', array( $this, 'register_exporters_and_erasers' ) );
+	}
+
+	/**
+	 * Register the shopper-list exporter and eraser with WordPress.
+	 *
+	 * @internal
+	 */
+	public function register_exporters_and_erasers(): void {
+		$label = __( 'WooCommerce Shopper Lists', 'woocommerce' );
+
+		$this->add_exporter( self::REGISTRATION_ID, $label, array( $this, 'export_data' ) );
+		$this->add_eraser( self::REGISTRATION_ID, $label, array( $this, 'erase_data' ) );
+	}
+
+	/**
+	 * Export every stored shopper list for the user matching the given email.
+	 *
+	 * @internal
+	 *
+	 * @param string $email_address Email address the request applies to.
+	 *
+	 * @return array{data: array<int, array<string, mixed>>, done: bool}
+	 */
+	public function export_data( string $email_address ): array {
+		$user = get_user_by( 'email', $email_address );
+		if ( ! $user instanceof \WP_User ) {
+			return array(
+				'data' => array(),
+				'done' => true,
+			);
+		}
+
+		$user_id    = (int) $user->ID;
+		$controller = wc_get_container()->get( ShopperListsController::class );
+		$data       = array();
+
+		foreach ( $controller->get_supported_slugs() as $slug ) {
+			$list = ShopperList::get_by_slug_raw( $slug, $user_id );
+			if ( ! $list || ! $list->get_items() ) {
+				continue;
+			}
+
+			$group_id    = self::GROUP_ID_PREFIX . $slug;
+			$group_label = sprintf(
+				/* translators: %s: shopper-list slug. */
+				__( 'Shopper List: %s', 'woocommerce' ),
+				$slug
+			);
+			foreach ( $list->get_items() as $item ) {
+				$data[] = array(
+					'group_id'    => $group_id,
+					'group_label' => $group_label,
+					'item_id'     => $item->get_key(),
+					'data'        => self::item_export_rows( $item ),
+				);
+			}
+		}
+
+		return array(
+			'data' => $data,
+			'done' => true,
+		);
+	}
+
+	/**
+	 * Erase every stored shopper list for the user matching the given email.
+	 *
+	 * @internal
+	 *
+	 * @param string $email_address Email address the request applies to.
+	 *
+	 * @return array{items_removed: bool, items_retained: bool, messages: array<int, string>, done: bool}
+	 */
+	public function erase_data( string $email_address ): array {
+		$response = array(
+			'items_removed'  => false,
+			'items_retained' => false,
+			'messages'       => array(),
+			'done'           => true,
+		);
+
+		$user = get_user_by( 'email', $email_address );
+		if ( ! $user instanceof \WP_User ) {
+			return $response;
+		}
+
+		$user_id    = (int) $user->ID;
+		$controller = wc_get_container()->get( ShopperListsController::class );
+
+		foreach ( $controller->get_supported_slugs() as $slug ) {
+			if ( ! Users::delete_site_user_meta( $user_id, ShopperList::META_KEY_PREFIX . $slug ) ) {
+				continue;
+			}
+
+			$response['items_removed'] = true;
+			$response['messages'][]    = sprintf(
+				/* translators: %s: shopper-list slug. */
+				__( 'Shopper List: %s', 'woocommerce' ),
+				$slug
+			);
+		}
+
+		return $response;
+	}
+
+	/**
+	 * Build the per-field `{name, value}` rows for a single saved item.
+	 *
+	 * @param ShopperListItem $item Item to export.
+	 *
+	 * @return array<int, array{name: string, value: string}>
+	 */
+	private static function item_export_rows( ShopperListItem $item ): array {
+		$product = $item->get_product();
+		$title   = ( $item->is_live() && $product instanceof \WC_Product )
+			? $product->get_title()
+			: $item->get_product_title_at_save();
+
+		$rows = array(
+			array(
+				'name'  => __( 'Product ID', 'woocommerce' ),
+				'value' => (string) $item->get_product_id(),
+			),
+			array(
+				'name'  => __( 'Product', 'woocommerce' ),
+				'value' => $title,
+			),
+		);
+
+		if ( $item->get_variation_id() > 0 ) {
+			$rows[]     = array(
+				'name'  => __( 'Variation ID', 'woocommerce' ),
+				'value' => (string) $item->get_variation_id(),
+			);
+			$attributes = wc_get_formatted_variation( $item->get_variation_attributes(), true );
+			if ( '' !== $attributes ) {
+				$rows[] = array(
+					'name'  => __( 'Variation', 'woocommerce' ),
+					'value' => $attributes,
+				);
+			}
+		}
+
+		$rows[] = array(
+			'name'  => __( 'Quantity', 'woocommerce' ),
+			'value' => (string) $item->get_quantity(),
+		);
+		$rows[] = array(
+			'name'  => __( 'Date Added', 'woocommerce' ),
+			'value' => $item->get_date_added_gmt(),
+		);
+
+		if ( $item->is_live() && $product instanceof \WC_Product ) {
+			$rows[] = array(
+				'name'  => __( 'URL', 'woocommerce' ),
+				'value' => $product->get_permalink(),
+			);
+		}
+
+		return $rows;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
index 48fa653dfff..4d01a6e562b 100644
--- a/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
@@ -63,19 +63,33 @@ class ShopperList {
 	}

 	/**
-	 * Load a list by slug. Returns false for any other list that doesn't exist.
+	 * Load a list by slug. Returns false for a disabled, unknown, or unloadable list.
 	 *
-	 * @param string   $slug List identifier.
+	 * @param string   $slug    List identifier.
 	 * @param int|null $user_id Defaults to the current user.
 	 * @return self|false
 	 */
 	public static function get_by_slug( string $slug, ?int $user_id = null ) {
-		// Gate disabled or unknown slugs upfront so previously-persisted lists
-		// don't bypass the feature flag (the Store API surfaces this as 404).
 		if ( ! wc_get_container()->get( ShopperListsController::class )->is_enabled( $slug ) ) {
 			return false;
 		}

+		return self::get_by_slug_raw( $slug, $user_id );
+	}
+
+	/**
+	 * Load a list by slug without the feature-flag gate, for internal callers
+	 * (e.g. privacy export/erase). Do not use for user-facing reads.
+	 *
+	 * @param string   $slug    List identifier.
+	 * @param int|null $user_id Defaults to the current user.
+	 * @return self|false
+	 */
+	public static function get_by_slug_raw( string $slug, ?int $user_id = null ) {
+		if ( ! in_array( $slug, wc_get_container()->get( ShopperListsController::class )->get_supported_slugs(), true ) ) {
+			return false;
+		}
+
 		$user_id = absint( $user_id ? $user_id : get_current_user_id() );
 		if ( ! $user_id ) {
 			return false;
@@ -87,7 +101,6 @@ class ShopperList {
 			return self::from_array( $stored, $user_id );
 		}

-		// In-memory list; saved on the first save().
 		return new self(
 			$user_id,
 			$slug,
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
index 18e403a55ee..48e29b45e5a 100644
--- a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Internal\ShopperLists;

 use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\Internal\ShopperLists\Privacy\Privacy;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

 /**
@@ -66,11 +67,24 @@ final class ShopperListsController implements RegisterHooksInterface {
 	}

 	/**
-	 * Register hooks.
+	 * Slugs of every supported list type, regardless of feature-flag state.
+	 *
+	 * @return string[]
+	 *
+	 * @since 10.9.0
+	 */
+	public function get_supported_slugs(): array {
+		return array_keys( self::SUPPORTED_LISTS );
+	}
+
+	/**
+	 * Register hooks and instantiate sibling services that set up hooks on construction.
 	 */
 	public function register(): void {
 		add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'maybe_flush_rewrite_rules' ), 10, 1 );
 		add_action( 'init', array( $this, 'maybe_register_wishlist_endpoint' ), 5 );
+
+		wc_get_container()->get( Privacy::class );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/ShopperLists/Privacy/PrivacyTests.php b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/Privacy/PrivacyTests.php
new file mode 100644
index 00000000000..1bfdb77cd54
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/Privacy/PrivacyTests.php
@@ -0,0 +1,358 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ShopperLists\Privacy;
+
+use Automattic\WooCommerce\Internal\ShopperLists\Privacy\Privacy;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the shopper-lists privacy exporter and eraser.
+ */
+class PrivacyTests extends WC_Unit_Test_Case {
+
+	private const SAVED_FOR_LATER_SLUG = 'saved-for-later';
+	private const WISHLIST_SLUG        = 'wishlist';
+
+	/**
+	 * Map of shopper-list slug => feature option key.
+	 */
+	private const LIST_OPTIONS = array(
+		self::SAVED_FOR_LATER_SLUG => 'woocommerce_cart_save_for_later_enabled',
+		self::WISHLIST_SLUG        => 'woocommerce_product_wishlist_enabled',
+	);
+
+	private const TEST_EMAIL = 'shopper-privacy@example.com';
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Privacy
+	 */
+	private $sut;
+
+	/**
+	 * @var int
+	 */
+	private $user_id;
+
+	/**
+	 * @var \WC_Product
+	 */
+	private $product;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+			$this->enable_list( $slug );
+		}
+
+		$this->sut = new Privacy();
+
+		$this->user_id = $this->factory->user->create(
+			array(
+				'role'       => 'customer',
+				'user_email' => self::TEST_EMAIL,
+			)
+		);
+
+		$this->product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Privacy SUT Product',
+				'regular_price' => 10.00,
+			)
+		);
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		if ( $this->product ) {
+			$this->product->delete( true );
+		}
+		foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+			$this->disable_list( $slug );
+		}
+		delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Exporter returns no data when the email does not match any user.
+	 */
+	public function test_export_returns_empty_for_unknown_email(): void {
+		$result = $this->sut->export_data( 'nobody@example.com' );
+
+		$this->assertIsArray( $result );
+		$this->assertArrayHasKey( 'data', $result );
+		$this->assertArrayHasKey( 'done', $result );
+		$this->assertSame( array(), $result['data'] );
+		$this->assertTrue( $result['done'] );
+	}
+
+	/**
+	 * @testdox Exporter emits one entry per saved item, scoped to a per-slug group ID.
+	 */
+	public function test_export_emits_one_entry_per_item_with_slug_scoped_group_id(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+		$this->seed_list( self::WISHLIST_SLUG );
+
+		$result = $this->sut->export_data( self::TEST_EMAIL );
+
+		$this->assertCount( 2, $result['data'] );
+
+		$group_ids = array_column( $result['data'], 'group_id' );
+		$this->assertContains( 'woocommerce-shopper-lists-saved-for-later', $group_ids );
+		$this->assertContains( 'woocommerce-shopper-lists-wishlist', $group_ids );
+
+		$labels = array_column( $result['data'], 'group_label', 'group_id' );
+		$this->assertSame( 'Shopper List: saved-for-later', $labels['woocommerce-shopper-lists-saved-for-later'] );
+		$this->assertSame( 'Shopper List: wishlist', $labels['woocommerce-shopper-lists-wishlist'] );
+
+		foreach ( $result['data'] as $entry ) {
+			$this->assertArrayHasKey( 'item_id', $entry );
+			$this->assertArrayHasKey( 'data', $entry );
+			$this->assertNotEmpty( $entry['data'] );
+		}
+	}
+
+	/**
+	 * @testdox Exporter does not emit phantom empty in-memory lists when no meta is stored.
+	 */
+	public function test_export_skips_lists_with_no_stored_items(): void {
+		$result = $this->sut->export_data( self::TEST_EMAIL );
+
+		$this->assertSame( array(), $result['data'] );
+		$this->assertTrue( $result['done'] );
+	}
+
+	/**
+	 * @testdox Exporter surfaces stored data even when the backing feature is disabled.
+	 */
+	public function test_export_surfaces_stored_data_when_feature_is_disabled(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+
+		$this->disable_list( self::SAVED_FOR_LATER_SLUG );
+
+		$result = $this->sut->export_data( self::TEST_EMAIL );
+
+		$this->assertCount( 1, $result['data'] );
+		$this->assertSame( 'woocommerce-shopper-lists-saved-for-later', $result['data'][0]['group_id'] );
+	}
+
+	/**
+	 * @testdox Exporter includes per-field rows for product, quantity, and date for each item.
+	 */
+	public function test_export_includes_per_field_rows_for_each_item(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+
+		$entry = $this->find_first_entry_for_slug(
+			$this->sut->export_data( self::TEST_EMAIL ),
+			self::SAVED_FOR_LATER_SLUG
+		);
+
+		$this->assertSame( (string) $this->product->get_id(), $this->row_value( $entry, 'Product ID' ) );
+		$this->assertSame( 'Privacy SUT Product', $this->row_value( $entry, 'Product' ) );
+		$this->assertSame( '1', $this->row_value( $entry, 'Quantity' ) );
+		$this->assertNotEmpty( $this->row_value( $entry, 'Date Added' ) );
+	}
+
+	/**
+	 * @testdox Exporter uses the live product title when the product still exists.
+	 */
+	public function test_export_uses_live_product_title(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+
+		$this->product->set_name( 'Renamed After Save' );
+		$this->product->save();
+
+		$entry = $this->find_first_entry_for_slug(
+			$this->sut->export_data( self::TEST_EMAIL ),
+			self::SAVED_FOR_LATER_SLUG
+		);
+
+		$this->assertSame( 'Renamed After Save', $this->row_value( $entry, 'Product' ) );
+	}
+
+	/**
+	 * @testdox Exporter falls back to the title snapshot when the product is gone.
+	 */
+	public function test_export_falls_back_to_title_snapshot_when_product_is_deleted(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+
+		$this->product->delete( true );
+		$this->product = null;
+
+		$entry = $this->find_first_entry_for_slug(
+			$this->sut->export_data( self::TEST_EMAIL ),
+			self::SAVED_FOR_LATER_SLUG
+		);
+
+		$this->assertSame( 'Privacy SUT Product', $this->row_value( $entry, 'Product' ) );
+	}
+
+	/**
+	 * @testdox Exporter includes a URL row when the product is publicly accessible.
+	 */
+	public function test_export_includes_url_row_when_product_is_live(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+
+		$entry     = $this->find_first_entry_for_slug(
+			$this->sut->export_data( self::TEST_EMAIL ),
+			self::SAVED_FOR_LATER_SLUG
+		);
+		$permalink = get_permalink( $this->product->get_id() );
+
+		$this->assertIsString( $permalink );
+		$this->assertSame( $permalink, $this->row_value( $entry, 'URL' ) );
+	}
+
+	/**
+	 * @testdox Exporter omits the URL row when the product is not publicly accessible.
+	 */
+	public function test_export_omits_url_row_when_product_is_not_live(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+
+		$this->product->set_status( 'draft' );
+		$this->product->save();
+
+		$entry = $this->find_first_entry_for_slug(
+			$this->sut->export_data( self::TEST_EMAIL ),
+			self::SAVED_FOR_LATER_SLUG
+		);
+
+		$this->assertNull( $this->row_value( $entry, 'URL' ) );
+	}
+
+	/**
+	 * @testdox Eraser is a no-op when the email does not match any user.
+	 */
+	public function test_erase_is_noop_for_unknown_email(): void {
+		$result = $this->sut->erase_data( 'nobody@example.com' );
+
+		$this->assertFalse( $result['items_removed'] );
+		$this->assertFalse( $result['items_retained'] );
+		$this->assertSame( array(), $result['messages'] );
+		$this->assertTrue( $result['done'] );
+	}
+
+	/**
+	 * @testdox Eraser deletes stored shopper-list meta and emits one prefixed message per removed slug.
+	 */
+	public function test_erase_removes_meta_and_emits_message_per_removed_slug(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+		$this->seed_list( self::WISHLIST_SLUG );
+
+		$result = $this->sut->erase_data( self::TEST_EMAIL );
+
+		$this->assertTrue( $result['items_removed'] );
+		$this->assertCount( count( self::LIST_OPTIONS ), $result['messages'] );
+		foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+			$this->assertFalse(
+				is_array( Users::get_site_user_meta( $this->user_id, ShopperList::META_KEY_PREFIX . $slug ) ),
+				"Meta for slug {$slug} should be removed."
+			);
+			$this->assertContains( "Shopper List: {$slug}", $result['messages'], "Eraser message should reference {$slug}." );
+		}
+	}
+
+	/**
+	 * @testdox Eraser reports items_removed false when the matched user has no stored list data.
+	 */
+	public function test_erase_reports_no_removal_when_nothing_is_stored(): void {
+		$result = $this->sut->erase_data( self::TEST_EMAIL );
+
+		$this->assertFalse( $result['items_removed'] );
+		$this->assertSame( array(), $result['messages'] );
+		$this->assertTrue( $result['done'] );
+	}
+
+	/**
+	 * @testdox Eraser removes stored data even when the backing feature is disabled.
+	 */
+	public function test_erase_removes_stored_data_when_feature_is_disabled(): void {
+		$this->seed_list( self::SAVED_FOR_LATER_SLUG );
+
+		$this->disable_list( self::SAVED_FOR_LATER_SLUG );
+
+		$result = $this->sut->erase_data( self::TEST_EMAIL );
+
+		$this->assertTrue( $result['items_removed'] );
+		$this->assertFalse(
+			is_array( Users::get_site_user_meta( $this->user_id, ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG ) )
+		);
+	}
+
+	/**
+	 * Pluck the first exported entry whose `group_id` matches the given slug.
+	 *
+	 * @param array  $result Result from Privacy::export_data().
+	 * @param string $slug   List slug.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function find_first_entry_for_slug( array $result, string $slug ): array {
+		$group_id = 'woocommerce-shopper-lists-' . $slug;
+		foreach ( $result['data'] as $entry ) {
+			if ( $group_id === $entry['group_id'] ) {
+				return $entry;
+			}
+		}
+		$this->fail( "No exported entry for slug {$slug}." );
+	}
+
+	/**
+	 * Return the value of the row with the given name within an exported entry,
+	 * or null if no such row exists.
+	 *
+	 * @param array  $entry     Exported entry returned by find_first_entry_for_slug().
+	 * @param string $row_name  Row name to look up.
+	 */
+	private function row_value( array $entry, string $row_name ): ?string {
+		foreach ( $entry['data'] as $row ) {
+			if ( $row_name === $row['name'] ) {
+				return $row['value'];
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * Seed a stored shopper list of the given slug for the test user.
+	 *
+	 * @param string $slug List slug.
+	 */
+	private function seed_list( string $slug ): void {
+		$list = ShopperList::get_by_slug_raw( $slug, $this->user_id );
+		$this->assertNotFalse( $list );
+		$list->add_item( ShopperListItem::from_product( $this->product->get_id() ) );
+		$list->save();
+	}
+
+	/**
+	 * Enable the feature backing the given shopper-list slug.
+	 *
+	 * @param string $slug List slug.
+	 */
+	private function enable_list( string $slug ): void {
+		update_option( self::LIST_OPTIONS[ $slug ], 'yes' );
+	}
+
+	/**
+	 * Disable the feature backing the given shopper-list slug.
+	 *
+	 * @param string $slug List slug.
+	 */
+	private function disable_list( string $slug ): void {
+		update_option( self::LIST_OPTIONS[ $slug ], 'no' );
+	}
+}