Commit ece28054960 for woocommerce

commit ece280549604ed50b4ec1ed8a470f17e7ea8691f
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Thu Jul 9 08:32:30 2026 +0700

    Improve Store API collection-data count tests (#65962)

    * test: tighten Store API collection data tests

    * fix: unregister globals and clear attribute cache for created attributes during the test

diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
index a4deb5e552d..6524ff25242 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
@@ -14,12 +14,21 @@ use Automattic\WooCommerce\Tests\Blocks\Helpers\ValidateSchema;
  */
 class ProductCollectionData extends ControllerTestCase {

+	/**
+	 * Product attributes created during a test.
+	 *
+	 * @var array<string,int>
+	 */
+	private $created_product_attributes = array();
+
 	/**
 	 * Setup test product data. Called before every test.
 	 */
 	protected function setUp(): void {
 		parent::setUp();

+		$this->created_product_attributes = array();
+
 		$fixtures = new FixtureData();

 		$this->products = array(
@@ -41,6 +50,28 @@ class ProductCollectionData extends ControllerTestCase {
 		$fixtures->add_product_review( $this->products[1]->get_id(), 4 );
 	}

+	/**
+	 * Cleanup test product data. Called after every test.
+	 */
+	protected function tearDown(): void {
+		global $wc_product_attributes;
+
+		foreach ( $this->created_product_attributes as $taxonomy => $attribute_id ) {
+			wc_delete_attribute( $attribute_id );
+
+			if ( taxonomy_exists( $taxonomy ) ) {
+				unregister_taxonomy( $taxonomy );
+			}
+
+			unset( $wc_product_attributes[ $taxonomy ] );
+		}
+
+		delete_transient( 'wc_attribute_taxonomies' );
+		\WC_Cache_Helper::invalidate_cache_group( 'woocommerce-attributes' );
+
+		parent::tearDown();
+	}
+
 	/**
 	 * Test getting items.
 	 */
@@ -81,7 +112,7 @@ class ProductCollectionData extends ControllerTestCase {
 		$product  = $fixtures->get_variable_product(
 			array(),
 			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+				$this->create_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
 			)
 		);
 		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
@@ -292,10 +323,8 @@ class ProductCollectionData extends ControllerTestCase {
 	/**
 	 * @testdox The count array params declare a default maxItems bound to limit query fan-out.
 	 */
-	public function test_count_params_declare_default_max_items() {
-		$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
-		$controller = $routes->get( 'product-collection-data' );
-		$params     = $controller->get_collection_params();
+	public function test_count_params_declare_default_max_items(): void {
+		$params = $this->get_collection_params();

 		$this->assertArrayHasKey( 'maxItems', $params['calculate_attribute_counts'], 'calculate_attribute_counts must be bounded.' );
 		$this->assertArrayHasKey( 'maxItems', $params['calculate_taxonomy_counts'], 'calculate_taxonomy_counts must be bounded.' );
@@ -306,8 +335,7 @@ class ProductCollectionData extends ControllerTestCase {
 	/**
 	 * @testdox An oversized calculate_attribute_counts array is rejected with HTTP 400.
 	 */
-	public function test_calculate_attribute_counts_rejects_oversized_array() {
-		$request  = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+	public function test_calculate_attribute_counts_rejects_oversized_array(): void {
 		$too_many = array_fill(
 			0,
 			26,
@@ -316,9 +344,12 @@ class ProductCollectionData extends ControllerTestCase {
 				'query_type' => 'or',
 			)
 		);
-		$request->set_param( 'calculate_attribute_counts', $too_many );

-		$response = rest_get_server()->dispatch( $request );
+		$response = $this->dispatch_collection_data_request(
+			array(
+				'calculate_attribute_counts' => $too_many,
+			)
+		);

 		$this->assertEquals( 400, $response->get_status(), 'More than 25 attribute-count entries should be rejected.' );
 		$this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
@@ -327,450 +358,463 @@ class ProductCollectionData extends ControllerTestCase {
 	/**
 	 * @testdox An oversized calculate_taxonomy_counts array is rejected with HTTP 400.
 	 */
-	public function test_calculate_taxonomy_counts_rejects_oversized_array() {
-		$request  = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$too_many = array_fill( 0, 26, 'product_cat' );
-		$request->set_param( 'calculate_taxonomy_counts', $too_many );
-
-		$response = rest_get_server()->dispatch( $request );
+	public function test_calculate_taxonomy_counts_rejects_oversized_array(): void {
+		$response = $this->dispatch_collection_data_request(
+			array(
+				'calculate_taxonomy_counts' => array_fill( 0, 26, 'product_cat' ),
+			)
+		);

 		$this->assertEquals( 400, $response->get_status(), 'More than 25 taxonomy-count entries should be rejected.' );
 		$this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
 	}

 	/**
-	 * @testdox An array exactly at the cap is accepted.
+	 * @testdox Count arrays exactly at the cap are accepted.
 	 */
-	public function test_calculate_taxonomy_counts_at_cap_is_accepted() {
-		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$at_cap  = array_fill( 0, 25, 'product_cat' );
-		$request->set_param( 'calculate_taxonomy_counts', $at_cap );
-
-		$response = rest_get_server()->dispatch( $request );
+	public function test_count_arrays_at_cap_are_accepted(): void {
+		$attribute_response = $this->dispatch_collection_data_request(
+			array(
+				'calculate_attribute_counts' => array_fill(
+					0,
+					25,
+					array(
+						'taxonomy'   => 'pa_size',
+						'query_type' => 'or',
+					)
+				),
+			)
+		);
+		$taxonomy_response  = $this->dispatch_collection_data_request(
+			array(
+				'calculate_taxonomy_counts' => array_fill( 0, 25, 'product_cat' ),
+			)
+		);

-		$this->assertEquals( 200, $response->get_status(), 'Exactly 25 entries should be accepted.' );
+		$this->assertEquals( 200, $attribute_response->get_status(), 'Exactly 25 attribute-count entries should be accepted.' );
+		$this->assertEquals( 200, $taxonomy_response->get_status(), 'Exactly 25 taxonomy-count entries should be accepted.' );
 	}

 	/**
-	 * @testdox Repeating the same attribute taxonomy is de-duplicated to a single set of counts.
+	 * @testdox Attribute count requests are normalized and deduplicated before filter data is queried.
 	 */
-	public function test_calculate_attribute_counts_deduplicates_taxonomies() {
-		$fixtures = new FixtureData();
-		$product  = $fixtures->get_variable_product(
-			array(),
-			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
-			)
-		);
-		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+	public function test_calculate_attribute_counts_normalizes_and_deduplicates_taxonomies(): void {
+		$this->create_size_attribute();
+		$calls  = array();
+		$filter = $this->register_filter_data_spy( $calls, array( array( 101 => 2 ) ) );

-		$single_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$single_request->set_param(
-			'calculate_attribute_counts',
-			array(
+		try {
+			$response = $this->dispatch_collection_data_request(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
-				),
-			)
-		);
-		$single = rest_get_server()->dispatch( $single_request )->get_data();
+					'calculate_attribute_counts' => array(
+						array(
+							'taxonomy'   => 'pa_size',
+							'query_type' => 'or',
+						),
+						array(
+							'taxonomy'   => ' pa_size ',
+							'query_type' => 'or',
+						),
+						array(
+							'taxonomy'   => 'PA_SIZE',
+							'query_type' => 'or',
+						),
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_pre_product_filter_data', $filter, 10 );
+		}

-		$repeated_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$repeated_request->set_param(
-			'calculate_attribute_counts',
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame(
 			array(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
-				),
-				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
-				),
-				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
+					'type'     => 'attribute',
+					'taxonomy' => 'pa_size',
 				),
-			)
+			),
+			$calls,
+			'Text variants of the same attribute taxonomy should fan out to one filter-data call.'
 		);
-		$repeated = rest_get_server()->dispatch( $repeated_request )->get_data();
-
-		$this->assertNotEmpty( $single['attribute_counts'], 'Baseline single-taxonomy request should return counts.' );
 		$this->assertEquals(
-			$single['attribute_counts'],
-			$repeated['attribute_counts'],
-			'Requesting the same taxonomy multiple times must not duplicate or alter the counts.'
+			array(
+				(object) array(
+					'term'  => 101,
+					'count' => 2,
+				),
+			),
+			$response->get_data()['attribute_counts']
 		);
 	}

 	/**
-	 * @testdox The same attribute requested with both "or" and "and" query types is counted separately for each type.
+	 * @testdox The same attribute requested with both query types is counted once per query type.
 	 */
-	public function test_calculate_attribute_counts_keeps_query_types_separate() {
-		$fixtures = new FixtureData();
-
-		// Two products with different sizes so that an active filter makes the "or" and "and" counts diverge.
-		$large_product = $fixtures->get_variable_product(
-			array(),
+	public function test_calculate_attribute_counts_keeps_query_types_separate(): void {
+		$this->create_size_attribute();
+		$calls  = array();
+		$filter = $this->register_filter_data_spy(
+			$calls,
 			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+				array( 201 => 3 ),
+				array( 202 => 1 ),
 			)
 		);
-		$fixtures->get_taxonomy_and_term( $large_product, 'pa_size', 'large', 'large' );

-		$small_product = $fixtures->get_variable_product(
-			array(),
-			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
-			)
-		);
-		$fixtures->get_taxonomy_and_term( $small_product, 'pa_size', 'small', 'small' );
-
-		// Shopper has selected "large". "or" counts ignore that selection (faceted what-if counts) while
-		// "and" counts respect it, so the two query types must produce different counts for pa_size.
-		$active_filter = array(
-			array(
-				'attribute' => 'pa_size',
-				'operator'  => 'in',
-				'slug'      => array( 'large' ),
-			),
-		);
-
-		$get_counts = function ( array $entries ) use ( $active_filter ) {
-			$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-			$request->set_param( 'attributes', $active_filter );
-			$request->set_param( 'calculate_attribute_counts', $entries );
-
-			return rest_get_server()->dispatch( $request )->get_data()['attribute_counts'];
-		};
+		try {
+			$response = $this->dispatch_collection_data_request(
+				array(
+					'attributes'                 => array(
+						array(
+							'attribute' => 'pa_size',
+							'operator'  => 'in',
+							'slug'      => array( 'large' ),
+						),
+					),
+					'calculate_attribute_counts' => array(
+						array(
+							'taxonomy'   => 'pa_size',
+							'query_type' => 'or',
+						),
+						array(
+							'taxonomy'   => 'pa_size',
+							'query_type' => 'and',
+						),
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_pre_product_filter_data', $filter, 10 );
+		}

-		$or_only  = $get_counts(
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame(
 			array(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
+					'type'     => 'attribute',
+					'taxonomy' => 'pa_size',
 				),
-			)
-		);
-		$and_only = $get_counts(
-			array(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'and',
+					'type'     => 'attribute',
+					'taxonomy' => 'pa_size',
 				),
-			)
+			),
+			$calls,
+			'The route should keep OR and AND fan-out separate for the same taxonomy.'
 		);
-		$both     = $get_counts(
+		$this->assertEquals(
 			array(
-				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
+				(object) array(
+					'term'  => 201,
+					'count' => 3,
 				),
-				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'and',
+				(object) array(
+					'term'  => 202,
+					'count' => 1,
 				),
-			)
-		);
-
-		$this->assertNotEmpty( $or_only, 'The "or" request should return counts.' );
-		$this->assertNotEmpty( $and_only, 'The "and" request should return counts.' );
-		$this->assertNotEquals(
-			$or_only,
-			$and_only,
-			'For the same taxonomy, "or" and "and" must produce different counts when it is an active filter.'
-		);
-		$this->assertCount(
-			count( $or_only ) + count( $and_only ),
-			$both,
-			'Requesting both query types must keep both result sets, not collapse them into one.'
+			),
+			$response->get_data()['attribute_counts'],
+			'Both query-type result sets should be returned, not merely the right number of rows.'
 		);
 	}

 	/**
-	 * @testdox Attribute taxonomies are normalized before dedup so case and whitespace variants collapse to one entry.
+	 * @testdox Invalid attribute taxonomies are skipped before filter data is queried.
 	 */
-	public function test_calculate_attribute_counts_normalizes_taxonomy_before_dedup() {
-		$fixtures = new FixtureData();
-		$product  = $fixtures->get_variable_product(
-			array(),
-			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
-			)
-		);
-		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+	public function test_calculate_attribute_counts_skips_invalid_taxonomies(): void {
+		$this->create_size_attribute();
+		$calls  = array();
+		$filter = $this->register_filter_data_spy( $calls, array( array( 301 => 4 ) ) );

-		$single_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$single_request->set_param(
-			'calculate_attribute_counts',
+		try {
+			$response = $this->dispatch_collection_data_request(
+				array(
+					'calculate_attribute_counts' => array(
+						array(
+							'taxonomy'   => 'pa_size',
+							'query_type' => 'or',
+						),
+						array(
+							'taxonomy'   => 'product_cat',
+							'query_type' => 'or',
+						),
+						array(
+							'taxonomy'   => 'pa_does_not_exist',
+							'query_type' => 'or',
+						),
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_pre_product_filter_data', $filter, 10 );
+		}
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame(
 			array(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
+					'type'     => 'attribute',
+					'taxonomy' => 'pa_size',
 				),
-			)
+			),
+			$calls,
+			'Non-attribute and missing taxonomies should not reach filter data.'
 		);
-		$single = rest_get_server()->dispatch( $single_request )->get_data();
+	}

-		$variants_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$variants_request->set_param(
-			'calculate_attribute_counts',
+	/**
+	 * @testdox Numeric attribute IDs are resolved before filter data is queried.
+	 */
+	public function test_calculate_attribute_counts_accepts_numeric_attribute_id(): void {
+		$attribute_id = $this->create_size_attribute();
+		$calls        = array();
+		$filter       = $this->register_filter_data_spy( $calls, array( array( 401 => 5 ) ) );
+
+		try {
+			$response = $this->dispatch_collection_data_request(
+				array(
+					'calculate_attribute_counts' => array(
+						array(
+							'taxonomy'   => (string) $attribute_id,
+							'query_type' => 'or',
+						),
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_pre_product_filter_data', $filter, 10 );
+		}
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame(
 			array(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
+					'type'     => 'attribute',
+					'taxonomy' => 'pa_size',
 				),
+			),
+			$calls,
+			'Numeric attribute IDs should resolve to the canonical taxonomy name.'
+		);
+	}
+
+	/**
+	 * @testdox Taxonomy count requests are normalized and filtered before filter data is queried.
+	 */
+	public function test_calculate_taxonomy_counts_normalizes_deduplicates_and_skips_invalid_taxonomies(): void {
+		$calls  = array();
+		$filter = $this->register_filter_data_spy( $calls, array( array( 501 => 6 ) ) );
+
+		try {
+			$response = $this->dispatch_collection_data_request(
 				array(
-					'taxonomy'   => ' pa_size ',
-					'query_type' => 'or',
-				),
+					'calculate_taxonomy_counts' => array(
+						'product_cat',
+						' product_cat ',
+						'PRODUCT_CAT',
+						'does_not_exist_tax',
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_pre_product_filter_data', $filter, 10 );
+		}
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame(
+			array(
 				array(
-					'taxonomy'   => 'PA_SIZE',
-					'query_type' => 'or',
+					'type'     => 'taxonomy',
+					'taxonomy' => 'product_cat',
 				),
-			)
+			),
+			$calls,
+			'Text variants and missing taxonomies should fan out to one taxonomy filter-data call.'
 		);
-		$variants = rest_get_server()->dispatch( $variants_request )->get_data();
-
-		$this->assertNotEmpty( $single['attribute_counts'], 'Baseline single-taxonomy request should return counts.' );
 		$this->assertEquals(
-			$single['attribute_counts'],
-			$variants['attribute_counts'],
-			'Case and whitespace variants of the same taxonomy must be counted once, not duplicated.'
+			array(
+				(object) array(
+					'term'  => 501,
+					'count' => 6,
+				),
+			),
+			$response->get_data()['taxonomy_counts']
 		);
 	}

 	/**
-	 * @testdox Non-attribute and non-existent taxonomies are skipped in calculate_attribute_counts.
+	 * @testdox Attribute counts are computed through the filter-data provider path.
 	 */
-	public function test_calculate_attribute_counts_skips_invalid_taxonomies() {
-		$fixtures = new FixtureData();
-		$product  = $fixtures->get_variable_product(
-			array(),
-			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
-			)
-		);
-		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+	public function test_calculate_attribute_counts_uses_filter_data_provider(): void {
+		$this->create_size_attribute();
+		$calls  = array();
+		$filter = $this->register_filter_data_spy( $calls, array( array( 601 => 7 ) ) );

-		$baseline_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$baseline_request->set_param(
-			'calculate_attribute_counts',
-			array(
+		try {
+			$response = $this->dispatch_collection_data_request(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
-				),
-			)
-		);
-		$baseline = rest_get_server()->dispatch( $baseline_request )->get_data();
+					'calculate_attribute_counts' => array(
+						array(
+							'taxonomy'   => 'pa_size',
+							'query_type' => 'or',
+						),
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_pre_product_filter_data', $filter, 10 );
+		}

-		$with_junk_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$with_junk_request->set_param(
-			'calculate_attribute_counts',
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame(
 			array(
 				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
+					'type'     => 'attribute',
+					'taxonomy' => 'pa_size',
 				),
-				array(
-					// Exists as a taxonomy but is not a product attribute.
-					'taxonomy'   => 'product_cat',
-					'query_type' => 'or',
-				),
-				array(
-					// Not a registered taxonomy at all.
-					'taxonomy'   => 'pa_does_not_exist',
-					'query_type' => 'or',
-				),
-			)
+			),
+			$calls,
+			'Attribute counts should use the shared filter-data provider path.'
 		);
-		$with_junk = rest_get_server()->dispatch( $with_junk_request )->get_data();
-
-		$this->assertNotEmpty( $baseline['attribute_counts'], 'Baseline attribute request should return counts.' );
 		$this->assertEquals(
-			$baseline['attribute_counts'],
-			$with_junk['attribute_counts'],
-			'Non-attribute and non-existent taxonomies must be skipped, not counted.'
+			array(
+				(object) array(
+					'term'  => 601,
+					'count' => 7,
+				),
+			),
+			$response->get_data()['attribute_counts']
 		);
 	}

 	/**
-	 * @testdox A numeric attribute ID resolves to the same counts as its taxonomy name.
+	 * @testdox Repeated attribute-count requests return stable counts.
 	 */
-	public function test_calculate_attribute_counts_accepts_numeric_attribute_id() {
+	public function test_calculate_attribute_counts_stable_across_repeated_requests(): void {
 		$fixtures = new FixtureData();
 		$product  = $fixtures->get_variable_product(
 			array(),
 			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+				$this->create_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
 			)
 		);
 		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );

-		$attribute_id = wc_attribute_taxonomy_id_by_name( 'pa_size' );
-		$this->assertNotEmpty( $attribute_id, 'Test attribute should resolve to an ID.' );
-
-		$by_name_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$by_name_request->set_param(
-			'calculate_attribute_counts',
+		$first  = $this->dispatch_collection_data_request(
 			array(
-				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
+				'calculate_attribute_counts' => array(
+					array(
+						'taxonomy'   => 'pa_size',
+						'query_type' => 'or',
+					),
 				),
 			)
-		);
-		$by_name = rest_get_server()->dispatch( $by_name_request )->get_data();
-
-		$by_id_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$by_id_request->set_param(
-			'calculate_attribute_counts',
+		)->get_data();
+		$second = $this->dispatch_collection_data_request(
 			array(
-				array(
-					'taxonomy'   => (string) $attribute_id,
-					'query_type' => 'or',
+				'calculate_attribute_counts' => array(
+					array(
+						'taxonomy'   => 'pa_size',
+						'query_type' => 'or',
+					),
 				),
 			)
-		);
-		$by_id = rest_get_server()->dispatch( $by_id_request )->get_data();
+		)->get_data();

-		$this->assertNotEmpty( $by_name['attribute_counts'], 'Baseline name-based request should return counts.' );
+		$this->assertNotEmpty( $first['attribute_counts'], 'First request should return counts.' );
 		$this->assertEquals(
-			$by_name['attribute_counts'],
-			$by_id['attribute_counts'],
-			'A numeric attribute ID must resolve to the same counts as its taxonomy name.'
+			$first['attribute_counts'],
+			$second['attribute_counts'],
+			'Repeated identical requests must return identical counts.'
 		);
 	}

 	/**
-	 * @testdox Taxonomies are normalized before dedup so case and whitespace variants collapse to one entry.
+	 * Get collection params for the product collection data route.
+	 *
+	 * @return array
 	 */
-	public function test_calculate_taxonomy_counts_normalizes_taxonomy_before_dedup() {
-		$category = wp_insert_term( 'Normalized Category', 'product_cat' );
-		wp_set_post_terms( $this->products[0]->get_id(), array( $category['term_id'] ), 'product_cat' );
-
-		$single_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$single_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat' ) );
-		$single = rest_get_server()->dispatch( $single_request )->get_data();
-
-		$variants_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$variants_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat', ' product_cat ', 'PRODUCT_CAT' ) );
-		$variants = rest_get_server()->dispatch( $variants_request )->get_data();
+	private function get_collection_params(): array {
+		$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+		$controller = $routes->get( 'product-collection-data' );

-		$this->assertNotEmpty( $single['taxonomy_counts'], 'Baseline single-taxonomy request should return counts.' );
-		$this->assertEquals(
-			$single['taxonomy_counts'],
-			$variants['taxonomy_counts'],
-			'Case and whitespace variants of the same taxonomy must be counted once, not duplicated.'
-		);
+		return $controller->get_collection_params();
 	}

 	/**
-	 * @testdox Non-existent taxonomies are skipped in calculate_taxonomy_counts.
+	 * Dispatch a product collection data request.
+	 *
+	 * @param array $params Request params.
+	 * @return \WP_REST_Response
 	 */
-	public function test_calculate_taxonomy_counts_skips_nonexistent_taxonomies() {
-		$category = wp_insert_term( 'Skip Test Category', 'product_cat' );
-		wp_set_post_terms( $this->products[0]->get_id(), array( $category['term_id'] ), 'product_cat' );
-
-		$baseline_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$baseline_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat' ) );
-		$baseline = rest_get_server()->dispatch( $baseline_request )->get_data();
+	private function dispatch_collection_data_request( array $params ): \WP_REST_Response {
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );

-		$with_junk_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$with_junk_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat', 'does_not_exist_tax', 'another_missing_tax' ) );
-		$with_junk = rest_get_server()->dispatch( $with_junk_request )->get_data();
+		foreach ( $params as $key => $value ) {
+			$request->set_param( $key, $value );
+		}

-		$this->assertNotEmpty( $baseline['taxonomy_counts'], 'Baseline taxonomy request should return counts.' );
-		$this->assertEquals(
-			$baseline['taxonomy_counts'],
-			$with_junk['taxonomy_counts'],
-			'Non-existent taxonomies must be skipped, not counted.'
-		);
+		return rest_get_server()->dispatch( $request );
 	}

 	/**
-	 * @testdox Attribute counts are computed through the cached filter-data path.
+	 * Create a product attribute taxonomy and track it for cleanup.
+	 *
+	 * @param string $raw_name Attribute name.
+	 * @param array  $terms Attribute terms.
+	 * @return array Attribute data and created terms.
 	 */
-	public function test_calculate_attribute_counts_uses_filter_data_cache() {
-		global $wpdb;
-
-		$fixtures = new FixtureData();
-		$product  = $fixtures->get_variable_product(
-			array(),
-			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
-			)
-		);
-		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
-
-		// Clear any filter-data transients left by other requests so this assertion is isolated. The
-		// entry-count counter is excluded so the assertion below proves a real data entry was written.
-		$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_wc_filter_data_%' AND option_name <> '_transient_wc_filter_data_entry_count'" );
+	private function create_product_attribute( string $raw_name, array $terms ): array {
+		$attribute_name = wc_sanitize_taxonomy_name( $raw_name );
+		$existing_id    = wc_attribute_taxonomy_id_by_name( $attribute_name );
+		$attribute      = FixtureData::get_product_attribute( $raw_name, $terms );
+		$attribute_id   = (int) $attribute['attribute_id'];
+		$taxonomy       = $attribute['attribute_taxonomy'];
+
+		if ( ! $existing_id && $attribute_id ) {
+			$this->created_product_attributes[ $taxonomy ] = $attribute_id;
+		}

-		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-		$request->set_param(
-			'calculate_attribute_counts',
-			array(
-				array(
-					'taxonomy'   => 'pa_size',
-					'query_type' => 'or',
-				),
-			)
-		);
-		$response = rest_get_server()->dispatch( $request );
-		$data     = $response->get_data();
+		return $attribute;
+	}

-		$this->assertEquals( 200, $response->get_status() );
-		// The returned counts must be correct, not merely present.
-		$this->assertNotEmpty( $data['attribute_counts'], 'Attribute counts should be returned.' );
+	/**
+	 * Create the size product attribute taxonomy.
+	 *
+	 * @return int
+	 */
+	private function create_size_attribute(): int {
+		$attribute = $this->create_product_attribute( 'size', array( 'small', 'medium', 'large' ) );

-		// A data-cache entry (not just the entry-count counter) must have been written.
-		$cached_entries = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_wc_filter_data_%' AND option_name <> '_transient_wc_filter_data_entry_count'" );
-		$this->assertGreaterThan(
-			0,
-			$cached_entries,
-			'Attribute counts should be written to the shared filter-data cache (proves the cached path is used).'
-		);
+		return (int) $attribute['attribute_id'];
 	}

 	/**
-	 * @testdox Repeated attribute-count requests return stable, correct counts (cache does not corrupt results).
+	 * Register a filter-data spy that returns canned counts.
+	 *
+	 * @param array $calls Captured filter-data calls.
+	 * @param array $results_by_call Count results keyed by call index.
+	 * @return callable
 	 */
-	public function test_calculate_attribute_counts_stable_across_repeated_requests() {
-		$fixtures = new FixtureData();
-		$product  = $fixtures->get_variable_product(
-			array(),
-			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
-			)
-		);
-		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+	private function register_filter_data_spy( array &$calls, array $results_by_call = array() ): callable {
+		$filter = function ( $pre_filter_counts, $type, $_query_vars, $context ) use ( &$calls, $results_by_call ) {
+			if ( ! in_array( $type, array( 'attribute', 'taxonomy' ), true ) || empty( $context['taxonomy'] ) ) {
+				return $pre_filter_counts;
+			}

-		$make_request = function () {
-			$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
-			$request->set_param(
-				'calculate_attribute_counts',
-				array(
-					array(
-						'taxonomy'   => 'pa_size',
-						'query_type' => 'or',
-					),
-				)
+			$calls[]    = array(
+				'type'     => $type,
+				'taxonomy' => $context['taxonomy'],
 			);
-			return rest_get_server()->dispatch( $request )->get_data();
+			$call_index = count( $calls ) - 1;
+
+			return $results_by_call[ $call_index ] ?? array( 1000 + $call_index => $call_index + 1 );
 		};

-		$first  = $make_request();
-		$second = $make_request();
+		add_filter( 'woocommerce_pre_product_filter_data', $filter, 10, 4 );

-		$this->assertNotEmpty( $first['attribute_counts'], 'First request should return counts.' );
-		$this->assertEquals(
-			$first['attribute_counts'],
-			$second['attribute_counts'],
-			'Repeated identical requests must return identical counts.'
-		);
+		return $filter;
 	}

 	/**
@@ -781,7 +825,7 @@ class ProductCollectionData extends ControllerTestCase {
 		$product  = $fixtures->get_variable_product(
 			array(),
 			array(
-				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+				$this->create_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
 			)
 		);