diff --git a/plugins/newspack-blocks/includes/class-modal-checkout.php b/plugins/newspack-blocks/includes/class-modal-checkout.php index fd4fc11df9..437c3740e7 100644 --- a/plugins/newspack-blocks/includes/class-modal-checkout.php +++ b/plugins/newspack-blocks/includes/class-modal-checkout.php @@ -29,6 +29,17 @@ final class Modal_Checkout { */ const CHECKOUT_REGISTRATION_ORDER_META_KEY = '_newspack_checkout_registration_meta'; + /** + * Billing fields with server-side format validation in the Store API, + * mapped from their classic checkout keys to Store API address keys. + * + * @var string[] + */ + const STORE_API_VALIDATED_ADDRESS_FIELDS = [ + 'billing_state' => 'state', + 'billing_postcode' => 'postcode', + ]; + /** * Whether the modal checkout has been enqueued. * @@ -157,6 +168,8 @@ public static function init() { add_filter( 'woocommerce_get_checkout_order_received_url', [ __CLASS__, 'woocommerce_get_return_url' ], 10, 2 ); add_filter( 'wc_get_template', [ __CLASS__, 'wc_get_template' ], 10, 2 ); add_filter( 'woocommerce_checkout_fields', [ __CLASS__, 'woocommerce_checkout_fields' ] ); + add_filter( 'rest_pre_dispatch', [ __CLASS__, 'scrub_store_api_checkout_address' ], 10, 3 ); + add_filter( 'woocommerce_get_country_locale', [ __CLASS__, 'relax_configured_off_locale_fields' ] ); add_filter( 'woocommerce_update_order_review_fragments', [ __CLASS__, 'order_review_fragments' ] ); add_filter( 'woocommerce_cart_needs_payment', [ __CLASS__, 'cart_needs_payment' ] ); add_filter( 'newspack_recaptcha_verify_captcha', [ __CLASS__, 'recaptcha_verify_captcha' ], 10, 3 ); @@ -1332,7 +1345,13 @@ public static function show_admin_bar( $show ) { } /** - * Modify fields for modal checkout. + * Remove the configured-off billing fields from modal checkout requests. + * + * Deliberately scoped to modal checkout: some publishers rely on standard + * Woo checkout flows that predate Audience Management, so those keep the + * stock field set. The express checkout gap this leaves (wallets submitting + * values for fields the buyer cannot see) is handled for modal-originated + * requests by scrub_store_api_checkout_address() below. * * @param array $fields Checkout fields. * @@ -1343,8 +1362,8 @@ public static function woocommerce_checkout_fields( $fields ) { return $fields; } $cart = \WC()->cart; - // Don't modify fields if shipping is required. - if ( $cart->needs_shipping_address() ) { + // Don't modify fields if there is no cart or shipping is required. + if ( ! $cart || $cart->needs_shipping_address() ) { return $fields; } /** @@ -1375,6 +1394,199 @@ public static function woocommerce_checkout_fields( $fields ) { return $fields; } + /** + * Scrub invalid address values from Store API checkout requests when the + * corresponding billing fields are configured off. + * + * Express checkout wallets submit to wc/store/v1/checkout and can supply + * values the buyer never sees or corrects (e.g. Apple Pay sending a suburb + * as the state), which hard-fails Store API address validation. Values the + * validation would accept are left untouched, and only the billing address + * is scrubbed. Runs on rest_pre_dispatch because the validation happens + * during dispatch. + * + * The shipping address is intentionally out of scope. Carts that need a + * shipping address bail below, and wallets return only billing contact when + * shipping is not requested, so a virtual-cart request carries no shipping + * address to scrub. + * + * Scoped to requests originating from the modal checkout, so any Store API + * checkout outside the modal (e.g. the blocks checkout page or express + * buttons on product pages) keeps stock behavior. + * + * @param mixed $result Response to replace the requested version with. + * @param \WP_REST_Server $server Server instance. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return mixed + */ + public static function scrub_store_api_checkout_address( $result, $server, $request ) { + if ( null !== $result ) { + return $result; + } + + if ( + ! $request instanceof \WP_REST_Request || + 'POST' !== $request->get_method() || + 0 !== strpos( $request->get_route(), '/wc/store/v1/checkout' ) + ) { + return $result; + } + + if ( ! self::is_modal_checkout_referer() ) { + return $result; + } + + if ( ! function_exists( 'WC' ) || ! class_exists( 'WC_Validation' ) ) { + return $result; + } + + $billing_fields = apply_filters( 'newspack_blocks_donate_billing_fields_keys', [] ); + + if ( empty( $billing_fields ) ) { + return $result; + } + + // Physical-goods flows are never modified. The cart is not always + // initialized this early in Store API requests; when it is, bail for + // carts that need a shipping address. Only the billing address is + // scrubbed, so shipping data is never touched either way. + if ( \WC()->cart && \WC()->cart->needs_shipping_address() ) { + return $result; + } + + $address = $request->get_param( 'billing_address' ); + + if ( is_array( $address ) ) { + $scrubbed = self::scrub_invalid_address_values( $address, $billing_fields ); + + if ( $scrubbed !== $address ) { + $request->set_param( 'billing_address', $scrubbed ); + } + } + + return $result; + } + + /** + * Whether the current request originates from the modal checkout, based on + * the referer. + * + * Express checkout submissions go to the Store API as JSON, so the usual + * modal_checkout request params are absent. The requests are made from the + * modal checkout iframe, whose URL carries modal_checkout=1, so it shows up + * as the (same-origin) referer. + * + * @return bool + */ + private static function is_modal_checkout_referer() { + $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( ! $referer ) { + return false; + } + + $query_string = \wp_parse_url( $referer, PHP_URL_QUERY ); + \wp_parse_str( (string) $query_string, $query_params ); + + return ! empty( $query_params['modal_checkout'] ); + } + + /** + * Drop configured-off address values that would fail Store API validation. + * + * @param array $address Store API address (billing or shipping). + * @param string[] $billing_fields Configured billing field keys. + * + * @return array Scrubbed address. + */ + private static function scrub_invalid_address_values( $address, $billing_fields ) { + $country = isset( $address['country'] ) ? $address['country'] : ''; + + if ( + ! in_array( 'billing_state', $billing_fields, true ) && + ! empty( $address['state'] ) && + is_string( $address['state'] ) && + $country + ) { + $states = \WC()->countries->get_states( $country ); + + if ( is_array( $states ) && ! empty( $states ) ) { + $code_match = array_key_exists( strtoupper( $address['state'] ), array_change_key_case( $states, CASE_UPPER ) ); + $name_match = in_array( strtolower( $address['state'] ), array_map( 'strtolower', $states ), true ); + + if ( ! $code_match && ! $name_match ) { + $address['state'] = ''; + } + } + } + + if ( + ! in_array( 'billing_postcode', $billing_fields, true ) && + ! empty( $address['postcode'] ) && + is_string( $address['postcode'] ) && + ! \WC_Validation::is_postcode( $address['postcode'], $country ) + ) { + $address['postcode'] = ''; + } + + return $address; + } + + /** + * Relax locale-required flags for configured-off billing fields. + * + * After an invalid wallet value is scrubbed (see + * scrub_store_api_checkout_address), the Store API order validation would + * still reject the checkout when the country locale marks the field as + * required. A field the site is configured not to collect cannot be + * required. + * + * Scoped to modal-originated requests so standard Woo flows keep the stock + * locale rules. + * + * @param array $locale Country locale field settings. + * + * @return array + */ + public static function relax_configured_off_locale_fields( $locale ) { + if ( ! self::is_modal_checkout_referer() && ! self::is_modal_checkout() ) { + return $locale; + } + + $billing_fields = apply_filters( 'newspack_blocks_donate_billing_fields_keys', [] ); + + if ( empty( $billing_fields ) ) { + return $locale; + } + + // Never relax requirements when the cart needs a shipping address: + // physical-goods flows keep the full locale rules. + if ( function_exists( 'WC' ) && \WC()->cart && \WC()->cart->needs_shipping_address() ) { + return $locale; + } + + $off = []; + + foreach ( self::STORE_API_VALIDATED_ADDRESS_FIELDS as $config_key => $address_key ) { + if ( ! in_array( $config_key, $billing_fields, true ) ) { + $off[] = $address_key; + } + } + + if ( empty( $off ) ) { + return $locale; + } + + foreach ( array_keys( $locale ) as $country ) { + foreach ( $off as $address_key ) { + $locale[ $country ][ $address_key ]['required'] = false; + } + } + + return $locale; + } + /** * If WooCommerce Subscriptions Gifting extension is available, render its fields. * diff --git a/plugins/newspack-blocks/tests/mocks/newspack-plugin-mocks.php b/plugins/newspack-blocks/tests/mocks/newspack-plugin-mocks.php new file mode 100644 index 0000000000..f86d52b421 --- /dev/null +++ b/plugins/newspack-blocks/tests/mocks/newspack-plugin-mocks.php @@ -0,0 +1,21 @@ +countries = new class() { + /** + * Get states for a country. + * + * @param string $country Country code. + * + * @return array + */ + public function get_states( $country ) { + return 'US' === $country ? [ 'CA' => 'California' ] : []; + } + }; + } + + /** + * Mimic WC()->payment_gateways() with no registered gateways. + * + * @return object + */ + public function payment_gateways() { + return new class() { + /** + * Get registered gateways. + * + * @return array + */ + public function payment_gateways() { + return []; + } + }; + } + }; + } + return $newspack_blocks_test_wc; + } +} + +if ( ! class_exists( 'WC_Validation' ) ) { + /** + * Mock WooCommerce postcode validation. + */ + class WC_Validation { + /** + * Mock postcode check: only the literal "INVALID" fails. + * + * @param string $postcode Postcode. + * @param string $country Country code. + * + * @return bool + */ + public static function is_postcode( $postcode, $country ) { + unset( $country ); + return 'INVALID' !== $postcode; + } + } +} + +if ( ! function_exists( 'wc_get_checkout_url' ) ) { + /** + * Mock WooCommerce checkout URL helper. + * + * @return string + */ + function wc_get_checkout_url() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Mock WooCommerce global. + return 'https://example.com/checkout/'; + } +} + class ModalCheckoutTest extends WP_UnitTestCase_Blocks { // phpcs:ignore /** * Clean up request data. */ public function tear_down() { - global $newspack_blocks_test_limited_product_id, $newspack_blocks_test_limited_user_id; + global $newspack_blocks_test_limited_product_id, $newspack_blocks_test_limited_user_id, $newspack_blocks_test_wc; $newspack_blocks_test_limited_product_id = null; $newspack_blocks_test_limited_user_id = null; + $newspack_blocks_test_wc = null; + if ( property_exists( \Newspack\WooCommerce_My_Account::class, 'is_from_my_account' ) ) { + \Newspack\WooCommerce_My_Account::$is_from_my_account = false; + } remove_all_filters( 'woocommerce_cart_item_removed_message' ); - unset( $_POST['billing_email'], $_POST['post_data'], $_REQUEST['modal_checkout'], $_REQUEST['post_data'] ); + remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); + unset( $_POST['billing_email'], $_POST['post_data'], $_REQUEST['modal_checkout'], $_REQUEST['post_data'], $_SERVER['HTTP_REFERER'] ); parent::tear_down(); } @@ -416,4 +523,453 @@ public function test_get_cart_product_summary_returns_empty_for_unsupported_cart ) ); } + + /** + * Set WC()->cart to a cart double with a controllable needs_shipping_address(). + * + * @param bool $needs_shipping Whether the cart needs a shipping address. + */ + private function set_mock_checkout_cart( $needs_shipping = false ) { + WC()->cart = new class( $needs_shipping ) { + /** + * Whether the cart needs a shipping address. + * + * @var bool + */ + private $needs_shipping; + + /** + * Constructor. + * + * @param bool $needs_shipping Whether the cart needs a shipping address. + */ + public function __construct( $needs_shipping ) { + $this->needs_shipping = $needs_shipping; + } + + /** + * Whether the cart needs a shipping address. + * + * @return bool + */ + public function needs_shipping_address() { + return $this->needs_shipping; + } + }; + } + + /** + * Configure the billing fields returned by the config filter. + * + * @param string[] $billing_fields Billing field keys. + */ + private function set_configured_billing_fields( $billing_fields ) { + add_filter( + 'newspack_blocks_donate_billing_fields_keys', + function() use ( $billing_fields ) { + return $billing_fields; + } + ); + } + + /** + * Mark the current request as a modal checkout request. + */ + private function set_modal_checkout_request() { + $_REQUEST['modal_checkout'] = '1'; + } + + /** + * Set a referer pointing at the modal checkout iframe, as express checkout + * Store API requests carry. + */ + private function set_modal_checkout_referer() { + $_SERVER['HTTP_REFERER'] = 'https://example.com/checkout/?modal_checkout=1'; + } + + /** + * Checkout fields fixture resembling the WooCommerce default structure. + * + * @return array + */ + private function get_checkout_fields_fixture() { + return [ + 'billing' => [ + 'billing_first_name' => [ 'label' => 'First name' ], + 'billing_last_name' => [ 'label' => 'Last name' ], + 'billing_country' => [ 'label' => 'Country' ], + 'billing_state' => [ 'label' => 'State' ], + 'billing_phone' => [ 'label' => 'Phone' ], + 'billing_email' => [ 'label' => 'Email' ], + ], + 'shipping' => [ + 'shipping_first_name' => [ 'label' => 'First name' ], + ], + 'order' => [ + 'order_comments' => [ 'label' => 'Order notes' ], + ], + ]; + } + + /** + * Configured-off billing fields are removed on modal checkout requests. + */ + public function test_checkout_fields_removes_configured_off_fields_on_modal_requests() { + $this->set_modal_checkout_request(); + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $this->get_checkout_fields_fixture() ); + + $this->assertSame( + [ 'billing_first_name', 'billing_email' ], + array_keys( $fields['billing'] ), + 'Billing fields not in the configured list should be removed.' + ); + $this->assertArrayHasKey( + 'shipping_first_name', + $fields['shipping'], + 'Shipping fields should be untouched.' + ); + } + + /** + * Standard (non-modal) Woo checkouts keep the stock field set, by design: + * publisher flows that predate Audience Management must not change. + */ + public function test_checkout_fields_noop_on_standard_checkout() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged on standard checkout requests.' + ); + } + + /** + * With no custom billing fields configured, checkout fields are unchanged. + */ + public function test_checkout_fields_noop_when_no_fields_configured() { + $this->set_modal_checkout_request(); + $this->set_mock_checkout_cart(); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged when no custom billing fields are configured.' + ); + } + + /** + * Carts that need a shipping address keep the full field set. + */ + public function test_checkout_fields_noop_when_cart_needs_shipping() { + $this->set_modal_checkout_request(); + $this->set_mock_checkout_cart( true ); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged when the cart needs a shipping address.' + ); + } + + /** + * Contexts without a cart keep the full field set. + */ + public function test_checkout_fields_noop_when_cart_unavailable() { + $this->set_modal_checkout_request(); + WC()->cart = null; + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged when no cart is available.' + ); + } + + /** + * My Account checkouts keep the full field set (they relax required flags + * instead of removing fields). + */ + public function test_checkout_fields_noop_for_my_account_checkout() { + if ( ! property_exists( \Newspack\WooCommerce_My_Account::class, 'is_from_my_account' ) ) { + $this->markTestSkipped( 'The WooCommerce_My_Account mock is not in use.' ); + } + + $this->set_modal_checkout_request(); + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + \Newspack\WooCommerce_My_Account::$is_from_my_account = true; + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged for My Account checkouts.' + ); + } + + /** + * The billing phone field gets the form-row-last class when configured. + */ + public function test_checkout_fields_billing_phone_gets_form_row_last_class() { + $this->set_modal_checkout_request(); + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email', 'billing_phone' ] ); + + $fields = \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $this->get_checkout_fields_fixture() ); + + $this->assertSame( + 'form-row-last', + $fields['billing']['billing_phone']['class'], + 'The billing phone field should get the form-row-last class.' + ); + } + + /** + * Build a Store API checkout request with the given billing address. + * + * @param array $billing_address Billing address. + * + * @return WP_REST_Request + */ + private function get_store_api_checkout_request( $billing_address ) { + $this->set_modal_checkout_referer(); + + $request = new WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_param( 'billing_address', $billing_address ); + + return $request; + } + + /** + * An invalid state is scrubbed from Store API checkout requests when the + * state field is configured off. + */ + public function test_store_api_scrub_drops_invalid_state_when_configured_off() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $request = $this->get_store_api_checkout_request( + [ + 'country' => 'US', + 'state' => 'REMUERA', + 'postcode' => '94043', + ] + ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $address = $request->get_param( 'billing_address' ); + + $this->assertSame( '', $address['state'], 'An invalid state should be scrubbed.' ); + $this->assertSame( '94043', $address['postcode'], 'A valid postcode should be kept.' ); + } + + /** + * Valid state values pass through untouched, whether provided as a code or + * a name. + */ + public function test_store_api_scrub_keeps_valid_state_values() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + foreach ( [ 'CA', 'ca', 'California' ] as $state ) { + $request = $this->get_store_api_checkout_request( + [ + 'country' => 'US', + 'state' => $state, + ] + ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( + $state, + $request->get_param( 'billing_address' )['state'], + "A valid state value ({$state}) should be left untouched." + ); + } + } + + /** + * An invalid postcode is scrubbed when the postcode field is configured off. + */ + public function test_store_api_scrub_drops_invalid_postcode_when_configured_off() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $request = $this->get_store_api_checkout_request( + [ + 'country' => 'US', + 'postcode' => 'INVALID', + ] + ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( '', $request->get_param( 'billing_address' )['postcode'], 'An invalid postcode should be scrubbed.' ); + } + + /** + * Nothing is scrubbed when the fields are part of the configured list. + */ + public function test_store_api_scrub_noop_when_fields_configured_on() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email', 'billing_state', 'billing_postcode' ] ); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + 'postcode' => 'INVALID', + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Configured-on fields should never be scrubbed.' ); + } + + /** + * Nothing is scrubbed with the default (empty) configuration. + */ + public function test_store_api_scrub_noop_for_default_config() { + $this->set_mock_checkout_cart(); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Default configuration should never be scrubbed.' ); + } + + /** + * Carts needing a shipping address are never scrubbed. + */ + public function test_store_api_scrub_bails_for_shipping_carts() { + $this->set_mock_checkout_cart( true ); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Shipping carts should never be scrubbed.' ); + } + + /** + * Requests not originating from the modal checkout are never scrubbed, + * keeping standard Woo and blocks checkout flows stock. + */ + public function test_store_api_scrub_noop_without_modal_referer() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + ]; + $request = $this->get_store_api_checkout_request( $address ); + unset( $_SERVER['HTTP_REFERER'] ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Requests without a modal checkout referer should never be scrubbed.' ); + } + + /** + * Array-valued state/postcode do not trigger a fatal on the string transforms; + * the schema is left to reject them with its own clean error. + */ + public function test_store_api_scrub_ignores_non_string_values() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $address = [ + 'country' => 'US', + 'state' => [ 'REMUERA' ], + 'postcode' => [ 'INVALID' ], + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Non-string address values should be left for the schema to reject.' ); + } + + /** + * Locale required flags are relaxed for configured-off state and postcode. + */ + public function test_locale_relaxation_for_configured_off_fields() { + $this->set_modal_checkout_referer(); + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $locale = [ + 'US' => [ + 'state' => [ 'required' => true ], + 'postcode' => [ 'required' => true ], + 'city' => [ 'required' => true ], + ], + ]; + + $relaxed = \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ); + + $this->assertFalse( $relaxed['US']['state']['required'], 'Configured-off state should not be required.' ); + $this->assertFalse( $relaxed['US']['postcode']['required'], 'Configured-off postcode should not be required.' ); + $this->assertTrue( $relaxed['US']['city']['required'], 'Fields without Store API validation should be untouched.' ); + } + + /** + * Locale required flags are untouched outside modal requests and, within + * them, for shipping carts, configured-on fields, and the default + * configuration. + */ + public function test_locale_relaxation_noop_cases() { + $locale = [ + 'US' => [ 'state' => [ 'required' => true ] ], + ]; + + // No modal checkout referer or request param. + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Non-modal requests should not relax locale fields.' ); + remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); + + // Default (empty) configuration. + $this->set_modal_checkout_referer(); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Default configuration should not relax locale fields.' ); + + // Configured-on fields. + $this->set_configured_billing_fields( [ 'billing_state', 'billing_postcode' ] ); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Configured-on fields should not be relaxed.' ); + + // Shipping carts. + remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + $this->set_mock_checkout_cart( true ); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Shipping carts should not be relaxed.' ); + } }