Commit bac3ecd9c53 for woocommerce

commit bac3ecd9c53524be36f1a59541801cfc6909c937
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Wed Oct 7 16:16:51 2026 +0300

    [tests] Pin when a cart asks about shipping at all (#69222)

    * [tests] Pin when a cart asks about shipping at all

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

    * [tests] Cover an extension answering for a single product

    * [tests] Quote the settings screens accurately in the cart shipping test

    * [tests] Say plainly what the disabled-method behaviour records

    * [tests] Clear leaked shipping methods so the no-method cart test is order-independent

    ---------

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

diff --git a/plugins/woocommerce/changelog/69222-wooplug-7843-shipping-tests-whether-shipping-applies b/plugins/woocommerce/changelog/69222-wooplug-7843-shipping-tests-whether-shipping-applies
new file mode 100644
index 00000000000..9bce119c0d7
--- /dev/null
+++ b/plugins/woocommerce/changelog/69222-wooplug-7843-shipping-tests-whether-shipping-applies
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Cover whether a cart asks about shipping at all; no production change.
+
+
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-needs-shipping-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-needs-shipping-test.php
new file mode 100644
index 00000000000..accc56a923e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-needs-shipping-test.php
@@ -0,0 +1,212 @@
+<?php
+/**
+ * Tests for WC_Cart::needs_shipping().
+ *
+ * @package WooCommerce\Tests\Cart
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Tests whether a cart asks the shopper for shipping at all.
+ *
+ * The expectations come from the General settings screen, whose "Shipping location(s)" select
+ * offers "Disable shipping & shipping calculations" as the supported way to switch shipping off,
+ * and from a product's Virtual checkbox, described as "Virtual products are intangible and are not
+ * shipped."
+ */
+class WC_Cart_Needs_Shipping_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Put a shippable product in the cart.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		global $wpdb;
+
+		// Start from no shipping method at all. Two kinds of leftover state survive the database
+		// rollback between tests and would otherwise be counted here: a method from before shipping
+		// zones loads whenever its settings option is enabled, and a zone-method row written by a
+		// committed query from another test. unregister_shipping_methods() only clears the list
+		// memoized on the WC_Shipping singleton, so clear both of those too. All of this is undone
+		// with the current test's transaction.
+		WC()->shipping()->unregister_shipping_methods();
+		foreach ( array( 'flat_rate', 'free_shipping', 'international_delivery', 'local_delivery', 'local_pickup' ) as $legacy_method ) {
+			delete_option( 'woocommerce_' . $legacy_method . '_settings' );
+		}
+		$wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_shipping_zone_methods" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$this->assertNotFalse( WC()->cart->add_to_cart( $product->get_id(), 1 ), 'The fixture product should reach the cart.' );
+	}
+
+	/**
+	 * Offer a flat rate from the Rest of the World zone.
+	 *
+	 * @param bool $enabled Whether the instance is switched on.
+	 * @return int The new instance id.
+	 */
+	private function zone_offers_a_flat_rate( bool $enabled = true ): int {
+		$instance_id = WC_Shipping_Zones::get_zone( 0 )->add_shipping_method( 'flat_rate' );
+
+		if ( ! $enabled ) {
+			global $wpdb;
+			$wpdb->update(
+				$wpdb->prefix . 'woocommerce_shipping_zone_methods',
+				array( 'is_enabled' => 0 ),
+				array( 'instance_id' => $instance_id ),
+				array( '%d' ),
+				array( '%d' )
+			);
+		}
+
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+		return $instance_id;
+	}
+
+	/**
+	 * @testdox With no shipping method defined anywhere, the cart does not ask about shipping.
+	 */
+	public function test_with_no_method_defined_the_cart_does_not_ask_about_shipping(): void {
+		$this->assertSame( 0, wc_get_shipping_method_count( true ), 'The fixture should start with no method at all.' );
+
+		$this->assertFalse( WC()->cart->needs_shipping(), 'There is nothing to ship with, so there is nothing to ask.' );
+	}
+
+	/**
+	 * @testdox With a method defined, a cart holding something physical asks about shipping.
+	 */
+	public function test_with_a_method_defined_a_physical_cart_asks_about_shipping(): void {
+		$this->zone_offers_a_flat_rate();
+
+		$this->assertTrue( WC()->cart->needs_shipping(), 'Something in the cart has to be shipped, and there is a method to ship it with.' );
+	}
+
+	/**
+	 * "Disable shipping & shipping calculations" is the supported way to switch shipping off, and it
+	 * has to win over a method still sitting in a zone.
+	 *
+	 * @testdox With shipping switched off, the cart does not ask about it even with a method defined.
+	 */
+	public function test_with_shipping_switched_off_the_cart_does_not_ask(): void {
+		$this->zone_offers_a_flat_rate();
+		update_option( 'woocommerce_ship_to_countries', 'disabled' );
+
+		$this->assertFalse( wc_shipping_enabled(), 'The fixture should have shipping switched off.' );
+		$this->assertFalse( WC()->cart->needs_shipping(), 'The merchant has switched shipping off, so nothing should be asked.' );
+	}
+
+	/**
+	 * Records what happens today, which is not the same as endorsing it. `needs_shipping()` counts
+	 * instances through `wc_get_shipping_method_count( true )`, whose first argument is
+	 * `$include_legacy` and not `$enabled_only`, so a switched-off instance still counts and the
+	 * shopper is asked for a shipping method the store cannot offer.
+	 *
+	 * woocommerce#56507 reported that and was closed as not planned, on the grounds that it is an
+	 * edge case and that a merchant who wants shipping off has the setting above. Two things are
+	 * worth knowing before relying on that. The reason given was that stores disable methods
+	 * conditionally by user type, but this count reads `is_enabled` straight from the zone methods
+	 * table behind a transient, so conditional availability, which happens later in
+	 * `is_available()`, cannot reach it. And the test below shows the rule is not applied
+	 * consistently in the first place.
+	 *
+	 * @testdox A method that exists but is switched off still makes the cart ask about shipping.
+	 */
+	public function test_a_switched_off_method_still_makes_the_cart_ask(): void {
+		$this->zone_offers_a_flat_rate( false );
+
+		$this->assertSame( 0, wc_get_shipping_method_count( false, true ), 'No zone instance should be counted as enabled.' );
+		$this->assertTrue( WC()->cart->needs_shipping(), 'Recorded behaviour: an instance that exists counts, enabled or not.' );
+	}
+
+	/**
+	 * A method that predates shipping zones is counted only while it is switched on, the opposite of
+	 * the zone instance above: the count has separate branches and only the zone branch ignores the
+	 * switch. This is not only about old methods. Block Local Pickup declares `local-pickup` and not
+	 * `shipping-zones`, so it is counted here too, which means switching off Local Pickup is obeyed
+	 * while switching off every zone method is not.
+	 *
+	 * @testdox A switched-off method from before shipping zones does not make the cart ask.
+	 *
+	 * @testWith ["yes", true]
+	 *           ["no", false]
+	 *
+	 * @param string $enabled  What the method's own setting holds.
+	 * @param bool   $expected Whether the cart should ask about shipping.
+	 */
+	public function test_a_method_from_before_shipping_zones_is_counted_only_while_switched_on( string $enabled, bool $expected ): void {
+		update_option(
+			'woocommerce_flat_rate_settings',
+			array(
+				'enabled'    => $enabled,
+				'title'      => 'Flat rate',
+				'tax_status' => 'taxable',
+				'cost'       => 10,
+			)
+		);
+		update_option( 'woocommerce_flat_rate', array() );
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+		WC()->shipping()->load_shipping_methods();
+
+		$this->assertSame( $expected, WC()->cart->needs_shipping(), 'A method from before zones, switched ' . $enabled . '.' );
+	}
+
+	/**
+	 * A merchant can mark a product virtual while it is already in a shopper's cart. The cart holds
+	 * the product as it was when it was added, so the change is seen only once the cart is read
+	 * again. This records the mechanism rather than a promise: what a real page load does is more
+	 * than the re-read below, and no screen describes the gap.
+	 *
+	 * @testdox A product made virtual while in the cart is only noticed when the cart is read again.
+	 */
+	public function test_a_product_made_virtual_while_in_the_cart_stops_the_asking(): void {
+		$this->zone_offers_a_flat_rate();
+		$this->assertTrue( WC()->cart->needs_shipping(), 'The cart should ask about shipping to begin with.' );
+
+		$item    = current( WC()->cart->get_cart() );
+		$product = wc_get_product( $item['product_id'] );
+		$product->set_virtual( true );
+		$product->save();
+
+		$this->assertTrue(
+			WC()->cart->needs_shipping(),
+			'The page the shopper already has open still holds the product as it was.'
+		);
+
+		WC()->cart->get_cart_from_session();
+
+		$this->assertFalse(
+			WC()->cart->needs_shipping(),
+			'Once the cart is read again, nothing in it has to be shipped.'
+		);
+	}
+
+	/**
+	 * An extension can also answer for a single product rather than for the whole cart, which is how
+	 * things like bookings and service products drop out of shipping.
+	 *
+	 * @testdox An extension can say a single product needs no shipping.
+	 */
+	public function test_an_extension_can_say_a_single_product_needs_no_shipping(): void {
+		$this->zone_offers_a_flat_rate();
+		$this->assertTrue( WC()->cart->needs_shipping(), 'The cart should ask about shipping to begin with.' );
+
+		add_filter( 'woocommerce_product_needs_shipping', '__return_false' );
+
+		$this->assertFalse( WC()->cart->needs_shipping(), 'With nothing in the cart needing shipping, there is nothing to ask.' );
+	}
+
+	/**
+	 * @testdox An extension can decide for itself whether the cart asks about shipping.
+	 */
+	public function test_an_extension_can_decide_whether_the_cart_asks(): void {
+		$this->zone_offers_a_flat_rate();
+
+		add_filter( 'woocommerce_cart_needs_shipping', '__return_false' );
+
+		$this->assertFalse( WC()->cart->needs_shipping(), 'The filter should be able to withdraw the shipping step.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
index 535bf37d22e..0ef2b133c72 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
@@ -69,6 +69,84 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 		remove_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
 	}

+	/**
+	 * A destination the store does not ship to cannot be delivered, but it can still be collected,
+	 * so the shopper is left with collection rather than with nothing.
+	 *
+	 * @testdox A destination the store does not ship to leaves collection as the only option.
+	 */
+	public function test_an_unshippable_destination_leaves_collection_as_the_only_option(): void {
+		update_option( 'woocommerce_ship_to_countries', 'specific' );
+		update_option( 'woocommerce_specific_ship_to_countries', array( 'GB' ) );
+
+		$rates = $this->rates_offered_to( 'US' );
+
+		$this->assertSame( array( 'local_pickup:1' ), array_keys( $rates ), 'Only what the shopper can collect should be offered.' );
+	}
+
+	/**
+	 * @testdox A destination the store does ship to is offered delivery as well as collection.
+	 */
+	public function test_a_shippable_destination_is_offered_delivery_too(): void {
+		update_option( 'woocommerce_ship_to_countries', 'specific' );
+		update_option( 'woocommerce_specific_ship_to_countries', array( 'US' ) );
+
+		$rates = $this->rates_offered_to( 'US' );
+
+		$this->assertSame( array( 'flat_rate:1', 'local_pickup:1' ), array_keys( $rates ), 'A destination the store ships to should be offered both.' );
+	}
+
+	/**
+	 * A package with nowhere named yet cannot be proven unshippable, so the shopper keeps every
+	 * option while they are still typing.
+	 *
+	 * @testdox A package with no destination country is offered delivery as well as collection.
+	 */
+	public function test_a_package_with_no_destination_country_is_offered_delivery_too(): void {
+		update_option( 'woocommerce_ship_to_countries', 'specific' );
+		update_option( 'woocommerce_specific_ship_to_countries', array( 'GB' ) );
+
+		$rates = $this->rates_offered_to( '' );
+
+		$this->assertSame( array( 'flat_rate:1', 'local_pickup:1' ), array_keys( $rates ), 'Nothing has been ruled out yet, so nothing should be withheld.' );
+	}
+
+	/**
+	 * Ask for the rates a package bound for the given country is offered.
+	 *
+	 * @param string $country Destination country.
+	 * @return array Rates keyed by rate id.
+	 */
+	private function rates_offered_to( string $country ): array {
+		$methods = array( new WC_Shipping_Flat_Rate( 1 ), new WC_Shipping_Local_Pickup( 1 ) );
+		$hook    = fn () => $methods;
+
+		// Rates are cached in the session against a hash of the package, and the ship-to setting is
+		// not part of that hash, so two calls with the same package would otherwise read the first
+		// call's answer.
+		WC()->session->set( 'shipping_for_package_0', null );
+
+		add_action( 'woocommerce_shipping_methods', $hook );
+
+		try {
+			$package = $this->sut->calculate_shipping_for_package(
+				array(
+					'contents'      => array(),
+					'contents_cost' => 10,
+					'destination'   => array(
+						'country'  => $country,
+						'state'    => 'CA',
+						'postcode' => '00000',
+					),
+				)
+			);
+		} finally {
+			remove_action( 'woocommerce_shipping_methods', $hook );
+		}
+
+		return $package['rates'];
+	}
+
 	/**
 	 * @testdox package rates filter doesn't cause errors when accessing non-existent rates with arithmetic operations
 	 *