Commit 2ce9208680e for woocommerce

commit 2ce9208680e9298617531ac4d0c56718edd93787
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 1 09:00:06 2026 +0300

    Fix exact terms missing from capped attribute searches (#68098)

    * fix(admin): include exact terms in capped attribute searches

    Classic product attribute searches return a finite window of contains matches. With large taxonomies, configured ordering can place an exact name beyond that window, leaving merchants unable to select it.

    Recover only an eligible exact name omitted by a full result window. Preserve existing ordering when the term is already visible, reuse filtered query constraints, and keep the response cap and final result filter intact.

    Refs #37789

    * chore: add changelog for exact attribute term search

    The capped global attribute search fix changes merchant-visible behavior and needs a plugin changelog entry.

    Record the exact-term selection outcome as a patch-level bug fix without claiming pagination or broader partial-match support.

    Refs #37789

    * refactor(admin): simplify exact attribute term recovery

    Filtered term-query arguments can use arbitrary scalar shapes, which
    made the exact-match fallback rely on numeric coercion and a dense
    guard.

    Accept only integer limits and zero offsets before recovery. Ambiguous
    pagination values now preserve the broad response, while exact lookup
    constraints, deduplication, and response caps remain unchanged.

    Refs #37789

    * refactor(admin): clarify exact attribute term recovery

    The recovery helper protects the AJAX endpoint's untyped public
    filter contract, but each fail-closed guard's rationale was implicit.

    The unsupported-shape provider supplied only a discriminator. Its setup
    and expectations lived in separate switches, so cases could drift.

    Document why each guard exists and make provider rows own their mutation,
    projection, expected result, and failure message. Keep shared execution
    and the zero-fallback invariant in the test body.

    Refs #37789

    * refactor(admin): return earlier from exact term recovery

    Exact-term recovery must reject unsupported filtered shapes without
    changing the broad response.

    Some decisive rejections still flowed through cap normalization and
    compound guards, obscuring control flow and doing avoidable work.

    Return immediately for empty inputs, unsupported offsets, and cap types.
    Keep the full-window check ahead of the term scan and preserve the single
    bounded fallback query.

    Refs #37789

    * chore(phpstan): remove obsolete AJAX suppression

    The exact-term recovery helper changed the inferred type flowing into the
    final attribute-term results filter, so PHPStan no longer reports the
    previously baselined parameter mismatch.

    Remove the unmatched suppression so full-project analysis can enforce the
    baseline without failing CI. This has no runtime behavior impact.

    Refs #37789

    * fix(admin): reject foreign taxonomy exact term matches

    The exact attribute-term fallback runs through public term-query hooks. A pre_get_terms callback can redirect that lookup after its arguments have been validated.

    Reject exact terms whose taxonomy differs from the requested attribute. This keeps the broad response unchanged while preserving existing hook behavior and duplicate handling.

    Refs #37789

    * test(admin): enable strict types for AJAX tests

    The legacy AJAX test file predates WooCommerce's strict-types requirement. Adding typed regression coverage left the file inconsistent with current test conventions and full-file PHPCS.

    Enable strict scalar typing after the existing file docblock. The complete AJAX test class remains compatible under strict mode.

    Refs #37789

    * fix(admin): accept the default empty offset in term recovery

    Exact attribute-term recovery runs only for the first result window,
    so it checks the filtered offset before replacing the broad selector.

    The allow-list accepted only int 0 and string '0'. WP_Term_Query
    documents its own no-offset default as an empty string and normalizes
    it with absint(), so a callback mirroring core's default shape passed
    a value the query treats as offset 0 while the guard rejected it. The
    recovery then skipped silently and the exact term stayed missing.

    Accept the empty string alongside the existing zero forms. A malformed
    '0.0' offset stays rejected, keeping the fail-closed read for input no
    caller intends.

    Refs #37789

    * docs(admin): document the term search filter error result

    get_terms() returns a WP_Error for an invalid taxonomy, and the
    attribute-term AJAX endpoint passes that result straight into
    woocommerce_json_search_found_product_attribute_terms. The hook
    docblock has always described $terms as an array only.

    PHPStan tracked the mismatch through a baseline entry until the
    recovery helper widened the inferred type and the entry stopped
    matching, so removing it left the inaccurate docblock untracked.

    Describe the parameter as array|WP_Error, matching what
    VisualAttributeTermAdmin::add_visual_data_to_attribute_terms already
    documents for the same hook.

    Refs #37789

    * test(admin): cover the filtered name selector guard

    Exact-term recovery refuses to replace the broad selector when a
    filter already set its own name argument, but no provider case
    exercised that guard, so removing it went undetected.

    Add a competing filtered name row to the unsupported argument shapes
    provider. Without the guard the recovery overwrites the filtered name
    array with the search text and issues the bounded exact-name query,
    which the row's exact-query assertion now catches.

    Refs #37789

diff --git a/plugins/woocommerce/changelog/fix-37789-exact-attribute-term-search b/plugins/woocommerce/changelog/fix-37789-exact-attribute-term-search
new file mode 100644
index 00000000000..a0824db3297
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-37789-exact-attribute-term-search
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure exact global attribute terms remain selectable when partial matches fill the search results.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index f9c73389b87..b9b0396d27f 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -2437,18 +2437,128 @@ class WC_AJAX {
 		 * @since 3.4.0
 		 * @param array $args The search arguments.
 		 */
-		$terms = get_terms( apply_filters( 'woocommerce_product_attribute_terms', $args ) );
+		$args  = apply_filters( 'woocommerce_product_attribute_terms', $args );
+		$terms = get_terms( $args );
+		$terms = self::maybe_include_exact_taxonomy_term( $terms, $args, $search_text, $taxonomy );

 		/**
 		 * Filter the product attribute terms search results.
 		 *
 		 * @since 7.0.0
-		 * @param array  $terms    The list of matched terms.
-		 * @param string $taxonomy The terms taxonomy.
+		 * @param array|WP_Error $terms    The list of matched terms, or a term query error.
+		 * @param string         $taxonomy The terms taxonomy.
 		 */
 		wp_send_json( apply_filters( 'woocommerce_json_search_found_product_attribute_terms', $terms, $taxonomy ) );
 	}

+	/**
+	 * Include an exact taxonomy term match omitted by a full broad result set.
+	 *
+	 * @param mixed $terms       The broad search results.
+	 * @param mixed $args        The filtered broad search arguments.
+	 * @param mixed $search_text The requested search text.
+	 * @param mixed $taxonomy    The requested taxonomy.
+	 * @return mixed
+	 */
+	private static function maybe_include_exact_taxonomy_term( $terms, $args, $search_text, $taxonomy ) {
+		// Public filters may change argument and result shapes, so compose only the expected representations.
+		if ( ! is_array( $args ) || ! is_array( $terms ) || ! is_string( $search_text ) || ! is_string( $taxonomy ) ) {
+			return $terms;
+		}
+
+		// An empty request or broad response cannot hide an exact term beyond a positive cap.
+		if ( '' === $search_text || empty( $terms ) ) {
+			return $terms;
+		}
+
+		$filtered_offset = $args['offset'] ?? 0;
+
+		// Recovery applies only to the first result window. WP_Term_Query defaults the offset to an empty string.
+		if ( ! in_array( $filtered_offset, array( 0, '0', '' ), true ) ) {
+			return $terms;
+		}
+
+		$filtered_number = $args['number'] ?? null;
+
+		// Public filters may replace the cap with an unsupported type, which should fail closed without coercion.
+		if ( ! is_int( $filtered_number ) && ! is_string( $filtered_number ) ) {
+			return $terms;
+		}
+
+		$number = filter_var( $filtered_number, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) );
+
+		// Recovery applies only when a positive finite cap is completely filled.
+		if ( false === $number || count( $terms ) !== $number ) {
+			return $terms;
+		}
+
+		// Replacing the broad selector is safe only for the standard query shape.
+		if (
+			'all' !== ( $args['fields'] ?? null ) ||
+			( $args['taxonomy'] ?? null ) !== $taxonomy ||
+			( $args['name__like'] ?? null ) !== $search_text ||
+			array_key_exists( 'name', $args ) ||
+			array_key_exists( 'search', $args )
+		) {
+			return $terms;
+		}
+
+		// Exact recovery is limited to nonhierarchical global attributes.
+		if ( ! taxonomy_is_product_attribute( $taxonomy ) || is_taxonomy_hierarchical( $taxonomy ) ) {
+			return $terms;
+		}
+
+		$term_ids = array();
+		foreach ( $terms as $term ) {
+			// A non-term result cannot be safely combined with an exact term object.
+			if ( ! $term instanceof WP_Term ) {
+				return $terms;
+			}
+
+			// Keep an already-visible exact term in its configured position.
+			if ( $search_text === $term->name ) {
+				return $terms;
+			}
+
+			$term_ids[] = (int) $term->term_id;
+		}
+
+		// Preserve filtered eligibility constraints while replacing only the broad selector and bounding the lookup.
+		$exact_args = $args;
+		unset( $exact_args['name__like'] );
+		$exact_args['name']    = $search_text;
+		$exact_args['fields']  = 'all';
+		$exact_args['number']  = 1;
+		$exact_args['offset']  = 0;
+		$exact_args['orderby'] = 'none';
+
+		$exact_terms = get_terms( $exact_args );
+		// Query hooks may return an error or alter the exact-query result shape.
+		if ( ! is_array( $exact_terms ) || 1 !== count( $exact_terms ) ) {
+			return $terms;
+		}
+
+		$exact_term = reset( $exact_terms );
+		// A non-term exact result cannot be safely combined with the broad term objects.
+		if ( ! $exact_term instanceof WP_Term ) {
+			return $terms;
+		}
+
+		// Query hooks may change the taxonomy after the exact-query arguments are validated.
+		if ( $taxonomy !== $exact_term->taxonomy ) {
+			return $terms;
+		}
+
+		// Database collation may resolve to an already-visible case- or accent-equivalent term.
+		if ( in_array( (int) $exact_term->term_id, $term_ids, true ) ) {
+			return $terms;
+		}
+
+		array_unshift( $terms, $exact_term );
+
+		return array_slice( $terms, 0, $number );
+	}
+
 	/**
 	 * Search for product attributes and return json.
 	 *
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index f4fefdc105b..0048087e7b9 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -8256,12 +8256,6 @@ parameters:
 			count: 1
 			path: includes/admin/woocommerce-legacy-reports.php

-		-
-			message: '#^@param array \$terms does not accept actual type of parameter\: array\<int, int\|string\|WP_Term\>\|numeric\-string\|WP_Error\.$#'
-			identifier: parameter.phpDocType
-			count: 1
-			path: includes/class-wc-ajax.php
-
 		-
 			message: '#^@param string \$search_text does not accept actual type of parameter\: array\|string\.$#'
 			identifier: parameter.phpDocType
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 705255dc4c1..a650c71fd3e 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -5,6 +5,8 @@
  * @package WooCommerce\Tests\WC_AJAX.
  */

