Commit a5c5785af67 for woocommerce

commit a5c5785af67a1ebb1d5f9ed44984c72367fb93e2
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Wed Oct 14 15:59:47 2026 +0300

    [tests] Cover multi-package rate hiding and collection on the block checkout (#69648)

    * [tests] Cover multi-package rate hiding and collection on the block checkout

    Split out from #69207. Adds ShippingController tests for package-level hiding
    mechanics and collection availability, plus a cart partial-address test; no production change.

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

    * Update plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php

    Co-authored-by: Raluca Stan <ralucastn@gmail.com>

    * Remove trailing whitespace in ShippingControllerTest docblock

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Raluca Stan <ralucastn@gmail.com>

diff --git a/plugins/woocommerce/changelog/69648-wooplug-7841-shipping-tests-mechanics-collection b/plugins/woocommerce/changelog/69648-wooplug-7841-shipping-tests-mechanics-collection
new file mode 100644
index 00000000000..b6e643b453d
--- /dev/null
+++ b/plugins/woocommerce/changelog/69648-wooplug-7841-shipping-tests-mechanics-collection
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Add ShippingController tests for multi-package rate hiding and collection, plus a cart partial-address test; no production change.
+
+
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index b2e16a262ed..b9c07477923 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -1395,6 +1395,50 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 		$this->assertFalse( WC()->cart->show_shipping(), "The classic checkout only treats hidden => true as hidden, so the {$field} stays required." );
 	}

