Commit 77b2cf19bc8 for woocommerce

commit 77b2cf19bc8eebb6fb39716fac650b1b48f2b7d7
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Wed Oct 7 15:33:14 2026 +0300

    [tests] Pin which zone an address matches (#69285)

    * [tests] Cover postcode ranges, wildcards, normalisation and the zone-order tie-break in zone matching

    * Add changefile(s) from automation for the following project(s): woocommerce

    * [tests] Pin that zone matching is decided by zone order, not specificity

    * [tests] Pin that a shadowed zone the admin warns about is never matched

    * Merge zone-matching tests into WC_Shipping_Zones_Test and assert the country restriction

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/69285-wooplug-7844-shipping-tests-which-zone-matches b/plugins/woocommerce/changelog/69285-wooplug-7844-shipping-tests-which-zone-matches
new file mode 100644
index 00000000000..5fd278ac86d
--- /dev/null
+++ b/plugins/woocommerce/changelog/69285-wooplug-7844-shipping-tests-which-zone-matches
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Add tests for shipping zone matching rules: postcode ranges, wildcards, normalisation and the zone-order tie-break; no production change.
+
+
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shipping-zones-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shipping-zones-test.php
index e1f7d074827..2fd5b6c3983 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shipping-zones-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-zones-test.php
@@ -111,6 +111,315 @@ class WC_Shipping_Zones_Test extends WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'zone_order_conflict_warning', $zones[ $state_zone->get_id() ] );
 	}

