woocommerce-paypal-payments/tests/PHPUnit/StoreSync/CartValidation/ShippingValidatorTest.php

714 lines
22 KiB
PHP

<?php
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\StoreSync\CartValidation;
use WooCommerce\PayPalCommerce\StoreSync\Schema\PayPalCart;
use WooCommerce\PayPalCommerce\StoreSync\Validation\StoreValidation;
use WooCommerce\PayPalCommerce\StoreSync\Validation\ValidationIssue;
use function Brain\Monkey\Functions\when;
use function Brain\Monkey\Filters\expectApplied;
/**
* @covers \WooCommerce\PayPalCommerce\StoreSync\CartValidation\ShippingValidator
*/
class ShippingValidatorTest extends ValidationTest {
private ShippingValidator $validator;
public function setUp(): void {
parent::setUp();
$this->validator = new ShippingValidator();
}
public function test_validate_returns_null_for_allowed_country(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => '123 Main St',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertNull( $result );
}
public function test_validate_detects_disallowed_country(): void {
$this->mock_wc_countries(
array( 'US' => 'United States', 'FR' => 'France' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'FR',
'address_line_1' => '123 Rue de la Paix',
'admin_area_2' => 'Paris',
'postal_code' => '75001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'BUSINESS_RULE', 'shipping_address.country_code' );
$this->assertIssueContext( $issue_data, 'SHIPPING_NOT_AVAILABLE' );
}
/**
* GIVEN a cart with no shipping address and a virtual product (needs_shipping = false)
* WHEN validate() is called
* THEN null is returned (no shipping required)
*/
public function test_validate_returns_null_for_cart_without_shipping_address(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$product = \Mockery::mock( 'WC_Product' );
$product->allows( 'needs_shipping' )->andReturn( false );
$item = $this->make_store_item( 0, $product );
$cart = PayPalCart::from_array(
array(
'items' => array(
array(
'item_id' => '1',
'quantity' => 1,
'name' => 'Test Product',
),
),
'payment_method' => 'paypal',
),
new StoreValidation()
);
$result = $this->validator->validate(
$this->wrap_in_store_cart( $cart, null, array( $item ) )
);
$this->assertNull( $result );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_validate_passes_country_check_when_wc_not_available(): void {
// WC() must not be defined; run in a separate process so that
// Patchwork stubs from other tests do not leak in.
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'FR',
'address_line_1' => '123 Rue de la Paix',
'admin_area_2' => 'Paris',
'postal_code' => '75001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertNull( $result );
}
// Scenario 1: Invalid Shipping Address Tests
public function test_validate_detects_missing_street_address(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'INVALID_DATA', 'shipping_address.address_line_1', 'missing street address' );
}
public function test_validate_detects_missing_city(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => '123 Main St',
'postal_code' => '10001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'INVALID_DATA', 'shipping_address.admin_area_2', 'missing city' );
}
public function test_validate_detects_missing_postal_code(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => '123 Main St',
'admin_area_2' => 'New York',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'INVALID_DATA', 'shipping_address.postal_code', 'missing postal code' );
}
public function test_validate_detects_multiple_missing_fields(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 3, $result );
$this->assertValidationIssue( $result[0]->to_array(), 'SHIPPING_ERROR', 'INVALID_DATA', 'shipping_address.address_line_1' );
$this->assertValidationIssue( $result[1]->to_array(), 'SHIPPING_ERROR', 'INVALID_DATA', 'shipping_address.admin_area_2' );
$this->assertValidationIssue( $result[2]->to_array(), 'SHIPPING_ERROR', 'INVALID_DATA', 'shipping_address.postal_code' );
}
// Scenario 2: PO Box Restriction Tests
/**
* GIVEN a cart shipping to a PO Box with an item that requires signature delivery
* WHEN validate() is called
* THEN a SHIPPING_ERROR/BUSINESS_RULE issue is returned for 'shipping_address'
* AND the context indicates a PO Box restriction with signature_required reason
* AND two resolution options are offered: UPDATE_ADDRESS and REMOVE_ITEM
*/
public function test_validate_detects_po_box_with_signature_required_items(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$product = \Mockery::mock( 'WC_Product' );
$paypal_item = \Mockery::mock( 'WooCommerce\PayPalCommerce\StoreSync\Schema\CartItem' );
$item = $this->make_store_item( 0, $product, true, 'USD', '1', 1, $paypal_item );
expectApplied( 'woocommerce_paypal_payments_store_sync_item_requires_signature' )
->once()
->andReturn( true );
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => 'PO Box 123',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate(
$this->wrap_in_store_cart( $cart, null, array( $item ) )
);
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'BUSINESS_RULE', 'shipping_address', 'PO Box delivery not available' );
// Verify context (context is a single IssueContext::to_array() result)
$this->assertArrayHasKey( 'context', $issue_data );
$context = $issue_data['context'];
$this->assertArrayHasKey( 'restricted_items', $context );
$this->assertArrayHasKey( 'restriction_reason', $context );
$this->assertArrayHasKey( 'po_box_detected', $context );
$this->assertSame( 'signature_required', $context['restriction_reason'] );
$this->assertTrue( $context['po_box_detected'] );
// Verify resolution_options
$this->assertArrayHasKey( 'resolution_options', $issue_data );
$this->assertCount( 2, $issue_data['resolution_options'] );
$this->assertSame( 'UPDATE_ADDRESS', $issue_data['resolution_options'][0]['action'] );
$this->assertSame( 'REMOVE_ITEM', $issue_data['resolution_options'][1]['action'] );
}
/**
* GIVEN a cart shipping to a PO Box with no items requiring signature delivery
* WHEN validate() is called
* THEN null is returned (PO Box is acceptable when no signature is needed)
*/
public function test_validate_accepts_po_box_without_signature_required_items(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$product = \Mockery::mock( 'WC_Product' );
$paypal_item = \Mockery::mock( 'WooCommerce\PayPalCommerce\StoreSync\Schema\CartItem' );
$item = $this->make_store_item( 0, $product, true, 'USD', '1', 1, $paypal_item );
expectApplied( 'woocommerce_paypal_payments_store_sync_item_requires_signature' )
->once()
->andReturn( false );
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => 'PO Box 123',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate(
$this->wrap_in_store_cart( $cart, null, array( $item ) )
);
$this->assertNull( $result );
}
public function test_validate_accepts_street_address_with_signature_required_items(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
// No need to mock product or filter because street addresses don't trigger signature check
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => '123 Main St',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
// Street addresses are always valid for signature-required items
$this->assertNull( $result );
}
/**
* GIVEN a cart shipping to a PO Box written as 'P.O. Box' (with dots and spaces)
* AND an item requires signature delivery
* WHEN validate() is called
* THEN a SHIPPING_ERROR issue is returned (PO Box detection is format-insensitive)
*/
public function test_validate_detects_po_box_with_dots_and_spaces(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$product = \Mockery::mock( 'WC_Product' );
$paypal_item = \Mockery::mock( 'WooCommerce\PayPalCommerce\StoreSync\Schema\CartItem' );
$item = $this->make_store_item( 0, $product, true, 'USD', '1', 1, $paypal_item );
expectApplied( 'woocommerce_paypal_payments_store_sync_item_requires_signature' )
->once()
->andReturn( true );
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => 'P.O. Box 456',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate(
$this->wrap_in_store_cart( $cart, null, array( $item ) )
);
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertValidationIssue( $result[0]->to_array(), 'SHIPPING_ERROR', 'BUSINESS_RULE', 'shipping_address' );
}
/**
* GIVEN a cart with a PO Box address and a cart with no items (empty store items)
* WHEN validate() is called
* THEN null is returned (no items means no signature requirement)
*/
public function test_validate_accepts_po_box_when_cart_has_no_items(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => 'PO Box 789',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
// No store items means no signature-required items are found
$result = $this->validator->validate(
$this->wrap_in_store_cart( $cart, null, array() )
);
$this->assertNull( $result );
}
/**
* Given a cart containing a shippable product and no shipping address
* When validate() is called
* Then a ValidationIssue for the 'shipping_address' field is returned
* And the issue asks the customer to provide a shipping address
* And a PROVIDE_MISSING_FIELD resolution option is included
*/
public function test_validate_returns_missing_address_issue_when_cart_needs_shipping(): void {
$product = \Mockery::mock( 'WC_Product' );
$product->allows( 'needs_shipping' )->andReturn( true );
$item = $this->make_store_item( 0, $product );
$cart = PayPalCart::from_array(
array(
'items' => array(
array(
'item_id' => '1',
'quantity' => 1,
'name' => 'Shippable Product',
),
),
'payment_method' => 'paypal',
),
new StoreValidation()
);
$result = $this->validator->validate(
$this->wrap_in_store_cart( $cart, null, array( $item ) )
);
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'DATA_ERROR', 'MISSING_FIELD', 'shipping_address', 'Shipping address is required' );
$this->assertArrayHasKey( 'resolution_options', $issue_data );
$actions = array_column( $issue_data['resolution_options'], 'action' );
$this->assertContains( 'PROVIDE_MISSING_FIELD', $actions );
}
/**
* Given a cart shipping to a US address with a postal code containing invalid characters
* When validate() is called
* Then a ValidationIssue for 'shipping_address.postal_code' is returned
* And the issue message mentions an invalid postal code format
*/
public function test_validate_detects_invalid_postal_code_format(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => '123 Main St',
'admin_area_2' => 'New York',
'postal_code' => '100!01',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'INVALID_DATA', 'shipping_address.postal_code', 'Invalid postal code format' );
}
// Gap A3: Fallback from empty shipping countries to allowed countries
/**
* Given WooCommerce has no shipping-specific countries configured (empty list)
* And the allowed-countries list contains US
* And the cart ships to US
* When validate() is called
* Then no country issue is returned (US is permitted via the allowed-countries fallback)
*/
public function test_validate_falls_back_to_allowed_countries_when_shipping_countries_is_empty(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array() // empty shipping countries → fallback to allowed countries
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => '123 Main St',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertNull( $result );
}
// Group B: PayPal-level US-only restriction
/**
* Given PayPal's supported-country allowlist only includes the United States
* And WooCommerce also allows US
* And the cart ships to US
* When validate() is called
* Then no PayPal-restriction issue is returned
*/
public function test_validate_accepts_us_shipping_address_for_paypal_restriction(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'address_line_1' => '123 Main St',
'admin_area_2' => 'New York',
'postal_code' => '10001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertNull( $result );
}
/**
* Given PayPal's supported-country allowlist only includes the United States
* And WooCommerce allows both US and CA
* And the cart ships to CA (Canada)
* When validate() is called
* Then a ValidationIssue is returned indicating CA is not supported by PayPal
* And the issue targets 'shipping_address.country_code'
* And the user message mentions that only specific countries are currently supported
*/
public function test_validate_rejects_canada_shipping_address_due_to_paypal_restriction(): void {
$this->mock_wc_countries(
array( 'US' => 'United States', 'CA' => 'Canada' ),
array( 'US' => 'United States', 'CA' => 'Canada' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'CA',
'address_line_1' => '456 Maple Ave',
'admin_area_2' => 'Toronto',
'postal_code' => 'M5V 3A8',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'BUSINESS_RULE', 'shipping_address.country_code' );
$context = $this->assertIssueContext( $issue_data, 'SHIPPING_NOT_AVAILABLE' );
$this->assertSame( 'CA', $context['destination_country'] );
}
/**
* Given PayPal's supported-country allowlist only includes the United States
* And WooCommerce allows both US and DE
* And the cart ships to DE (Germany)
* When validate() is called
* Then a ValidationIssue is returned indicating DE is not supported by PayPal
* And the issue targets 'shipping_address.country_code'
*/
public function test_validate_rejects_germany_shipping_address_due_to_paypal_restriction(): void {
$this->mock_wc_countries(
array( 'US' => 'United States', 'DE' => 'Germany' ),
array( 'US' => 'United States', 'DE' => 'Germany' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'DE',
'address_line_1' => '1 Unter den Linden',
'admin_area_2' => 'Berlin',
'postal_code' => '10117',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertInstanceOf( ValidationIssue::class, $result[0] );
$issue_data = $result[0]->to_array();
$this->assertValidationIssue( $issue_data, 'SHIPPING_ERROR', 'BUSINESS_RULE', 'shipping_address.country_code' );
}
/**
* Given a country is disallowed by both WooCommerce settings and the PayPal country
* restriction
* And the cart ships to FR (France), which WooCommerce does not allow and PayPal does not
* support When validate() is called Then exactly one country-level ValidationIssue is returned
* (checks do not stack)
*/
public function test_validate_produces_single_country_issue_when_disallowed_by_both_woocommerce_and_paypal(): void {
$this->mock_wc_countries(
array( 'US' => 'United States', 'FR' => 'France' ),
array( 'US' => 'United States' ) // FR not in shipping countries
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'FR',
'address_line_1' => '123 Rue de la Paix',
'admin_area_2' => 'Paris',
'postal_code' => '75001',
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$country_issues = array_filter(
$result,
static function ( ValidationIssue $issue ): bool {
$data = $issue->to_array();
return isset( $data['field'] ) && $data['field'] === 'shipping_address.country_code';
}
);
$this->assertCount( 1, $country_issues );
}
// ---------------------------------------------------------------------------
// Context assertions
// ---------------------------------------------------------------------------
/**
* GIVEN a cart shipping to a US address that is missing the street address field
* WHEN validate() is called
* THEN a ValidationIssue is returned for shipping_address.address_line_1
* AND context[0]['specific_issue'] === 'SHIPPING_ADDRESS_UNSERVICEABLE'
*/
public function test_validate_address_completeness_issue_includes_shipping_address_unserviceable_context(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$cart = $this->create_cart_with_shipping(
array(
'country_code' => 'US',
'admin_area_2' => 'New York',
'postal_code' => '10001',
// address_line_1 intentionally omitted
)
);
$result = $this->validator->validate( $this->wrap_in_store_cart( $cart ) );
$this->assertIsArray( $result );
$this->assertGreaterThanOrEqual( 1, count( $result ) );
// Isolate the street-address completeness issue.
$street_issues = array_values(
array_filter(
$result,
static function ( ValidationIssue $issue ): bool {
$data = $issue->to_array();
return isset( $data['field'] ) && $data['field'] === 'shipping_address.address_line_1';
}
)
);
$this->assertCount( 1, $street_issues );
$issue_data = $street_issues[0]->to_array();
$this->assertIssueContext( $issue_data, 'SHIPPING_ADDRESS_UNSERVICEABLE' );
}
private function mock_wc_countries( array $all_countries, array $shipping_countries ): void {
$countries_mock = \Mockery::mock( 'WC_Countries' );
$countries_mock->allows( 'get_countries' )->andReturn( $all_countries );
$countries_mock->allows( 'get_allowed_countries' )->andReturn( $all_countries );
$countries_mock->allows( 'get_shipping_countries' )->andReturn( $shipping_countries );
when( 'WC' )->alias(
function () use ( $countries_mock ) {
$wc = new \stdClass();
$wc->countries = $countries_mock;
return $wc;
}
);
}
}