+	/**
+	 * The classic cart asks for the fields the shopper's own country requires, and GB asks for no
+	 * state. Away from it the question is `has_full_shipping_address()`, which also wants a town,
+	 * so the same half-typed address reads differently on the two sides.
+	 *
+	 * @testdox show_shipping() away from the classic cart wants the whole address, not the fields the country requires.
+	 */
+	public function test_show_shipping_away_from_the_classic_cart_wants_the_whole_address(): void {
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+		$customer                        = WC()->cart->get_customer();
+		$this->original_shipping_address = array(
+			'shipping_country'  => $customer->get_shipping_country(),
+			'shipping_state'    => $customer->get_shipping_state(),
+			'shipping_city'     => $customer->get_shipping_city(),
+			'shipping_postcode' => $customer->get_shipping_postcode(),
+		);
+		$customer->set_shipping_country( 'GB' );
+		$customer->set_shipping_state( '' );
+		$customer->set_shipping_city( '' );
+		$customer->set_shipping_postcode( 'PR1 4SS' );
+
+		$this->assertFalse(
+			$customer->has_full_shipping_address(),
+			'The fixture turns on GB requiring a town, so this should be an incomplete address.'
+		);
+
+		WC()->cart->cart_context = 'shortcode';
+
+		$this->assertTrue(
+			WC()->cart->show_shipping(),
+			'Field by field this is enough for GB, which requires no state and does not ask about the town.'
+		);
+
+		WC()->cart->cart_context = 'store-api';
+
+		$this->assertFalse(
+			WC()->cart->show_shipping(),
+			'Asked as a whole, the same address is still missing a town.'
+		);
+	}
+
 	/**
 	 * Hide a US address field in the country locale while leaving it required.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
index 294fade93be..b89e67e7469 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
@@ -685,4 +685,208 @@ class ShippingControllerTest extends \WC_Unit_Test_Case {
 			),
 		);
 	}
+
+
+	/**
+	 * The classic cart hides the whole shipping section through `WC_Cart::show_shipping()` instead,
+	 * because there pickup is one of the shipping methods rather than a separate choice.
+	 *
+	 * @testdox The classic cart is left alone, whatever the setting says.
+	 */
+	public function test_the_classic_cart_is_left_alone(): void {
+		WC()->cart->cart_context = 'shortcode';
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+
+		$packages = $this->shipping_controller->remove_shipping_if_no_address(
+			array( $this->package_offering( array( 'flat_rate:1', 'pickup_location:0' ) ) )
+		);
+
+		$this->assertSame(
+			array( 'flat_rate:1', 'pickup_location:0' ),
+			array_keys( $packages[0]['rates'] ),
+			'The classic cart decides this for itself and should not be filtered here.'
+		);
+	}
+
+	/**
+	 * @testdox Every package is hidden, not just the first one.
+	 */
+	public function test_every_package_is_hidden_not_just_the_first(): void {
+		$this->shopper_is_on_the_block_checkout();
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+
+		$packages = $this->shipping_controller->remove_shipping_if_no_address(
+			array(
+				$this->package_offering( array( 'flat_rate:1', 'pickup_location:0' ) ),
+				$this->package_offering( array( 'flat_rate:2', 'pickup_location:0' ) ),
+			)
+		);
+
+		$this->assertCount( 2, $packages, 'Both packages should still be there, with less in them.' );
+		$this->assertSame( array( 'pickup_location:0' ), array_keys( $packages[0]['rates'] ), 'The first package should be stripped.' );
+		$this->assertSame( array( 'pickup_location:0' ), array_keys( $packages[1]['rates'] ), 'And so should the second.' );
+	}
+
+	/**
+	 * `WC()->customer` starts out null and is only built once a session exists, so this filter can
+	 * run with no customer to ask. With nothing known about where the shopper is, there is no
+	 * address, and the setting says to wait for one.
+	 *
+	 * @testdox With no customer to ask, delivery is hidden rather than shown.
+	 */
+	public function test_delivery_is_hidden_when_there_is_no_customer_to_ask(): void {
+		$this->shopper_is_on_the_block_checkout();
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+
+		$customer      = WC()->customer;
+		WC()->customer = null;
+
+		try {
+			$packages = $this->shipping_controller->remove_shipping_if_no_address(
+				array( $this->package_offering( array( 'flat_rate:1', 'pickup_location:0' ) ) )
+			);
+		} finally {
+			WC()->customer = $customer;
+		}
+
+		$this->assertSame(
+			array( 'pickup_location:0' ),
+			array_keys( $packages[0]['rates'] ),
+			'Without a customer there is no address to have entered, so delivery should stay hidden.'
+		);
+	}
+
+	/**
+	 * This filter checks `instanceof WC_Shipping_Rate` before reading the rate, so an entry an
+	 * extension added that is not a rate is dropped.
+	 * On a real request this guard is never reached: `filter_shipping_packages()`
+	 * runs first on the same hook and fatals on the same entry. It only protects direct calls.
+	 *
+	 * @testdox A non-rate entry in the rates array does not survive the hiding.
+	 */
+	public function test_only_real_rates_survive_the_hiding(): void {
+		$this->shopper_is_on_the_block_checkout();
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+
+		$package                               = $this->package_offering( array( 'flat_rate:1', 'pickup_location:0' ) );
+		$package['rates']['pickup_location:9'] = 'not a rate at all';
+
+		$packages = $this->shipping_controller->remove_shipping_if_no_address( array( $package ) );
+
+		$this->assertSame(
+			array( 'pickup_location:0' ),
+			array_keys( $packages[0]['rates'] ),
+			'Something that only looks like a pickup rate by its key should not be kept.'
+		);
+	}
+
+	/**
+	 * The whole order is collected in one go, so it only makes sense to offer collection when every
+	 * package can be collected. That is not something the settings screen says; it follows from an
+	 * order having a single tax location, which the method's own docblock gives as the reason.
+	 *
+	 * @testdox Collection stays on offer while every package can be collected.
+	 */
+	public function test_collection_stays_while_every_package_can_be_collected(): void {
+		$this->shopper_is_on_the_block_checkout();
+
+		$packages = $this->shipping_controller->filter_shipping_packages(
+			array(
+				$this->package_offering( array( 'flat_rate:1', 'pickup_location:0' ) ),
+				$this->package_offering( array( 'flat_rate:2', 'pickup_location:0' ) ),
+			)
+		);
+
+		$this->assertSame( array( 'flat_rate:1', 'pickup_location:0' ), array_keys( $packages[0]['rates'] ), 'Nothing should be withdrawn from the first package.' );
+		$this->assertSame( array( 'flat_rate:2', 'pickup_location:0' ), array_keys( $packages[1]['rates'] ), 'Nor from the second.' );
+	}
+
+	/**
+	 * @testdox When one package cannot be collected, collection disappears from the whole order.
+	 */
+	public function test_one_uncollectable_package_withdraws_collection_from_the_order(): void {
+		$this->shopper_is_on_the_block_checkout();
+
+		$packages = $this->shipping_controller->filter_shipping_packages(
+			array(
+				$this->package_offering( array( 'flat_rate:1', 'pickup_location:0' ) ),
+				$this->package_offering( array( 'flat_rate:2' ) ),
+			)
+		);
+
+		$this->assertSame( array( 'flat_rate:1' ), array_keys( $packages[0]['rates'] ), 'The collectable package should lose collection too, or the order could not be collected in one go.' );
+		$this->assertSame( array( 'flat_rate:2' ), array_keys( $packages[1]['rates'] ), 'The uncollectable package is left as it was.' );
+	}
+
+	/**
+	 * A package can come back with nothing on offer, for example when nothing in it can be shipped
+	 * to the address given.
+	 *
+	 * @testdox A package offering nothing at all counts as one that cannot be collected.
+	 */
+	public function test_a_package_with_no_rates_counts_as_uncollectable(): void {
+		$this->shopper_is_on_the_block_checkout();
+
+		$packages = $this->shipping_controller->filter_shipping_packages(
+			array(
+				$this->package_offering( array( 'pickup_location:0' ) ),
+				$this->package_offering( array() ),
+			)
+		);
+
+		$this->assertSame( array(), array_keys( $packages[0]['rates'] ), 'A package with no options at all cannot be collected, so collection goes from the order.' );
+		$this->assertSame( array(), array_keys( $packages[1]['rates'] ), 'And the empty package is still an empty package.' );
+	}
+
+	/**
+	 * What the shopper ends up with when both rules apply at once, through the filter the two are
+	 * really attached to rather than by calling them in a chosen order.
+	 *
+	 * @testdox With costs hidden, no address and a package that cannot be collected, nothing is offered.
+	 */
+	public function test_an_uncollectable_package_leaves_an_addressless_shopper_with_nothing(): void {
+		$this->shopper_is_on_the_block_checkout();
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+
+		$packages = apply_filters(
+			'woocommerce_shipping_packages',
+			array(
+				$this->package_offering( array( 'flat_rate:1', 'pickup_location:0' ) ),
+				$this->package_offering( array( 'flat_rate:2' ) ),
+			)
+		);
+
+		$this->assertSame( array(), array_keys( $packages[0]['rates'] ), 'Collection went because the order cannot be collected, and delivery went because there is no address.' );
+		$this->assertSame( array(), array_keys( $packages[1]['rates'] ), 'The same is true of the package that was never collectable.' );
+	}
+
+	/**
+	 * Unlike the sibling filter above it, this one asks no question about where the shopper is, so
+	 * the classic cart loses collection in the same way. The reason holds there too: the classic
+	 * cart also taxes a collected order at one place, the shop base. The collection method here is
+	 * `local_pickup` rather than `pickup_location`, because that is what a classic cart offers.
+	 *
+	 * @testdox On the classic cart too, one uncollectable package withdraws collection.
+	 */
+	public function test_the_classic_cart_also_loses_collection_for_the_whole_order(): void {
+		WC()->cart->cart_context = 'shortcode';
+
+		$packages = $this->shipping_controller->filter_shipping_packages(
+			array(
+				$this->package_offering( array( 'flat_rate:1', 'local_pickup:3' ) ),
+				$this->package_offering( array( 'flat_rate:2' ) ),
+			)
+		);
+
+		$this->assertSame(
+			array( 'flat_rate:1' ),
+			array_keys( $packages[0]['rates'] ),
+			'Collection should go from the classic cart as well, since this filter does not ask which cart it is.'
+		);
+		$this->assertSame(
+			array( 'flat_rate:2' ),
+			array_keys( $packages[1]['rates'] ),
+			'The package that was never collectable is left as it was.'
+		);
+	}
 }