woocommerce-paypal-payments/tests/PHPUnit/StoreSync/CartValidation/ShippingValidatorTest.php
2026-04-30 14:20:42 +02:00

674 lines
20 KiB
PHP

<?php
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\StoreSync\CartValidation;
use WooCommerce\PayPalCommerce\StoreSync\Helper\ProductManager;
use WooCommerce\PayPalCommerce\StoreSync\Schema\PayPalCart;
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;
private $product_manager;
public function setUp(): void {
parent::setUp();
$this->product_manager = \Mockery::mock( ProductManager::class );
$this->validator = new ShippingValidator( $this->product_manager );
}
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( $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( $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' );
}
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->shouldReceive( 'needs_shipping' )->andReturn( false );
$this->product_manager->shouldReceive( 'find_product' )
->andReturn( $product );
$cart = PayPalCart::from_array(
array(
'items' => array(
array(
'item_id' => '1',
'quantity' => 1,
'name' => 'Test Product',
),
),
'payment_method' => 'paypal',
)
);
$result = $this->validator->validate( $cart );
$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( $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( $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( $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( $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( $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
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' );
$this->product_manager->shouldReceive( 'find_product' )
->andReturn( $product );
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( $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', 'PO Box delivery not available' );
// Verify context (context is a list of IssueContext::to_array() results)
$this->assertArrayHasKey( 'context', $issue_data );
$this->assertCount( 1, $issue_data['context'] );
$context = $issue_data['context'][0];
$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'] );
}
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' );
$this->product_manager->shouldReceive( 'find_product' )
->andReturn( $product );
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( $cart );
$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( $cart );
// Street addresses are always valid for signature-required items
$this->assertNull( $result );
}
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' );
$this->product_manager->shouldReceive( 'find_product' )
->andReturn( $product );
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( $cart );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
$this->assertValidationIssue( $result[0]->to_array(), 'SHIPPING_ERROR', 'BUSINESS_RULE', 'shipping_address' );
}
public function test_validate_handles_product_not_found_for_signature_check(): void {
$this->mock_wc_countries(
array( 'US' => 'United States' ),
array( 'US' => 'United States' )
);
$this->product_manager->shouldReceive( 'find_product' )
->andReturn( null );
$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( $cart );
// Should pass because product not found means no signature requirement
$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->shouldReceive( 'needs_shipping' )->andReturn( true );
$this->product_manager->shouldReceive( 'find_product' )
->andReturn( $product );
$cart = PayPalCart::from_array(
array(
'items' => array(
array(
'item_id' => '1',
'quantity' => 1,
'name' => 'Shippable Product',
),
),
'payment_method' => 'paypal',
)
);
$result = $this->validator->validate( $cart );
$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( $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( $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( $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( $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( $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( $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( $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;
}
);
}
}