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
*