+	/**
+	 * The zone editor offers a `low...high` postcode range. The destination inside it matches and
+	 * one past its top does not.
+	 *
+	 * @testdox A numeric postcode range matches the postcodes inside it and no others.
+	 */
+	public function test_a_numeric_postcode_range_matches_inside_and_not_outside(): void {
+		$this->zone_with( 'Beverly Hills', '90210...90220', 'postcode' );
+
+		$this->assertSame(
+			'Beverly Hills',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'US',
+					'state'    => 'CA',
+					'postcode' => '90215',
+				)
+			),
+			'A postcode inside the range should land in the zone.'
+		);
+		$this->assertNotSame(
+			'Beverly Hills',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'US',
+					'state'    => 'CA',
+					'postcode' => '90230',
+				)
+			),
+			'A postcode past the top of the range should not.'
+		);
+	}
+
+	/**
+	 * A range whose bounds are not plain numbers is compared by turning each into a number, so a
+	 * range written with letters still contains the codes between its ends.
+	 *
+	 * @testdox A postcode range written with letters still matches the codes inside it.
+	 */
+	public function test_a_postcode_range_with_letters_matches_inside(): void {
+		$this->zone_with( 'Aberdeen', 'AB10...AB15', 'postcode' );
+
+		$this->assertSame(
+			'Aberdeen',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'GB',
+					'state'    => '',
+					'postcode' => 'AB12',
+				)
+			),
+			'A code inside the lettered range should land in the zone.'
+		);
+		$this->assertNotSame(
+			'Aberdeen',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'GB',
+					'state'    => '',
+					'postcode' => 'AB20',
+				)
+			),
+			'A code past the top should not.'
+		);
+	}
+
+	/**
+	 * A bare `*` is the wildcard the editor offers for "any postcode", so the zone matches every
+	 * address that reaches its country, and only its country.
+	 *
+	 * @testdox A bare wildcard postcode matches any postcode in the zone's country, but not outside it.
+	 */
+	public function test_a_bare_wildcard_postcode_matches_any_postcode(): void {
+		$zone = new WC_Shipping_Zone();
+		$zone->set_zone_name( 'Anywhere in GB' );
+		$zone->set_zone_order( 1 );
+		$zone->add_location( 'GB', 'country' );
+		$zone->add_location( '*', 'postcode' );
+		$zone->save();
+
+		$this->assertSame(
+			'Anywhere in GB',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'GB',
+					'state'    => '',
+					'postcode' => 'SW1A 1AA',
+				)
+			),
+			'The bare wildcard should accept any postcode.'
+		);
+		$this->assertNotSame(
+			'Anywhere in GB',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'US',
+					'state'    => 'CA',
+					'postcode' => 'SW1A 1AA',
+				)
+			),
+			'The same postcode outside GB should not match, since the country rule still restricts the zone.'
+		);
+	}
+
+	/**
+	 * A shopper types a postcode with a space or a hyphen; it is normalised before matching, so it
+	 * still lands in the zone whose rule is written without them.
+	 *
+	 * @testdox A postcode typed with a space or hyphen is matched with those removed.
+	 */
+	public function test_a_postcode_is_matched_with_spaces_and_hyphens_removed(): void {
+		$this->zone_with( 'Cambridge', 'CB231GG', 'postcode' );
+
+		$this->assertSame(
+			'Cambridge',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'GB',
+					'state'    => '',
+					'postcode' => 'CB23 1GG',
+				)
+			),
+			'A space in the typed postcode should not keep it out of the zone.'
+		);
+		$this->assertSame(
+			'Cambridge',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'GB',
+					'state'    => '',
+					'postcode' => 'cb23-1gg',
+				)
+			),
+			'Nor should a hyphen or lower case.'
+		);
+	}
+
+	/**
+	 * A zone with no region rules is what the editor calls "Everywhere", and it matches every
+	 * address rather than none.
+	 *
+	 * @testdox A zone with no regions matches every address.
+	 */
+	public function test_a_zone_with_no_regions_matches_every_address(): void {
+		$zone = new WC_Shipping_Zone();
+		$zone->set_zone_name( 'Everywhere' );
+		$zone->set_zone_order( 1 );
+		$zone->save();
+
+		$this->assertSame(
+			'Everywhere',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'JP',
+					'state'    => '',
+					'postcode' => '100-0001',
+				)
+			),
+			'A region-less zone should match an address nothing else covers.'
+		);
+	}
+
+	/**
+	 * When nothing a merchant drew matches, the address falls through to the Rest of the World zone
+	 * (id 0) rather than to no zone at all.
+	 *
+	 * @testdox An address no zone covers falls through to the Rest of the World zone.
+	 */
+	public function test_an_unmatched_address_falls_through_to_the_rest_of_the_world_zone(): void {
+		$this->zone_with( 'Only Germany', 'DE', 'country' );
+
+		$matched = WC_Shipping_Zones::get_zone_matching_package(
+			array(
+				'destination' => array(
+					'country'  => 'US',
+					'state'    => 'CA',
+					'postcode' => '90210',
+				),
+			)
+		);
+
+		$this->assertSame( 0, $matched->get_id(), 'An uncovered address should resolve to the Rest of the World zone.' );
+	}
+
+	/**
+	 * Two zones at the same order both match the address. The matcher breaks the tie on the lower
+	 * zone id, and the shadow warning that tells the merchant one zone hides the other ranks them
+	 * the same way, so the warning names the zone that really loses.
+	 *
+	 * @testdox When two zones share an order the lower id wins, and the shadow warning agrees.
+	 */
+	public function test_when_two_zones_share_an_order_the_lower_id_wins(): void {
+		$first  = $this->zone_with( 'First US', 'US', 'country', 1 );
+		$second = $this->zone_with( 'Second US', 'US', 'country', 1 );
+
+		$this->assertLessThan( $second->get_id(), $first->get_id(), 'The first zone saved should hold the lower id.' );
+
+		$matched = WC_Shipping_Zones::get_zone_matching_package(
+			array(
+				'destination' => array(
+					'country'  => 'US',
+					'state'    => 'CA',
+					'postcode' => '90210',
+				),
+			)
+		);
+
+		$this->assertSame( $first->get_id(), $matched->get_id(), 'With orders tied, the lower zone id should win.' );
+
+		// The admin warning ranks the pair the same way, so it flags the zone that really loses.
+		$warned = array();
+		foreach ( WC_Shipping_Zones::get_zones_with_order_conflict_warnings() as $zone ) {
+			if ( ! empty( $zone['zone_order_conflict_warning'] ) ) {
+				$warned[] = $zone['zone_name'];
+			}
+		}
+		$this->assertContains( 'Second US', $warned, 'The higher-id zone should be warned as shadowed.' );
+		$this->assertNotContains( 'First US', $warned, 'The winning zone should carry no warning.' );
+	}
+
+	/**
+	 * A zone defined only by a postcode, with no country, matches that postcode wherever the
+	 * shopper says they are, which is a sharper edge of the editor than a merchant may expect.
+	 *
+	 * @testdox A postcode-only zone is reachable from a country the merchant did not intend.
+	 */
+	public function test_a_postcode_only_zone_is_reachable_from_another_country(): void {
+		$this->zone_with( 'Postcode only', '12345', 'postcode' );
+
+		$this->assertSame(
+			'Postcode only',
+			$this->matched_zone_name(
+				array(
+					'country'  => 'FR',
+					'state'    => '',
+					'postcode' => '12345',
+				)
+			),
+			'A postcode-only zone matches the postcode in any country.'
+		);
+	}
+
+	/**
+	 * Matching is decided by zone order, not by how specific a zone is: a broad zone placed above a
+	 * narrow one that also matches shadows it, and moving the broad zone below the narrow one hands
+	 * the address back to the narrow zone.
+	 *
+	 * @testdox The zone listed first wins, so a broad zone above a narrow one shadows it.
+	 */
+	public function test_a_broad_zone_above_a_narrow_one_shadows_it(): void {
+		$broad = $this->zone_with( 'All of the US', 'US', 'country', 1 );
+		$this->zone_with( 'Beverly Hills only', '90210', 'postcode', 2 );
+
+		$destination = array(
+			'country'  => 'US',
+			'state'    => 'CA',
+			'postcode' => '90210',
+		);
+
+		$this->assertSame(
+			'All of the US',
+			$this->matched_zone_name( $destination ),
+			'An address both zones match should land in the one listed first, broad though it is.'
+		);
+
+		// Move the broad zone below the narrow one; the same address now lands in the narrow zone.
+		$broad->set_zone_order( 3 );
+		$broad->save();
+
+		$this->assertSame(
+			'Beverly Hills only',
+			$this->matched_zone_name( $destination ),
+			'With the narrow zone now listed first, it wins, so order decides rather than specificity.'
+		);
+	}
+
+	/**
+	 * The admin warns that a zone "will not be matched because another covers the same region earlier
+	 * in the list." That warning is true of the matcher, not only of the helper that prints it: a
+	 * broad zone above a narrower one it fully covers makes the narrower zone unreachable, so no
+	 * address the narrower zone describes ever lands in it.
+	 *
+	 * @testdox A zone the warning flags as shadowed is never returned by the matcher.
+	 */
+	public function test_a_shadowed_zone_is_never_matched(): void {
+		$this->zone_with( 'All of the US', 'US', 'country', 1 );
+		$this->zone_with( 'California only', 'US:CA', 'state', 2 );
+
+		$warned = array();
+		foreach ( WC_Shipping_Zones::get_zones_with_order_conflict_warnings() as $zone ) {
+			if ( ! empty( $zone['zone_order_conflict_warning'] ) ) {
+				$warned[] = $zone['zone_name'];
+			}
+		}
+		$this->assertContains( 'California only', $warned, 'The narrower zone should be flagged as shadowed.' );
+
+		// The matcher agrees: a Californian address lands in the broad zone, never the flagged one.
+		$matched = WC_Shipping_Zones::get_zone_matching_package(
+			array(
+				'destination' => array(
+					'country'  => 'US',
+					'state'    => 'CA',
+					'postcode' => '90210',
+				),
+			)
+		);
+		$this->assertSame( 'All of the US', $matched->get_zone_name(), 'The broad zone that shadows it wins instead, never the flagged one.' );
+	}
+
 	/**
 	 * Add a custom location type for tests.
 	 *
@@ -144,4 +453,32 @@ class WC_Shipping_Zones_Test extends WC_Unit_Test_Case {

 		return $zone;
 	}
+
+	/**
+	 * Build a zone with one location rule.
+	 *
+	 * @param string $name  Zone name, used to assert which one matched.
+	 * @param string $code  Location code, e.g. 'US' or '90210...90220'.
+	 * @param string $type  Location type: 'country', 'state', 'continent' or 'postcode'.
+	 * @param int    $order Zone order.
+	 * @return WC_Shipping_Zone
+	 */
+	private function zone_with( string $name, string $code, string $type, int $order = 1 ): WC_Shipping_Zone {
+		$zone = new WC_Shipping_Zone();
+		$zone->set_zone_name( $name );
+		$zone->set_zone_order( $order );
+		$zone->add_location( $code, $type );
+		$zone->save();
+		return $zone;
+	}
+
+	/**
+	 * The name of the zone an address lands in.
+	 *
+	 * @param array $destination Destination with 'country', 'state', 'postcode' keys.
+	 * @return string
+	 */
+	private function matched_zone_name( array $destination ): string {
+		return WC_Shipping_Zones::get_zone_matching_package( array( 'destination' => $destination ) )->get_zone_name();
+	}
 }