Commit 3274337ce24 for woocommerce
commit 3274337ce24bfba8bca81c042fa23cbc0e1dab03
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Fri Oct 9 14:42:03 2026 +0300
[tests] Pin which pickup locations are offered, and what each option carries (#69203)
* [tests] Pin which pickup locations are offered, and what each option carries
* Add changefile(s) from automation for the following project(s): woocommerce
* [tests] Assert two pickup options are labelled differently
* [tests] Pin that the rest of the store treats a pickup order as collected
* [tests] Correct why the registered pickup list leaves the legacy method out
* [tests] Pin that an earlier filter cannot drop local pickup from the collection list
* [tests] Reload shipping methods so pickup identity does not depend on test order
* [tests] Pin that no branch holds the method back, and that closing one keeps the list in place
* [tests] Cover the block checkout naming a pickup branch by its own name and address, and showing its cost
* [tests] Format the pickup option matcher to satisfy prettier
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/69203-wooplug-7840-shipping-tests-pickup-identity b/plugins/woocommerce/changelog/69203-wooplug-7840-shipping-tests-pickup-identity
new file mode 100644
index 00000000000..c553d96be44
--- /dev/null
+++ b/plugins/woocommerce/changelog/69203-wooplug-7840-shipping-tests-pickup-identity
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Cover which local pickup locations are offered and what each option carries; no production change.
+
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx
index dd2b2292ed1..4ef542292f5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx
@@ -252,3 +252,79 @@ test( 'description is not shown if rate is not selected', async () => {
expect( screen.queryByText( 'Store 1 details.' ) ).not.toBeInTheDocument();
expect( screen.getByText( 'Store 2 details.' ) ).toBeInTheDocument();
} );
+
+test( "labels a location by its own name and address, not the shipping method's", async () => {
+ const packageData = generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [
+ generateShippingRate( {
+ rateId: 'pickup_location:1',
+ name: 'Local pickup',
+ methodID: 'pickup_nyc',
+ price: '0',
+ instanceID: 0,
+ meta_data: [
+ { key: 'pickup_location', value: 'Downtown Store' },
+ {
+ key: 'pickup_address',
+ value: '12 High Street, London',
+ },
+ ],
+ } ),
+ ],
+ } );
+ ( useShippingData as jest.Mock ).mockImplementation( () => {
+ return {
+ selectShippingRate: jest.fn(),
+ isSelectingRate: false,
+ shippingRates: [ packageData ],
+ };
+ } );
+
+ render( <CheckoutPickupOptionsBlock /> );
+
+ // Named by the branch, which is how two branches are told apart, rather than by the
+ // method all of them share.
+ expect(
+ await screen.findByRole( 'radio', { name: /Downtown Store/ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'radio', { name: /Local pickup/ } )
+ ).not.toBeInTheDocument();
+ expect( screen.getByText( '12 High Street, London' ) ).toBeInTheDocument();
+} );
+
+test( 'shows the pickup cost instead of free once the merchant sets one', async () => {
+ const packageData = generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [
+ generateShippingRate( {
+ rateId: 'pickup_location:1',
+ name: 'Pickup New York City',
+ methodID: 'pickup_nyc',
+ price: '500',
+ instanceID: 0,
+ meta_data: [
+ { key: 'pickup_location', value: 'New York City' },
+ ],
+ } ),
+ ],
+ } );
+ ( useShippingData as jest.Mock ).mockImplementation( () => {
+ return {
+ selectShippingRate: jest.fn(),
+ isSelectingRate: false,
+ shippingRates: [ packageData ],
+ };
+ } );
+
+ render( <CheckoutPickupOptionsBlock /> );
+
+ // The amount is rendered in parts ($ then 5.00), so read it off the option's accessible name.
+ expect(
+ await screen.findByRole( 'radio', {
+ name: /New York City.*\$\s*5\.00/,
+ } )
+ ).toBeInTheDocument();
+ expect( screen.queryByText( 'free' ) ).not.toBeInTheDocument();
+} );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupIdentityTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupIdentityTest.php
new file mode 100644
index 00000000000..78f7bb32649
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupIdentityTest.php
@@ -0,0 +1,206 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Shipping;
+
+use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests that the rest of the store agrees an order is being collected.
+ *
+ * The settings screen promises that local pickup "will appear as an option on the block based
+ * checkout". What follows from a shopper taking that option is that the order is treated as
+ * collected everywhere else: no delivery address is asked for, and the order shows where to
+ * collect from instead of where it is being sent.
+ */
+class PickupIdentityTest extends WC_Unit_Test_Case {
+
+ /**
+ * The checkout page id to put back.
+ *
+ * @var int|string
+ */
+ private $original_checkout_page_id;
+
+ /**
+ * Local pickup is only registered while the checkout is the block one, and it is registered on
+ * woocommerce_load_shipping_methods, which fires once. Anything that loaded the methods earlier
+ * in the process did so against the old page, so the methods have to be loaded again here.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->original_checkout_page_id = get_option( 'woocommerce_checkout_page_id' );
+ update_option(
+ 'woocommerce_checkout_page_id',
+ $this->factory->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'Checkout',
+ 'post_content' => '<!-- wp:woocommerce/checkout /-->',
+ 'post_status' => 'publish',
+ )
+ )
+ );
+
+ $this->reload_shipping_methods();
+ }
+
+ /**
+ * Put the checkout page back, and the shipping methods with it.
+ */
+ public function tearDown(): void {
+ try {
+ update_option( 'woocommerce_checkout_page_id', $this->original_checkout_page_id );
+ $this->reload_shipping_methods();
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * Rebuild the registered shipping methods against the checkout page currently set.
+ */
+ private function reload_shipping_methods(): void {
+ WC()->shipping()->unregister_shipping_methods();
+ WC()->shipping()->load_shipping_methods();
+ }
+
+ /**
+ * Build an order carrying one shipping line of the given method.
+ *
+ * @param string $method_id Shipping method id.
+ * @param array $meta Meta to put on the shipping line.
+ * @return \WC_Order
+ */
+ private function order_shipped_by( string $method_id, array $meta = array() ): \WC_Order {
+ $order = new \WC_Order();
+ $item = new \WC_Order_Item_Shipping();
+ $item->set_method_id( $method_id );
+ $item->set_method_title( 'Shipping' );
+
+ foreach ( $meta as $key => $value ) {
+ $item->add_meta_data( $key, $value );
+ }
+
+ $order->add_item( $item );
+ $order->save();
+
+ return wc_get_order( $order->get_id() );
+ }
+
+ /**
+ * @testdox An order being collected is not asked for a delivery address, while a delivered one is.
+ *
+ * @testWith ["pickup_location", false]
+ * ["flat_rate", true]
+ *
+ * @param string $method_id Method the order was placed with.
+ * @param bool $expected Whether a delivery address is still wanted.
+ */
+ public function test_a_collected_order_is_not_asked_for_a_delivery_address( string $method_id, bool $expected ): void {
+ $order = $this->order_shipped_by( $method_id );
+
+ $this->assertSame(
+ $expected,
+ $order->needs_shipping_address(),
+ 'An order placed with ' . $method_id . '.'
+ );
+ }
+
+ /**
+ * The list is a filter so that extensions can add their own collection method, and asserting a
+ * closed list would pin the very thing the filter exists to open.
+ *
+ * @testdox A collection method an extension adds is treated as collection too.
+ */
+ public function test_a_collection_method_an_extension_adds_is_treated_as_collection(): void {
+ $order = $this->order_shipped_by( 'depot_collection' );
+
+ $this->assertTrue( $order->needs_shipping_address(), 'Before the extension speaks up, it is an ordinary delivery.' );
+
+ add_filter(
+ 'woocommerce_order_hide_shipping_address',
+ static function ( $methods ) {
+ $methods[] = 'depot_collection';
+ return $methods;
+ }
+ );
+
+ $this->assertFalse( $order->needs_shipping_address(), 'Once the extension says it is collection, no delivery address is wanted.' );
+ }
+
+ /**
+ * ShippingController re-adds the pickup ids at priority 10, so a filter that runs before it
+ * cannot take them out. It can still win by running later.
+ *
+ * @testdox An earlier filter cannot drop local pickup from the collection list.
+ */
+ public function test_an_earlier_filter_cannot_drop_local_pickup(): void {
+ $order = $this->order_shipped_by( 'local_pickup' );
+
+ $this->assertFalse( $order->needs_shipping_address(), 'Local pickup should not want a delivery address to begin with.' );
+
+ $empty_it = static function () {
+ return array();
+ };
+
+ add_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 5 );
+ $this->assertFalse(
+ $order->needs_shipping_address(),
+ 'A filter running before the controller should not be able to turn collection back into delivery.'
+ );
+ remove_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 5 );
+
+ add_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 20 );
+ $this->assertTrue(
+ $order->needs_shipping_address(),
+ 'Running after the controller, the same filter does have the last word.'
+ );
+ remove_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 20 );
+ }
+
+ /**
+ * Two lists answer "is this collection" and they do not agree. The canonical
+ * `woocommerce_local_pickup_methods` list names `legacy_local_pickup` so that an order placed
+ * with the pre-zones method is still taxed at the shop. The registered list is built from the
+ * methods that declare `local-pickup` support, and the legacy class never declares it, so it is
+ * missing there even on a store where it is loaded and enabled.
+ *
+ * This records the divergence rather than blessing it. The two lists are read by different
+ * callers, so the same order can be taxed as collection and still asked for a delivery address.
+ *
+ * @testdox The canonical list names a method the registered list leaves out.
+ */
+ public function test_the_canonical_list_keeps_a_method_the_registered_list_does_not(): void {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Documented in WC_Abstract_Order::get_tax_location().
+ $canonical = apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) );
+ $registered = LocalPickupUtils::get_local_pickup_method_ids();
+
+ $this->assertContains( 'legacy_local_pickup', $canonical, 'An order placed with the legacy method still has to be recognised.' );
+ $this->assertNotContains( 'legacy_local_pickup', $registered, 'The registered list leaves it out, because that class never declares local-pickup support.' );
+ $this->assertContains( 'pickup_location', $canonical, 'The block method joins the canonical list through the filter.' );
+ $this->assertContains( 'pickup_location', $registered, 'And it is registered, so it is in the other list as well.' );
+ }
+
+ /**
+ * @testdox The order shows where to collect from, in place of where it would have been sent.
+ */
+ public function test_the_order_shows_where_to_collect_from(): void {
+ $order = $this->order_shipped_by(
+ 'pickup_location',
+ array(
+ 'pickup_location' => 'Downtown',
+ 'pickup_address' => '1 Market St, San Francisco, CA 94105',
+ 'pickup_details' => 'Ring the bell at the side door.',
+ )
+ );
+
+ $shown = $order->get_shipping_to_display();
+
+ $this->assertStringContainsString( 'Downtown', $shown, 'The shopper should be told which branch to collect from.' );
+ $this->assertStringContainsString( 'Market St', $shown, 'And where that branch is.' );
+ $this->assertStringContainsString( 'Ring the bell', $shown, 'And whatever the merchant told them to do on arrival.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationTest.php
new file mode 100644
index 00000000000..975b58415e6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationTest.php
@@ -0,0 +1,460 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Shipping;
+
+use Automattic\WooCommerce\Blocks\Shipping\PickupLocation;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests the options PickupLocation offers the shopper.
+ *
+ * Every expected value here is taken from what the Local pickup settings screen promises the
+ * merchant: that enabling it makes pickup "appear as an option on the block based checkout", that
+ * the title is "the shipping method title shown to customers", that the cost is an "Optional cost
+ * to charge for local pickup" and that "by default, the local pickup shipping method is free",
+ * and that each location carries a required Location name and its Pickup details.
+ *
+ * Two expectations here are not merchant promises. The rate id format is one, pinned only
+ * relatively because it is what a shopper's stored choice points at. The `_pickup_location_address`
+ * meta is the other: it is hidden internal data the order uses to work out its tax location.
+ *
+ * One expectation is recorded rather than endorsed. `has_valid_pickup_location()` short-circuits to
+ * valid as soon as city, postcode and state are filled in, without asking the country what it needs,
+ * so a location saved with no street line counts as having a usable address even in a country that
+ * requires one. Whether that is right is a question for the team rather than for a test: a stall
+ * inside a mall may genuinely have no street line, and the screen does not say. It is reported, and
+ * `test_records_that_a_missing_street_line_still_counts_as_usable()` pins what happens today so a
+ * change to it is a deliberate edit rather than a surprise.
+ */
+class PickupLocationTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var PickupLocation
+ */
+ private $sut;
+
+ /**
+ * Configure local pickup the way the settings screen would, then build the method.
+ *
+ * @param array $locations Pickup locations.
+ * @param array $settings Method settings, merged over the defaults.
+ */
+ private function pickup_configured_with( array $locations, array $settings = array() ): void {
+ update_option(
+ 'woocommerce_pickup_location_settings',
+ array_merge(
+ array(
+ 'enabled' => 'yes',
+ 'title' => 'Pickup',
+ 'tax_status' => 'taxable',
+ 'cost' => '',
+ ),
+ $settings
+ )
+ );
+ update_option( 'pickup_location_pickup_locations', $locations );
+
+ $this->sut = new PickupLocation();
+ }
+
+ /**
+ * Build a location as the settings screen stores one.
+ *
+ * @param string $name Location name.
+ * @param bool $enabled Whether the location is switched on.
+ * @param array $address Address, defaulting to a complete one.
+ * @param string $details Pickup details.
+ * @return array
+ */
+ private function location( string $name, bool $enabled = true, array $address = array(), string $details = '' ): array {
+ return array(
+ 'name' => $name,
+ 'address' => $address ? $address : array(
+ 'address_1' => '1 Market St',
+ 'city' => 'San Francisco',
+ 'state' => 'CA',
+ 'postcode' => '94105',
+ 'country' => 'US',
+ ),
+ 'details' => $details,
+ 'enabled' => $enabled,
+ );
+ }
+
+ /**
+ * Ask the method what it offers for an ordinary package.
+ *
+ * @return array Rates keyed by rate id.
+ */
+ private function offered_rates(): array {
+ // Unsaved on purpose: add_rate() only reads the name and the quantity off the package.
+ $product = new \WC_Product_Simple();
+ $product->set_name( 'Fixture product' );
+
+ $this->sut->calculate_shipping(
+ array(
+ 'contents' => array(
+ 'item' => array(
+ 'data' => $product,
+ 'quantity' => 1,
+ ),
+ ),
+ 'contents_cost' => 10.0,
+ 'destination' => array(
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '94105',
+ ),
+ )
+ );
+
+ return $this->sut->rates;
+ }
+
+ /**
+ * Ask the method for the one option a single-location store should offer.
+ *
+ * @return \WC_Shipping_Rate
+ */
+ private function single_offered_rate(): \WC_Shipping_Rate {
+ $rates = $this->offered_rates();
+ $this->assertCount( 1, $rates, 'One open branch should give the shopper one option.' );
+
+ return current( $rates );
+ }
+
+ /**
+ * @testdox Each enabled location is offered as its own option, named so the shopper can tell the branches apart.
+ */
+ public function test_every_enabled_location_is_offered(): void {
+ $this->pickup_configured_with(
+ array(
+ $this->location( 'Downtown' ),
+ $this->location( 'Airport' ),
+ )
+ );
+
+ $rates = $this->offered_rates();
+
+ $this->assertCount( 2, $rates, 'Two open branches should give the shopper two options.' );
+
+ $names = array_map(
+ static function ( $rate ) {
+ return $rate->get_meta_data()['pickup_location'];
+ },
+ array_values( $rates )
+ );
+
+ $this->assertSame( array( 'Downtown', 'Airport' ), $names, 'Each option should carry its own location name.' );
+
+ $labels = array_map(
+ static function ( $rate ) {
+ return $rate->get_label();
+ },
+ array_values( $rates )
+ );
+
+ $this->assertStringContainsString( 'Downtown', $labels[0], 'The first option should name its own branch to the shopper.' );
+ $this->assertStringContainsString( 'Airport', $labels[1], 'And the second should name its own.' );
+ $this->assertNotSame( $labels[0], $labels[1], 'Two options a shopper cannot tell apart are no better than one.' );
+ }
+
+ /**
+ * @testdox A location that is switched off is not offered.
+ */
+ public function test_a_disabled_location_is_not_offered(): void {
+ $this->pickup_configured_with(
+ array(
+ $this->location( 'Downtown' ),
+ $this->location( 'Closed for refurbishment', false ),
+ )
+ );
+
+ $rates = $this->offered_rates();
+
+ $this->assertCount( 1, $rates, 'A location switched off should not be offered.' );
+ $this->assertSame( 'Downtown', current( $rates )->get_meta_data()['pickup_location'], 'The open branch is the one that survives.' );
+ }
+
+ /**
+ * The screen lets the method be enabled before any location exists, so this is a state a
+ * merchant passes through rather than an error.
+ *
+ * @testdox With no location open there is nothing to offer, even with pickup enabled.
+ */
+ public function test_no_open_location_means_no_option(): void {
+ $this->pickup_configured_with( array( $this->location( 'Closed', false ) ) );
+
+ $this->assertEmpty( $this->offered_rates(), 'Pickup enabled with every branch closed should offer nothing.' );
+ }
+
+ /**
+ * "By default, the local pickup shipping method is free", and the cost, when set, is charged
+ * for local pickup rather than per branch.
+ *
+ * @testdox Pickup is free by default, and a configured cost is charged at every branch.
+ */
+ public function test_cost_is_free_by_default_and_otherwise_shared(): void {
+ $this->pickup_configured_with(
+ array(
+ $this->location( 'Downtown' ),
+ $this->location( 'Airport' ),
+ )
+ );
+
+ $rates = $this->offered_rates();
+ $this->assertCount( 2, $rates, 'Both branches should be offered before their cost is examined.' );
+
+ foreach ( $rates as $rate ) {
+ $this->assertSame( '0', $rate->get_cost(), 'With no cost set, pickup should be free.' );
+ }
+
+ $this->pickup_configured_with(
+ array(
+ $this->location( 'Downtown' ),
+ $this->location( 'Airport' ),
+ ),
+ array( 'cost' => '5' )
+ );
+
+ $rates = $this->offered_rates();
+ $this->assertCount( 2, $rates, 'Both branches should still be offered once a cost is set.' );
+
+ foreach ( $rates as $rate ) {
+ $this->assertSame( '5', $rate->get_cost(), 'A configured cost should be charged whichever branch is chosen.' );
+ }
+ }
+
+ /**
+ * @testdox The title shown to customers appears on every option.
+ */
+ public function test_the_customer_facing_title_appears_on_the_option(): void {
+ $this->pickup_configured_with(
+ array( $this->location( 'Downtown' ) ),
+ array( 'title' => 'Collect in store' )
+ );
+
+ $label = $this->single_offered_rate()->get_label();
+
+ $this->assertStringContainsString( 'Collect in store', $label, 'The option should carry the title the merchant set for customers.' );
+ $this->assertStringContainsString( 'Downtown', $label, 'And the branch name, or a shopper cannot tell two branches apart.' );
+ }
+
+ /**
+ * @testdox The pickup details the merchant wrote travel with the option.
+ */
+ public function test_pickup_details_travel_with_the_option(): void {
+ $this->pickup_configured_with(
+ array( $this->location( 'Downtown', true, array(), 'Ring the bell at the side door.' ) )
+ );
+
+ $this->assertSame(
+ 'Ring the bell at the side door.',
+ $this->single_offered_rate()->get_meta_data()['pickup_details'],
+ 'Details the merchant wrote for the shopper should reach the shopper.'
+ );
+ }
+
+ /**
+ * A branch is a place the shopper can walk into, so it stays on offer whatever state its
+ * address is in. What the address decides is whether the shopper is told where to go, and
+ * whether the order has somewhere to work its tax out from.
+ *
+ * An address counts as usable when it holds what that country actually asks for. The editor
+ * collects Address, City, Country / State and Postcode / ZIP, and only the Location name is
+ * required of the merchant, so an address is not judged against a fixed list of fields.
+ *
+ * @testdox A location is always offered, and carries an address only when its country has what it needs.
+ *
+ * @dataProvider provider_address_shapes
+ *
+ * @param array $address The address the merchant saved.
+ * @param bool $is_usable Whether the shopper should be given the address.
+ * @param string $why What makes this address the shape it is.
+ */
+ public function test_an_address_is_judged_against_what_its_country_asks_for( array $address, bool $is_usable, string $why ): void {
+ $this->pickup_configured_with( array( $this->location( 'Branch', true, $address ) ) );
+
+ $rates = $this->offered_rates();
+ $this->assertCount( 1, $rates, 'The branch should be offered whatever its address looks like: ' . $why );
+
+ $meta = current( $rates )->get_meta_data();
+ $this->assertSame( 'Branch', $meta['pickup_location'], 'It should be named either way.' );
+
+ if ( $is_usable ) {
+ $this->assertStringContainsString( $address['city'], $meta['pickup_address'], 'The shopper should be told where to collect from: ' . $why );
+ $this->assertStringContainsString( $address['address_1'], $meta['pickup_address'], 'Including the street line the merchant saved.' );
+ $this->assertSame( $address, $meta['_pickup_location_address'], 'The order should keep the address it will work tax out from.' );
+ } else {
+ $this->assertSame( '', $meta['pickup_address'], 'A half-built address should be left empty rather than shown: ' . $why );
+ $this->assertSame( array(), $meta['_pickup_location_address'], 'And nothing should be kept for the order to tax against.' );
+ }
+ }
+
+ /**
+ * Address shapes, and whether they give the shopper somewhere to go.
+ *
+ * @return array
+ */
+ public function provider_address_shapes(): array {
+ $us = array(
+ 'address_1' => '1 Market St',
+ 'city' => 'San Francisco',
+ 'state' => 'CA',
+ 'postcode' => '94105',
+ 'country' => 'US',
+ );
+
+ return array(
+ 'a complete address' => array( $us, true, 'everything filled in' ),
+ 'no country at all' => array( array_merge( $us, array( 'country' => '' ) ), false, 'a country is the one thing always needed' ),
+ 'a country that wants a postcode' => array( array_merge( $us, array( 'postcode' => '' ) ), false, 'the US asks for a postcode and none was given' ),
+ 'a country that wants no postcode' => array(
+ array(
+ 'address_1' => '1 Sheikh Zayed Rd',
+ 'city' => 'Dubai',
+ 'state' => '',
+ 'postcode' => '',
+ 'country' => 'AE',
+ ),
+ true,
+ 'the UAE asks for neither a postcode nor a state',
+ ),
+ );
+ }
+
+ /**
+ * Rate ids are the array positions of the saved locations, so what an id means depends on the
+ * array holding still. Switching a branch off leaves it in place and the others keep their ids.
+ *
+ * @testdox Switching one location off does not change which option the others are.
+ */
+ public function test_switching_one_location_off_does_not_renumber_the_others(): void {
+ $this->pickup_configured_with(
+ array(
+ $this->location( 'Downtown' ),
+ $this->location( 'Airport' ),
+ $this->location( 'Harbour' ),
+ )
+ );
+
+ $before = array();
+ $rates = $this->offered_rates();
+ $this->assertCount( 3, $rates, 'All three branches should be offered to begin with.' );
+
+ foreach ( $rates as $id => $rate ) {
+ $before[ $rate->get_meta_data()['pickup_location'] ] = $id;
+ }
+
+ $this->pickup_configured_with(
+ array(
+ $this->location( 'Downtown' ),
+ $this->location( 'Airport', false ),
+ $this->location( 'Harbour' ),
+ )
+ );
+
+ $after = array();
+ $rates = $this->offered_rates();
+ $this->assertCount( 2, $rates, 'Closing one branch should leave two.' );
+
+ foreach ( $rates as $id => $rate ) {
+ $after[ $rate->get_meta_data()['pickup_location'] ] = $id;
+ }
+
+ $this->assertSame( $before['Downtown'], $after['Downtown'], 'Downtown should still be the same option.' );
+ $this->assertSame( $before['Harbour'], $after['Harbour'], 'Harbour should still be the same option, not take over the closed branch.' );
+ $this->assertNotSame( $after['Downtown'], $after['Harbour'], 'And the two should remain separate options.' );
+ }
+
+ /**
+ * The screen says local pickup "will appear as an option on the block based checkout" when it
+ * is enabled, which is the whole point of the switch.
+ *
+ * @testdox The method is offered only while local pickup is switched on.
+ *
+ * @testWith ["yes", true]
+ * ["no", false]
+ *
+ * @param string $enabled The stored setting.
+ * @param bool $expected Whether the method should offer itself at all.
+ */
+ public function test_the_method_is_offered_only_while_it_is_switched_on( string $enabled, bool $expected ): void {
+ $this->pickup_configured_with( array( $this->location( 'Downtown' ) ), array( 'enabled' => $enabled ) );
+
+ $this->assertSame(
+ $expected,
+ $this->sut->is_available( array( 'destination' => array( 'country' => 'US' ) ) ),
+ 'Local pickup switched ' . $enabled . '.'
+ );
+ }
+
+ /**
+ * The method answers for itself rather than for any one branch, so nothing about the branches
+ * can hold it back. A store that has not added one yet, and one whose only branch is closed,
+ * are both still stores where local pickup is switched on. The sibling test covers the switch
+ * being off, which needs no locations to say anything.
+ *
+ * @testdox Being offered follows the switch, and no branch can hold it back.
+ */
+ public function test_the_switch_decides_on_its_own_whatever_locations_exist(): void {
+ $package = array( 'destination' => array( 'country' => 'US' ) );
+
+ $this->pickup_configured_with( array(), array( 'enabled' => 'yes' ) );
+ $this->assertTrue( $this->sut->is_available( $package ), 'With nowhere to collect from yet, the method is still the one that was switched on.' );
+
+ $this->pickup_configured_with( array( $this->location( 'Downtown', false ) ), array( 'enabled' => 'yes' ) );
+ $this->assertTrue( $this->sut->is_available( $package ), 'A branch being closed is not the method being switched off.' );
+ }
+
+ /**
+ * The settings screen keeps limited HTML in the pickup details, so a merchant can save markup
+ * there. What the shopper is handed on the rate is plain text either way: `add_meta_data()`
+ * cleans every value it stores. The wording survives, the markup does not.
+ *
+ * @testdox Markup a merchant saved in the pickup details does not travel on the rate.
+ */
+ public function test_markup_in_the_pickup_details_does_not_reach_the_rate(): void {
+ $this->pickup_configured_with(
+ array( $this->location( 'Downtown', true, array(), 'Ring the <strong>side</strong> door.' ) )
+ );
+
+ $meta = $this->single_offered_rate()->get_meta_data();
+
+ $this->assertArrayHasKey( 'pickup_details', $meta, 'The details should reach the shopper.' );
+ $this->assertSame( 'Ring the side door.', $meta['pickup_details'], 'With the wording intact and the markup gone.' );
+ }
+
+ /**
+ * Records the behaviour this file's docblock reports to the team, rather than endorsing it.
+ * `has_valid_pickup_location()` stops as soon as city, postcode and state are filled and never
+ * asks the country whether it also wants a street line, so a US location saved without one is
+ * treated as having a usable address.
+ *
+ * @testdox Today, a location with no street line still counts as having a usable address.
+ */
+ public function test_records_that_a_missing_street_line_still_counts_as_usable(): void {
+ $this->pickup_configured_with(
+ array(
+ $this->location(
+ 'Mall stand',
+ true,
+ array(
+ 'address_1' => '',
+ 'city' => 'San Francisco',
+ 'state' => 'CA',
+ 'postcode' => '94105',
+ 'country' => 'US',
+ )
+ ),
+ )
+ );
+
+ $meta = $this->single_offered_rate()->get_meta_data();
+
+ $this->assertStringContainsString( 'San Francisco', $meta['pickup_address'], 'Recorded behaviour: the address is treated as usable even though the US asks for a street line.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
index fa682edda2d..b209f60e831 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
@@ -378,4 +378,50 @@ class PickupLocationsRestControllerTest extends WC_Unit_Test_Case {
$this->assertStringNotContainsString( 'alert(1)', $saved['cost'], 'Inline script payload must not survive cost sanitization.' );
$this->assertStringContainsString( '5 + 1.50', $saved['cost'], 'Math formula syntax must be preserved in cost — must not be coerced to float.' );
}
+
+ /**
+ * Rate ids are the positions of the saved locations, so what a shopper's stored choice points
+ * at depends on the array holding still. Switching a branch off through the settings screen
+ * keeps it in the list, which is what keeps the branches either side of it where they were.
+ *
+ * @testdox Switching a location off leaves it in the list, so the others keep their positions.
+ */
+ public function test_switching_a_location_off_keeps_the_list_in_place(): void {
+ wp_set_current_user( $this->shop_manager_id );
+
+ $branch = function ( string $name, bool $enabled ): array {
+ return array(
+ 'name' => $name,
+ 'address' => array(
+ 'address_1' => '1 Market St',
+ 'city' => 'San Francisco',
+ 'state' => 'CA',
+ 'postcode' => '94105',
+ 'country' => 'US',
+ ),
+ 'details' => '',
+ 'enabled' => $enabled,
+ );
+ };
+
+ $request = new \WP_REST_Request( 'POST', '/wc/v3/pickup-locations' );
+ $request->set_param(
+ 'pickup_locations',
+ array(
+ $branch( 'Downtown', true ),
+ $branch( 'Airport', false ),
+ $branch( 'Harbour', true ),
+ )
+ );
+
+ $this->sut->update_settings( $request );
+
+ $saved = get_option( 'pickup_location_pickup_locations' );
+
+ $this->assertCount( 3, $saved, 'A branch that was switched off is still a branch the merchant has.' );
+ $this->assertSame( 'Downtown', $saved[0]['name'], 'The branch before it should not have moved.' );
+ $this->assertSame( 'Airport', $saved[1]['name'], 'The closed branch should hold its own place.' );
+ $this->assertSame( 'Harbour', $saved[2]['name'], 'And the branch after it should not have moved up.' );
+ $this->assertFalse( $saved[1]['enabled'], 'It should be saved as closed rather than removed.' );
+ }
}