+declare( strict_types = 1 );
+
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\Orders\CouponsController;
 use Automattic\WooCommerce\Internal\Orders\TaxesController;
@@ -303,6 +305,564 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		}//end try
 	}

+	/**
+	 * @testdox Should include an exact taxonomy term match beyond the result limit.
+	 */
+	public function test_json_search_taxonomy_terms_includes_exact_name_beyond_limit(): void {
+		$fixture = null;
+
+		try {
+			$term_names = array();
+			for ( $index = 0; $index < 50; ++$index ) {
+				$term_names[] = sprintf( 'Candidate 6 %02d', $index );
+			}
+			$term_names[] = '6';
+
+			$fixture       = $this->create_attribute_taxonomy_fixture_for_test( $term_names );
+			$exact_term_id = $fixture['term_ids']['6'];
+
+			$filter_call_count = 0;
+			$filter_taxonomy   = null;
+			$filter_saw_exact  = false;
+			$filter_callback   = function ( $terms, $taxonomy ) use ( &$filter_call_count, &$filter_taxonomy, &$filter_saw_exact, $exact_term_id ) {
+				++$filter_call_count;
+				$filter_taxonomy  = $taxonomy;
+				$filter_saw_exact = in_array( $exact_term_id, wp_list_pluck( $terms, 'term_id' ), true );
+
+				return $terms;
+			};
+
+			add_filter( 'woocommerce_json_search_found_product_attribute_terms', $filter_callback, 20, 2 );
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], '6', $exact_query_count );
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], '6', 50, 'menu_order' );
+
+			$this->assertCount( 50, $response, 'The response should respect the requested result limit.' );
+			$this->assertCount( 50, array_unique( wp_list_pluck( $response, 'term_id' ) ), 'The response should not contain duplicate terms.' );
+			$this->assertSame( $exact_term_id, $response[0]['term_id'], 'The exact term match should be the first response item.' );
+			$this->assertSame( 1, $filter_call_count, 'The final results filter should run once.' );
+			$this->assertTrue( $filter_saw_exact, 'The final results filter should receive the exact term match.' );
+			$this->assertSame( $fixture['taxonomy'], $filter_taxonomy, 'The final results filter should receive the requested taxonomy unchanged.' );
+			$this->assertSame( 1, $exact_query_count, 'The omitted exact match should trigger one bounded exact-name term query.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should include an exact taxonomy term match when a filter supplies the default empty offset.
+	 */
+	public function test_json_search_taxonomy_terms_includes_exact_name_with_empty_offset(): void {
+		$fixture = null;
+
+		try {
+			$fixture = $this->create_attribute_taxonomy_fixture_for_test(
+				array(
+					'Candidate 6 00',
+					'Candidate 6 01',
+					'Candidate 6 02',
+					'6',
+				)
+			);
+
+			// WP_Term_Query documents an empty string as its own "no offset" default.
+			add_filter(
+				'woocommerce_product_attribute_terms',
+				static function ( $args ) {
+					$args['offset'] = '';
+
+					return $args;
+				}
+			);
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], '6', $exact_query_count );
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], '6', 3, 'menu_order' );
+
+			$this->assertSame(
+				array( '6', 'Candidate 6 00', 'Candidate 6 01' ),
+				wp_list_pluck( $response, 'name' ),
+				'An empty filtered offset should still recover the omitted exact match.'
+			);
+			$this->assertSame( 1, $exact_query_count, 'An empty filtered offset should trigger one bounded exact-name term query.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should preserve the ordering of a visible exact taxonomy term match.
+	 */
+	public function test_json_search_taxonomy_terms_does_not_promote_visible_exact_name(): void {
+		$fixture = null;
+
+		try {
+			$fixture = $this->create_attribute_taxonomy_fixture_for_test(
+				array(
+					'Alpha candidate first',
+					'Alpha',
+					'Alpha candidate third',
+					'Alpha candidate fourth',
+				)
+			);
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], 'Alpha', $exact_query_count );
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], 'Alpha', 3, 'menu_order' );
+
+			$this->assertSame(
+				array( 'Alpha candidate first', 'Alpha', 'Alpha candidate third' ),
+				wp_list_pluck( $response, 'name' ),
+				'The visible exact match should retain its menu order position.'
+			);
+			$this->assertCount(
+				3,
+				array_unique( wp_list_pluck( $response, 'term_id' ) ),
+				'The response should contain three unique term IDs.'
+			);
+			$this->assertSame( 0, $exact_query_count, 'A byte-identical visible exact match should not trigger a fallback term query.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should preserve the ordering of a visible database-equivalent exact taxonomy term match.
+	 */
+	public function test_json_search_taxonomy_terms_deduplicates_visible_collation_equivalent_name(): void {
+		$fixture = null;
+
+		try {
+			$fixture = $this->create_attribute_taxonomy_fixture_for_test(
+				array(
+					'Alpha candidate first',
+					'Álpha',
+					'Alpha candidate third',
+					'Alpha candidate fourth',
+				)
+			);
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], 'alpha', $exact_query_count );
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], 'alpha', 3, 'menu_order' );
+
+			$this->assertSame(
+				array( 'Alpha candidate first', 'Álpha', 'Alpha candidate third' ),
+				wp_list_pluck( $response, 'name' ),
+				'The database-equivalent visible exact match should retain its menu order position.'
+			);
+			$this->assertCount( 3, array_unique( wp_list_pluck( $response, 'term_id' ) ), 'The response should contain three unique term IDs.' );
+			$this->assertSame( 1, $exact_query_count, 'The database-authoritative comparison should use one bounded exact-name term query.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should respect exclusion of an exact taxonomy term match through the query arguments filter.
+	 */
+	public function test_json_search_taxonomy_terms_respects_filtered_exact_term_exclusion(): void {
+		$fixture = null;
+
+		try {
+			$fixture = $this->create_attribute_taxonomy_fixture_for_test(
+				array(
+					'Candidate 6 first',
+					'Candidate 6 second',
+					'Candidate 6 third',
+					'6',
+				)
+			);
+
+			$exact_term_id     = $fixture['term_ids']['6'];
+			$filter_call_count = 0;
+			$filter_callback   = function ( $args ) use ( &$filter_call_count, $exact_term_id ) {
+				++$filter_call_count;
+				$args['exclude'] = array( $exact_term_id );
+
+				return $args;
+			};
+
+			add_filter( 'woocommerce_product_attribute_terms', $filter_callback );
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], '6', $exact_query_count );
+
+			$response     = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], '6', 3, 'menu_order' );
+			$response_ids = wp_list_pluck( $response, 'term_id' );
+
+			$this->assertCount( 3, $response, 'The response should contain the requested number of terms.' );
+			$this->assertNotContains( $exact_term_id, $response_ids, 'The excluded exact term should not appear in the response.' );
+			$this->assertSame( 1, $filter_call_count, 'The product attribute term query arguments filter should run exactly once.' );
+			$this->assertSame( 1, $exact_query_count, 'The full supported response should perform at most one exact-name term query.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should preserve a full broad response when no exact taxonomy term exists.
+	 */
+	public function test_json_search_taxonomy_terms_preserves_full_response_without_exact_name(): void {
+		$fixture = null;
+
+		try {
+			$fixture = $this->create_attribute_taxonomy_fixture_for_test(
+				array(
+					'Candidate 6 first',
+					'Candidate 6 second',
+					'Candidate 6 third',
+				)
+			);
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], '6', $exact_query_count );
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], '6', 3, 'menu_order' );
+
+			$this->assertSame(
+				array( 'Candidate 6 first', 'Candidate 6 second', 'Candidate 6 third' ),
+				wp_list_pluck( $response, 'name' ),
+				'The broad result should remain unchanged when no exact term exists.'
+			);
+			$this->assertSame( 1, $exact_query_count, 'A full supported response should perform only one bounded exact-name term query.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should reject an exact taxonomy term returned from another taxonomy.
+	 */
+	public function test_json_search_taxonomy_terms_rejects_exact_name_from_other_taxonomy(): void {
+		$requested_fixture = null;
+		$foreign_fixture   = null;
+
+		try {
+			$term_names = array(
+				'Candidate 6 first',
+				'Candidate 6 second',
+				'Candidate 6 third',
+			);
+
+			$requested_fixture = $this->create_attribute_taxonomy_fixture_for_test( $term_names );
+			$foreign_fixture   = $this->create_attribute_taxonomy_fixture_for_test( array( '6' ) );
+
+			add_action(
+				'pre_get_terms',
+				static function ( $query ) use ( $requested_fixture, $foreign_fixture ) {
+					$query_taxonomies = (array) ( $query->query_vars['taxonomy'] ?? array() );
+					$query_names      = (array) ( $query->query_vars['name'] ?? array() );
+
+					if ( in_array( $requested_fixture['taxonomy'], $query_taxonomies, true ) && in_array( '6', $query_names, true ) ) {
+						$query->query_vars['taxonomy'] = array( $foreign_fixture['taxonomy'] );
+					}
+				}
+			);
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $requested_fixture['taxonomy'], '6', 3, 'menu_order' );
+
+			$this->assertSame( $term_names, wp_list_pluck( $response, 'name' ), 'A foreign exact term should not displace the requested taxonomy results.' );
+			$this->assertNotContains( $foreign_fixture['term_ids']['6'], wp_list_pluck( $response, 'term_id' ), 'The response should not contain a term from another taxonomy.' );
+		} finally {
+			if ( null !== $foreign_fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $foreign_fixture );
+			}
+
+			if ( null !== $requested_fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $requested_fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should treat the search string zero as a valid exact taxonomy term name.
+	 */
+	public function test_json_search_taxonomy_terms_includes_exact_zero_name(): void {
+		$fixture = null;
+
+		try {
+			$fixture = $this->create_attribute_taxonomy_fixture_for_test(
+				array(
+					'Candidate 0 first',
+					'Candidate 0 second',
+					'Candidate 0 third',
+					'0',
+				)
+			);
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], '0', 3, 'menu_order' );
+
+			$this->assertCount( 3, $response, 'The response should retain its configured cap.' );
+			$this->assertSame( $fixture['term_ids']['0'], $response[0]['term_id'], 'The exact zero-named term should be the first result.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should leave unsupported filtered taxonomy search argument shapes unchanged.
+	 *
+	 * @dataProvider unsupported_taxonomy_term_search_argument_provider
+	 *
+	 * @param Closure $filter_callback         Applies the unsupported filtered argument shape.
+	 * @param Closure $project_response        Projects the AJAX response into the value under assertion.
+	 * @param Closure $resolve_expected        Resolves the expected value from the runtime fixture.
+	 * @param string  $response_assertion_text Explains the expected pass-through behavior.
+	 */
+	public function test_json_search_taxonomy_terms_leaves_unsupported_filtered_shapes_unchanged( Closure $filter_callback, Closure $project_response, Closure $resolve_expected, string $response_assertion_text ): void {
+		$fixture = null;
+
+		try {
+			$term_names = array(
+				'Candidate 6 00',
+				'Candidate 6 01',
+				'Candidate 6 02',
+				'Candidate 6 03',
+				'Candidate 6 04',
+				'6',
+			);
+			$fixture    = $this->create_attribute_taxonomy_fixture_for_test( $term_names );
+
+			add_filter(
+				'woocommerce_product_attribute_terms',
+				$filter_callback
+			);
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], '6', $exact_query_count );
+
+			$response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], '6', 3, 'menu_order' );
+
+			$this->assertSame( 0, $exact_query_count, 'Unsupported filtered argument shapes should not trigger the exact-name term query.' );
+			$this->assertSame(
+				$resolve_expected(
+					array(
+						'fixture'    => $fixture,
+						'term_names' => $term_names,
+					)
+				),
+				$project_response( $response ),
+				$response_assertion_text
+			);
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * Unsupported filtered taxonomy search argument shapes.
+	 *
+	 * @return array<string, array{Closure, Closure, Closure, string}>
+	 */
+	public function unsupported_taxonomy_term_search_argument_provider(): array {
+		$pluck_names = static fn( $response ) => wp_list_pluck( $response, 'name' );
+		$unchanged   = static fn( $response ) => $response;
+
+		$first_three_names = static fn( $context ) => array_slice( $context['term_names'], 0, 3 );
+		$offset_names      = static fn( $context ) => array_slice( $context['term_names'], 1, 3 );
+		$all_names         = static fn( $context ) => $context['term_names'];
+		$first_three_ids   = static fn( $context ) => array_slice( array_values( $context['fixture']['term_ids'] ), 0, 3 );
+
+		return array(
+			'string arguments' => array(
+				static fn( $args ) => http_build_query( $args ),
+				$pluck_names,
+				$first_three_names,
+				'A string argument shape should retain the broad response.',
+			),
+			'alternate fields' => array(
+				static function ( $args ) {
+					$args['fields'] = 'ids';
+
+					return $args;
+				},
+				$unchanged,
+				$first_three_ids,
+				'Alternate field shapes should pass through unchanged.',
+			),
+			'taxonomy array'   => array(
+				static function ( $args ) {
+					$args['taxonomy'] = array( $args['taxonomy'] );
+
+					return $args;
+				},
+				$pluck_names,
+				$first_three_names,
+				'A taxonomy array should retain the broad response.',
+			),
+			'nonzero offset'   => array(
+				static function ( $args ) {
+					$args['offset'] = 1;
+
+					return $args;
+				},
+				$pluck_names,
+				$offset_names,
+				'A nonzero offset should retain the requested broad window.',
+			),
+			'absent limit'     => array(
+				static function ( $args ) {
+					unset( $args['number'] );
+
+					return $args;
+				},
+				$pluck_names,
+				$all_names,
+				'An absent limit should retain the unbounded broad response.',
+			),
+			'non-finite limit' => array(
+				static function ( $args ) {
+					$args['number'] = 'INF';
+
+					return $args;
+				},
+				$pluck_names,
+				$all_names,
+				'A non-finite limit should retain the unbounded broad response.',
+			),
+			'decimal limit'    => array(
+				static function ( $args ) {
+					$args['number'] = '3.5';
+
+					return $args;
+				},
+				$pluck_names,
+				$first_three_names,
+				'A decimal limit should retain the broad response.',
+			),
+			'decimal offset'   => array(
+				static function ( $args ) {
+					$args['offset'] = '0.0';
+
+					return $args;
+				},
+				$pluck_names,
+				$first_three_names,
+				'A decimal offset should retain the broad response.',
+			),
+			'term query error' => array(
+				static function ( $args ) {
+					$args['taxonomy'] = 'not_a_registered_taxonomy';
+
+					return $args;
+				},
+				static fn( $response ) => isset( $response['errors']['invalid_taxonomy'] ),
+				static fn() => true,
+				'A term-query error should pass through unchanged.',
+			),
+			'competing search' => array(
+				static function ( $args ) {
+					$args['search'] = 'Candidate';
+
+					return $args;
+				},
+				$pluck_names,
+				$first_three_names,
+				'A competing filtered search selector should retain the broad response.',
+			),
+			'competing name'   => array(
+				static function ( $args ) {
+					$args['name'] = array(
+						'Candidate 6 00',
+						'Candidate 6 01',
+						'Candidate 6 02',
+					);
+
+					return $args;
+				},
+				$pluck_names,
+				$first_three_names,
+				'A competing filtered name selector should retain the broad response.',
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should leave an empty taxonomy search and hierarchical attribute taxonomy unchanged.
+	 */
+	public function test_json_search_taxonomy_terms_skips_empty_and_hierarchical_searches(): void {
+		$fixture = null;
+
+		try {
+			$term_names = array( 'Alpha first', 'Alpha second', 'Alpha third', 'Alpha' );
+			$fixture    = $this->create_attribute_taxonomy_fixture_for_test( $term_names );
+
+			$empty_response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], '', 3, 'menu_order' );
+			$this->assertSame( array_slice( $term_names, 0, 3 ), wp_list_pluck( $empty_response, 'name' ), 'An empty search should retain the broad response.' );
+
+			unregister_taxonomy( $fixture['taxonomy'] );
+			register_taxonomy(
+				$fixture['taxonomy'],
+				array( 'product' ),
+				array(
+					'hierarchical' => true,
+					'capabilities' => array(
+						'manage_terms' => 'manage_product_terms',
+						'edit_terms'   => 'edit_product_terms',
+						'delete_terms' => 'delete_product_terms',
+						'assign_terms' => 'assign_product_terms',
+					),
+				)
+			);
+
+			$exact_query_count = 0;
+			$this->track_exact_taxonomy_term_queries_for_test( $fixture['taxonomy'], 'Alpha', $exact_query_count );
+			$hierarchical_response = $this->search_taxonomy_terms_via_ajax_for_test( $fixture['taxonomy'], 'Alpha', 3, 'menu_order' );
+
+			$this->assertSame( array_slice( $term_names, 0, 3 ), wp_list_pluck( $hierarchical_response, 'name' ), 'A hierarchical attribute taxonomy should retain the broad response.' );
+			$this->assertSame( 0, $exact_query_count, 'A hierarchical attribute taxonomy should not trigger an exact-name term query.' );
+		} finally {
+			if ( null !== $fixture ) {
+				$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			}
+		}
+	}
+
+	/**
+	 * Count exact-name term queries for a taxonomy during a test.
+	 *
+	 * The parent test fixture restores ordinary hooks after each test.
+	 *
+	 * @param string $taxonomy         Taxonomy to observe.
+	 * @param string $name             Exact name to observe.
+	 * @param int    $exact_query_count Exact-query counter, passed by reference.
+	 */
+	private function track_exact_taxonomy_term_queries_for_test( string $taxonomy, string $name, int &$exact_query_count ): void {
+		add_action(
+			'pre_get_terms',
+			function ( $query ) use ( $taxonomy, $name, &$exact_query_count ) {
+				$query_taxonomies = (array) ( $query->query_vars['taxonomy'] ?? array() );
+				$query_names      = (array) ( $query->query_vars['name'] ?? array() );
+
+				if ( in_array( $taxonomy, $query_taxonomies, true ) && in_array( $name, $query_names, true ) ) {
+					++$exact_query_count;
+				}
+			}
+		);
+	}
+
 	/**
 	 * Register a product attribute taxonomy created inside a test.
 	 *
@@ -333,6 +893,111 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		return $taxonomy;
 	}

+	/**
+	 * Create a global product attribute and ordered terms for a test.
+	 *
+	 * @param string[] $term_names Term names in menu order.
+	 * @return array{taxonomy: string, term_ids: array<array-key, int>}
+	 */
+	private function create_attribute_taxonomy_fixture_for_test( array $term_names ): array {
+		$fixture = array(
+			'taxonomy' => '',
+			'term_ids' => array(),
+		);
+
+		try {
+			$suffix       = wp_unique_id();
+			$attribute_id = wc_create_attribute(
+				array(
+					'name'     => 'AJAX search fixture ' . $suffix,
+					'slug'     => 'ajax_search_' . $suffix,
+					'type'     => 'select',
+					'order_by' => 'menu_order',
+				)
+			);
+
+			if ( ! is_int( $attribute_id ) ) {
+				throw new RuntimeException( 'The product attribute fixture could not be created.' );
+			}
+
+			$fixture['taxonomy'] = $this->register_attribute_taxonomy_for_test( $attribute_id );
+
+			foreach ( $term_names as $menu_order => $term_name ) {
+				$term = wp_insert_term( $term_name, $fixture['taxonomy'] );
+
+				if ( is_wp_error( $term ) ) {
+					throw new RuntimeException( 'A product attribute term fixture could not be created.' );
+				}
+
+				$term_id                           = (int) $term['term_id'];
+				$fixture['term_ids'][ $term_name ] = $term_id;
+				wc_set_term_order( $term_id, $menu_order, $fixture['taxonomy'] );
+			}
+		} catch ( Throwable $throwable ) {
+			$this->unregister_attribute_taxonomy_fixture_for_test( $fixture );
+			throw $throwable;
+		}
+
+		return $fixture;
+	}
+
+	/**
+	 * Unregister the process state for a global product attribute fixture.
+	 *
+	 * Database writes are rolled back by the parent test case transaction.
+	 *
+	 * @param array{taxonomy: string, term_ids: array<array-key, int>} $fixture Fixture data.
+	 */
+	private function unregister_attribute_taxonomy_fixture_for_test( array $fixture ): void {
+		global $wc_product_attributes;
+
+		if ( taxonomy_exists( $fixture['taxonomy'] ) ) {
+			unregister_taxonomy( $fixture['taxonomy'] );
+		}
+
+		unset( $wc_product_attributes[ $fixture['taxonomy'] ] );
+	}
+
+	/**
+	 * Run an authenticated taxonomy term AJAX search for a test.
+	 *
+	 * @param string $taxonomy Taxonomy to search.
+	 * @param string $term     Search term.
+	 * @param int    $limit    Maximum result count.
+	 * @param string $orderby  Result ordering.
+	 * @return array
+	 */
+	private function search_taxonomy_terms_via_ajax_for_test( string $taxonomy, string $term, int $limit, string $orderby ): array {
+		$original_get     = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Preserve test globals before building the authenticated request.
+		$original_post    = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Preserve test globals before building the authenticated request.
+		$original_request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Preserve test globals before building the authenticated request.
+		$original_user_id = get_current_user_id();
+
+		try {
+			$this->_setRole( 'administrator' );
+			$_GET = array(
+				'security' => wp_create_nonce( 'search-taxonomy-terms' ),
+				'taxonomy' => $taxonomy,
+				'term'     => $term,
+				'limit'    => $limit,
+				'orderby'  => $orderby,
+			);
+
+			$response = $this->do_ajax( 'woocommerce_json_search_taxonomy_terms' );
+
+			if ( ! is_array( $response ) ) {
+				throw new RuntimeException( 'The taxonomy term AJAX response should be an array.' );
+			}
+
+			return $response;
+		} finally {
+			$_GET     = $original_get;
+			$_POST    = $original_post;
+			$_REQUEST = $original_request;
+			wp_set_current_user( $original_user_id );
+		}
+	}
+
 	/**
 	 * Test coupon and recalculation of totals sequences when product prices are tax inclusive.
 	 */