diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index e02629141..252f363ad 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -12,7 +12,7 @@ jobs: name: PHP ${{ matrix.php-versions }} WC ${{ matrix.wc-versions }} steps: - - uses: ddev/github-action-setup-ddev@v1 + - uses: ddev/github-action-setup-ddev@1c7ef18595da42355373cb6d9417a6f44d758b93 # v1.10.1 with: autostart: false diff --git a/.github/workflows/package-new.yml b/.github/workflows/package-new.yml index 2b12eca03..01303699e 100644 --- a/.github/workflows/package-new.yml +++ b/.github/workflows/package-new.yml @@ -27,7 +27,7 @@ jobs: create_archive: needs: check_version - uses: inpsyde/reusable-workflows/.github/workflows/build-plugin-archive.yml@main + uses: inpsyde/reusable-workflows/.github/workflows/build-plugin-archive.yml@a9af34f34e95cbe18703198c7e972e97ebcd7473 with: PHP_VERSION: 7.4 NODE_VERSION: 22 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index c3e07d1bb..aa66cb1fa 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1 with: php-version: 7.4 diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 00ff24fcb..1eaa00b41 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1 with: php-version: ${{ matrix.php-versions }} @@ -22,7 +22,7 @@ jobs: run: composer validate - name: Install dependencies - uses: ramsey/composer-install@v1 + uses: ramsey/composer-install@a7320a0581dcd0432930c48a0e7ced67e6ec17e8 # v1.3.0 with: composer-options: "--prefer-dist" diff --git a/.github/workflows/spell-check.yml b/.github/workflows/spell-check.yml index 14c28abac..78ea83c0d 100644 --- a/.github/workflows/spell-check.yml +++ b/.github/workflows/spell-check.yml @@ -14,7 +14,7 @@ jobs: - name: Check spelling id: spelling - uses: crate-ci/typos@v1.30.2 + uses: crate-ci/typos@7bc041cbb7ca9167c9e0e4ccbb26f48eb0f9d4e0 # v1.30.2 with: # Path to config file config: .github/workflows-config/typos.toml diff --git a/changelog.txt b/changelog.txt index 77916063c..b041f4d38 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,21 @@ *** Changelog *** += 3.0.7 - xxxx-xx-xx = +* Enhancement - Deprecate `application_context` in favor of `experience_context` object #3431 + **NOTE**: If you were extending/modifying the `application_context` object programmatically, you may need to update your code to utilize `experience_context` for your customizations. +* Enhancement - Add Contact Module feature +* Enhancement - Add WooCommerce Tracks integration +* Enhancement - Onboarding notification for Firefox browser #3433 +* Enhancement - Reset BN code on plugin uninstall #3471 +* Enhancement - Add "Stay updated with PayPal" option in the old and new settings UI #3430 +* Enhancement - Add French Territories to the supported ACDC countries list #3438 +* Enhancement - Auto-enable logging during onboarding #3369 +* Fix - DUPLICATE_INVOICE_ID in Sandbox due to missing invoice prefix #3435 +* Fix - Subscription product could not be unlinked from PayPal Subscription #3429 +* Fix - PayPal button greyed out on single product page for variable products with >2 attributes #3395 +* Fix - APMs automatically enabled despite selecting "No, ..." during onboarding #3362 +* Fix - Ditch items logic does not work when using saved card payment #3476 + = 3.0.6 - 2025-05-27 = * Enhancement - Implement 3D secure check for Google Pay #3163 * Enhancement - Add options for "Disable Credit Cards" and "Language" #3226 diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index e6a5c05da..b1101dec7 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -2145,7 +2145,7 @@ return array( $feature_enabled = (bool) apply_filters( // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores -- feature flags use this convention 'woocommerce.feature-flags.woocommerce_paypal_payments.contact_module_enabled', - getenv( 'PCP_CONTACT_MODULE_ENABLED' ) === '1' + getenv( 'PCP_CONTACT_MODULE_ENABLED' ) !== '0' ); /** diff --git a/package.json b/package.json index bf73a6a18..2d4ef6f40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-paypal-payments", - "version": "3.0.6", + "version": "3.0.7", "description": "WooCommerce PayPal Payments", "repository": "https://github.com/woocommerce/woocommerce-paypal-payments", "license": "GPL-2.0", diff --git a/readme.txt b/readme.txt index fe13ec28b..840fe2d93 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: woocommerce, paypal, payments, ecommerce, credit card Requires at least: 6.5 Tested up to: 6.8 Requires PHP: 7.4 -Stable tag: 3.0.6 +Stable tag: 3.0.7 License: GPLv2 License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -156,6 +156,22 @@ If you encounter issues with the PayPal buttons not appearing after an update, p == Changelog == += 3.0.7 - xxxx-xx-xx = +* Enhancement - Deprecate `application_context` in favor of `experience_context` object #3431 + **NOTE**: If you were extending/modifying the `application_context` object programmatically, you may need to update your code to utilize `experience_context` for your customizations. +* Enhancement - Add Contact Module feature +* Enhancement - Add WooCommerce Tracks integration +* Enhancement - Onboarding notification for Firefox browser #3433 +* Enhancement - Reset BN code on plugin uninstall #3471 +* Enhancement - Add "Stay updated with PayPal" option in the old and new settings UI #3430 +* Enhancement - Add French Territories to the supported ACDC countries list #3438 +* Enhancement - Auto-enable logging during onboarding #3369 +* Fix - DUPLICATE_INVOICE_ID in Sandbox due to missing invoice prefix #3435 +* Fix - Subscription product could not be unlinked from PayPal Subscription #3429 +* Fix - PayPal button greyed out on single product page for variable products with >2 attributes #3395 +* Fix - APMs automatically enabled despite selecting "No, ..." during onboarding #3362 +* Fix - Ditch items logic does not work when using saved card payment #3476 + = 3.0.6 - 2025-05-27 = * Enhancement - Implement 3D secure check for Google Pay #3163 * Enhancement - Add options for "Disable Credit Cards" and "Language" #3226 diff --git a/tests/integration/PHPUnit/Factories/CouponFactory.php b/tests/integration/PHPUnit/Factories/CouponFactory.php new file mode 100644 index 000000000..b1b53cd30 --- /dev/null +++ b/tests/integration/PHPUnit/Factories/CouponFactory.php @@ -0,0 +1,92 @@ +createCoupon($preset); + } + + /** + * @param array $preset + * @return WC_Coupon + */ + private function createCoupon(array $preset): WC_Coupon + { + $coupon = new WC_Coupon(); + $coupon->set_code($preset['coupon_code']); + $coupon->set_discount_type($preset['type']); + $coupon->set_amount($preset['amount']); + $coupon->set_status('publish'); + $coupon->save(); + + $this->created_coupon_ids[] = $coupon->get_id(); + + return $coupon; + } + + /** + * @param string $coupon_code + * @return bool + */ + public function exists(string $coupon_code): bool + { + return (bool)wc_get_coupon_id_by_code($coupon_code); + } + + /** + * @param string $coupon_code + * @return WC_Coupon|null + */ + public function getByCode(string $coupon_code): ?WC_Coupon + { + $coupon_id = wc_get_coupon_id_by_code($coupon_code); + + return $coupon_id ? new WC_Coupon($coupon_id) : null; + } + + /** + * Delete all created coupons + */ + public function cleanup(): void + { + foreach ($this->created_coupon_ids as $coupon_id) { + wp_delete_post($coupon_id, true); + } + + $this->created_coupon_ids = []; + } + + /** + * @return array + */ + public function getCreatedIds(): array + { + return $this->created_coupon_ids; + } +} diff --git a/tests/integration/PHPUnit/Factories/OrderFactory.php b/tests/integration/PHPUnit/Factories/OrderFactory.php new file mode 100644 index 000000000..ebeaca71b --- /dev/null +++ b/tests/integration/PHPUnit/Factories/OrderFactory.php @@ -0,0 +1,242 @@ +product_factory = $product_factory ?? new ProductFactory(); + $this->coupon_factory = $coupon_factory ?? new CouponFactory(); + } + + /** + * @param int $customer_id + * @param string $payment_method + * @param array $product_presets + * @param array $discount_presets + * @param bool $set_paid + * @return WC_Order + * @throws \WC_Data_Exception + */ + public function create( + int $customer_id, + string $payment_method, + array $product_presets, + array $discount_presets = [], + bool $set_paid = true + ): WC_Order + { + $products = $this->resolveProductPresets($product_presets); + $discounts = $this->resolveDiscountPresets($discount_presets); + + $order = wc_create_order([ + 'customer_id' => $customer_id, + 'set_paid' => $set_paid, + ]); + + if (is_wp_error($order)) { + throw new \WC_Data_Exception('order_creation_failed', 'Failed to create order'); + } + + $this->setBillingAddress($order); + $this->addProductsToOrder($order, $products); + $this->applyDiscountsToOrder($order, $discounts); + + $order->set_payment_method($payment_method); + $order->calculate_totals(); + $order->save(); + + return $order; + } + + /** + * @param WC_Order $order + */ + private function setBillingAddress(WC_Order $order): void + { + $order->set_billing_first_name('John'); + $order->set_billing_last_name('Doe'); + $order->set_billing_address_1('969 Market'); + $order->set_billing_city('San Francisco'); + $order->set_billing_state('CA'); + $order->set_billing_postcode('94103'); + $order->set_billing_country('US'); + $order->set_billing_email('john.doe@example.com'); + $order->set_billing_phone('(555) 555-5555'); + } + + /** + * @param WC_Order $order + * @param array $products + * @throws \WC_Data_Exception + */ + private function addProductsToOrder(WC_Order $order, array $products): void + { + foreach ($products as $product_data) { + $product_id = null; + + if (!empty($product_data['sku'])) { + $product_sku = $product_data['sku']; + $product_id = wc_get_product_id_by_sku($product_sku); + } + + if (!$product_id && isset($product_data['id'])) { + $product_id = $product_data['id']; + } + + if (!$product_id) { + throw new \WC_Data_Exception('invalid_product', "Product not found - no valid SKU or ID provided"); + } + + $variation_id = $product_data['variation_id'] ?? 0; + $product_type = $product_data['type'] ?? 'simple'; + + $product = wc_get_product($variation_id ?: $product_id); + + if (!$product) { + throw new \WC_Data_Exception('invalid_product', "Product {$product_id} not found"); + } + + // Use appropriate item class based on product type + $item = $this->createOrderItem($product_type, $product_data, $product); + + if ($variation_id && $product->is_type('variation')) { + $item->set_variation_data($product->get_variation_attributes()); + } + + $order->add_item($item); + } + } + + /** + * @param WC_Order $order + * @param array $discounts + */ + private function applyDiscountsToOrder(WC_Order $order, array $discounts): void + { + foreach ($discounts as $discount) { + if (isset($discount['coupon_code'])) { + $order->apply_coupon($discount['coupon_code']); + } + + if (isset($discount['fee'])) { + $fee = new WC_Order_Item_Fee(); + $fee->set_props([ + 'name' => $discount['fee']['name'], + 'amount' => -abs($discount['fee']['amount']), + 'total' => -abs($discount['fee']['amount']), + ]); + $order->add_item($fee); + } + } + } + + /** + * @param array $product_presets + * @return array + * @throws \WC_Data_Exception + */ + private function resolveProductPresets(array $product_presets): array + { + $available_presets = ProductPresets::get(); + $products = []; + + foreach ($product_presets as $preset) { + if (is_string($preset)) { + if (!isset($available_presets[$preset])) { + throw new \WC_Data_Exception('invalid_preset', "Product preset '{$preset}' not found"); + } + $products[] = $available_presets[$preset]; + } elseif (is_array($preset)) { + $preset_name = $preset['preset']; + $quantity = $preset['quantity'] ?? 1; + + if (!isset($available_presets[$preset_name])) { + throw new \WC_Data_Exception('invalid_preset', "Product preset '{$preset_name}' not found"); + } + + $product_data = $available_presets[$preset_name]; + $product_data['quantity'] = $quantity; + $products[] = $product_data; + } + } + + return $products; + } + + /** + * @param array $discount_presets + * @return array + * @throws \WC_Data_Exception + */ + private function resolveDiscountPresets(array $discount_presets): array + { + $available_presets = DiscountPresets::get(); + $discounts = []; + + foreach ($discount_presets as $preset) { + if (!isset($available_presets[$preset])) { + throw new \WC_Data_Exception('invalid_preset', "Discount preset '{$preset}' not found"); + } + $discounts[] = $available_presets[$preset]; + } + + return $discounts; + } + + /** + * Delete all created orders + */ + public function cleanup(): void + { + foreach ($this->created_order_ids as $order_id) { + wp_delete_post($order_id, true); + } + + $this->created_order_ids = []; + } + + /** + * @return array + */ + public function getCreatedIds(): array + { + return $this->created_order_ids; + } + + /** + * @param string $product_type + * @param array $product_data + * @param \WC_Product $product + * @return \WC_Order_Item_Product + */ + private function createOrderItem(string $product_type, array $product_data, \WC_Product $product): \WC_Order_Item_Product + { + $item = new \WC_Order_Item_Product(); + + $item->set_props([ + 'product_id' => $product->get_id(), + 'variation_id' => $product_data['variation_id'] ?? 0, + 'quantity' => $product_data['quantity'], + 'subtotal' => $product->get_price() * $product_data['quantity'], + 'total' => $product->get_price() * $product_data['quantity'], + ]); + + return $item; + } +} diff --git a/tests/integration/PHPUnit/Factories/ProductFactory.php b/tests/integration/PHPUnit/Factories/ProductFactory.php new file mode 100644 index 000000000..caaaabde3 --- /dev/null +++ b/tests/integration/PHPUnit/Factories/ProductFactory.php @@ -0,0 +1,166 @@ +createSimpleProduct($preset); + } + switch ($preset['type']) { + case 'variable': + return $this->createVariableProduct($preset); + case 'subscription': + return $this->createSubscriptionProduct($preset); + case 'simple': + default: + return $this->createSimpleProduct($preset); + } + } + + /** + * @param array $preset + * @return WC_Product_Simple + */ + private function createSimpleProduct(array $preset): WC_Product_Simple + { + $product = new WC_Product_Simple(); + $product_id = wc_get_product_id_by_sku($preset['sku']); + $product->set_sku($preset['sku']); + $product->set_id($product_id); + $product->set_name($preset['name']); + $product->set_regular_price($preset['price']); + $product->set_status('publish'); + $product->save(); + + $this->created_product_ids[] = $product_id; + + return $product; + } + + /** + * @param array $preset + * @return WC_Product_Variation + */ + private function createVariableProduct(array $preset): WC_Product_Variation + { + // Create parent variable product + $parent = new WC_Product_Variable(); + $product_id = wc_get_product_id_by_sku($preset['sku']); + $parent->set_sku($preset['sku']); + $parent->set_id($product_id); + $parent->set_name($preset['name']); + $parent->set_status('publish'); + $parent->save(); + + // Create variation + $variation = new WC_Product_Variation(); + $variation->set_id($preset['variation_id']); + $variation->set_parent_id($preset['product_id']); + $variation->set_regular_price($preset['price']); + $variation->set_attributes(['color' => 'red']); + $variation->set_status('publish'); + $variation->save(); + + $this->created_product_ids[] = $product_id; + $this->created_product_ids[] = $preset['variation_id']; + + return $variation; + } + + /** + * @param array $preset + * @return \WC_Product_Subscription + */ + private function createSubscriptionProduct(array $preset): \WC_Product_Subscription + { + $product = new \WC_Product_Subscription(); + $product->set_props([ + 'name' => $preset['name'], + 'regular_price' => $preset['price'], + 'price' => $preset['price'], + 'sku' => $preset['sku'], + 'manage_stock' => false, + 'tax_status' => 'taxable', + 'downloadable' => false, + 'virtual' => false, + 'stock_status' => 'instock', + 'weight' => '1.1', + 'subscription_period' => $preset['subscription_period'], + 'subscription_period_interval' => $preset['subscription_period_interval'], + 'subscription_length' => $preset['subscription_length'], + 'subscription_trial_period' => $preset['subscription_trial_period'], + 'subscription_trial_length' => $preset['subscription_trial_length'], + 'subscription_price' => $preset['subscription_price'], + 'subscription_sign_up_fee' => $preset['subscription_sign_up_fee'], + ]); + + $product->set_status('publish'); + $product->save(); + + $this->created_product_ids[] = $product->get_id(); + return $product; + } + + /** + * @param string $sku + * @return bool + */ + public function exists(string $sku): bool + { + $existing_product_id = wc_get_product_id_by_sku($sku); + return (bool)$existing_product_id; + } + + /** + * Delete all created products + */ + public function cleanup(): void + { + foreach ($this->created_product_ids as $product_id) { + wp_delete_post($product_id, true); + } + + $this->created_product_ids = []; + } +} diff --git a/tests/integration/PHPUnit/Fixtures/DiscountPresets.php b/tests/integration/PHPUnit/Fixtures/DiscountPresets.php new file mode 100644 index 000000000..c0a4e2320 --- /dev/null +++ b/tests/integration/PHPUnit/Fixtures/DiscountPresets.php @@ -0,0 +1,26 @@ + [ + 'coupon_code' => 'TEST10PERCENT', + 'type' => 'percent', + 'amount' => '10' + ], + 'fixed_5' => [ + 'coupon_code' => 'TEST5FIXED', + 'type' => 'fixed_cart', + 'amount' => '5.00' + ], + 'manual_discount' => [ + 'fee' => ['name' => 'Test Discount', 'amount' => 3.50] + ], + ]; + } +} diff --git a/tests/integration/PHPUnit/Fixtures/ProductPresets.php b/tests/integration/PHPUnit/Fixtures/ProductPresets.php new file mode 100644 index 000000000..3a447ba27 --- /dev/null +++ b/tests/integration/PHPUnit/Fixtures/ProductPresets.php @@ -0,0 +1,49 @@ + [ + 'sku' => 'DUMMY_SIMPLE_SKU_01', + 'name' => 'Test Simple Product', + 'price' => '10.00', + 'quantity' => 1, + 'type' => 'simple' + ], + 'simple_expensive' => [ + 'sku' => 'DUMMY_SIMPLE_SKU_02', + 'name' => 'Test Expensive Product', + 'price' => '199.99', + 'quantity' => 1, + 'type' => 'simple' + ], + /*'variable' => [ + 'sku' => 'DUMMY_VARIABLE_SKU_01', + 'variation_id' => 20002, + 'name' => 'Test Variable Product', + 'price' => '25.00', + 'quantity' => 1, + 'type' => 'variable' + ],*/ + 'subscription' => [ + 'name' => 'Dummy Subscription Product', + 'price' => '10.00', + 'quantity' => 1, + 'type' => 'subscription', + 'sku' => 'DUMMY SUB SKU', + 'subscription_period' => 'day', + 'subscription_period_interval' => 1, + 'subscription_length' => 0, + 'subscription_trial_period' => '', + 'subscription_trial_length' => 0, + 'subscription_price' => 10, + 'subscription_sign_up_fee' => 0, + ] + ]; + } +} diff --git a/tests/integration/PHPUnit/IntegrationMockedTestCase.php b/tests/integration/PHPUnit/IntegrationMockedTestCase.php index f23360130..8394f1179 100644 --- a/tests/integration/PHPUnit/IntegrationMockedTestCase.php +++ b/tests/integration/PHPUnit/IntegrationMockedTestCase.php @@ -18,6 +18,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit; use WooCommerce\PayPalCommerce\Helper\RedirectorStub; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use WooCommerce\PayPalCommerce\PPCP; +use WooCommerce\PayPalCommerce\Tests\Integration\Traits\CreateTestOrders; +use WooCommerce\PayPalCommerce\Tests\Integration\Traits\CreateTestProducts; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; @@ -25,311 +27,249 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; class IntegrationMockedTestCase extends TestCase { - use MockeryPHPUnitIntegration; + use MockeryPHPUnitIntegration, CreateTestOrders, CreateTestProducts; - public function setUp(): void - { - parent::setUp(); - $this->default_product_id = $this->createAProductIfNotProvided(); - } + public function setUp(): void + { + parent::setUp(); + $this->customer_id = $this->createCustomerIfNotExists(); + $this->createTestProducts(); + $this->createTestCoupons(); + } - /** - * @param int $customer_id - * @param string $payment_method - * @param int $product_id - * @param bool $set_paid - * @return \WC_Order|\WP_Error - * @throws \WC_Data_Exception - */ - public function getMockedOrder(int $customer_id, string $payment_method, int $product_id, bool $set_paid = true) - { - $order = wc_create_order([ - 'customer_id' => $customer_id, - 'set_paid' => $set_paid, - 'billing' => [ - 'first_name' => 'John', - 'last_name' => 'Doe', - 'address_1' => '969 Market', - 'address_2' => '', - 'city' => 'San Francisco', - 'state' => 'CA', - 'postcode' => '94103', - 'country' => 'US', - 'email' => 'john.doe@example.com', - 'phone' => '(555) 555-5555' - ], - 'line_items' => [ - [ - 'product_id' => $product_id, - 'quantity' => 1 - ] - ], - ]); - $order->set_payment_method($payment_method); - // Make sure the order is properly saved - $order->save(); + public function tearDown(): void + { + // This cleans up everything created during tests + //$this->cleanupTestData(); + parent::tearDown(); + } - // Add the product to the order - $item = new WC_Order_Item_Product(); - $item->set_props([ - 'product_id' => $product_id, - 'quantity' => 1, - 'subtotal' => 10, - 'total' => 10, - ]); - $order->add_item($item); - $order->calculate_totals(); - $order->save(); - return $order; - } - - /** - * @param string $sku - * @return int - */ - public function createAProductIfNotProvided(string $sku = 'DUMMY SUB SKU'): int - { - $product_id = wc_get_product_id_by_sku($sku); - if (!$product_id) { - $product = new \WC_Product_Subscription(); - $product->set_props([ - 'name' => 'Dummy Subscription Product', - 'regular_price' => 10, - 'price' => 10, - 'sku' => 'DUMMY SUB SKU', - 'manage_stock' => false, - 'tax_status' => 'taxable', - 'downloadable' => false, - 'virtual' => false, - 'stock_status' => 'instock', - 'weight' => '1.1', - // Subscription-specific properties - 'subscription_period' => 'month', - 'subscription_period_interval' => 1, - 'subscription_length' => 0, // 0 means unlimited - 'subscription_trial_period' => '', - 'subscription_trial_length' => 0, - 'subscription_price' => 10, - 'subscription_sign_up_fee' => 0, - ]); - $product->save(); - $product_id = $product->get_id(); - } - return $product_id; - } - - /** - * @param array $overriddenServices - * @return ContainerInterface - */ - protected function bootstrapModule(array $overriddenServices = []): ContainerInterface - { - $overriddenServices = array_merge([ - 'http.redirector' => function () { - return new RedirectorStub(); - } - ], $overriddenServices); + /** + * @param array $overriddenServices + * @return ContainerInterface + */ + protected function bootstrapModule(array $overriddenServices = []): ContainerInterface + { + $overriddenServices = array_merge([ + 'http.redirector' => function () { + return new RedirectorStub(); + } + ], $overriddenServices); - $module = new class ($overriddenServices) implements ServiceModule, ExecutableModule { - use ModuleClassNameIdTrait; + $module = new class ($overriddenServices) implements ServiceModule, ExecutableModule { + use ModuleClassNameIdTrait; - public function __construct(array $services) - { - $this->services = $services; - } + public function __construct(array $services) + { + $this->services = $services; + } - public function services(): array - { - return $this->services; - } + public function services(): array + { + return $this->services; + } - public function run(ContainerInterface $c): bool - { - return true; - } - }; + public function run(ContainerInterface $c): bool + { + return true; + } + }; - $rootDir = ROOT_DIR; - $bootstrap = require("$rootDir/bootstrap.php"); - $appContainer = $bootstrap($rootDir, [], [$module]); + $rootDir = ROOT_DIR; + $bootstrap = require("$rootDir/bootstrap.php"); + $appContainer = $bootstrap($rootDir, [], [$module]); - PPCP::init($appContainer); + PPCP::init($appContainer); - return $appContainer; - } + return $appContainer; + } - public function createCustomerIfNotExists(int $customer_id= 1): int - { - $customer = new \WC_Customer($customer_id); - if ( empty($customer->get_email() )) { - $customer->set_email('customer'. $customer_id. '@example.com'); + public function createCustomerIfNotExists(int $customer_id = 1): int + { + $customer = new \WC_Customer($customer_id); + if (empty($customer->get_email())) { + $customer->set_email('customer' . $customer_id . '@example.com'); $customer->set_first_name('John'); $customer->set_last_name('Doe'); $customer->save(); } - return $customer->get_id(); - } + return $customer->get_id(); + } - /** - * Creates a payment token for a customer. - * - * @param int $customer_id The customer ID. - * @return WC_Payment_Token_CC The created payment token. - * @throws \Exception - */ - public function createAPaymentTokenForTheCustomer(int $customer_id = 1, $gateway_id = 'ppcp-gateway'): WC_Payment_Token_CC - { - $this->createCustomerIfNotExists($customer_id); + /** + * Creates a payment token for a customer. + * + * @param int $customer_id The customer ID. + * @return WC_Payment_Token_CC The created payment token. + * @throws \Exception + */ + public function createAPaymentTokenForTheCustomer(int $customer_id = 1, $gateway_id = 'ppcp-gateway'): WC_Payment_Token_CC + { + $this->createCustomerIfNotExists($customer_id); - $token = new WC_Payment_Token_CC(); - $token->set_token('test_token_' . uniqid()); // Unique token ID - $token->set_gateway_id($gateway_id); - $token->set_user_id($customer_id); + $token = new WC_Payment_Token_CC(); + $token->set_token('test_token_' . uniqid()); // Unique token ID + $token->set_gateway_id($gateway_id); + $token->set_user_id($customer_id); - // These fields are required for WC_Payment_Token_CC - $token->set_card_type('visa'); // lowercase is often expected - $token->set_last4('1234'); - $token->set_expiry_month('12'); - $token->set_expiry_year('2030'); // Missing expiry year in your original code + // These fields are required for WC_Payment_Token_CC + $token->set_card_type('visa'); // lowercase is often expected + $token->set_last4('1234'); + $token->set_expiry_month('12'); + $token->set_expiry_year('2030'); // Missing expiry year in your original code - $result = $token->save(); + $result = $token->save(); - if (!$result || is_wp_error($result)) { - throw new \Exception('Failed to save payment token: ' . - (is_wp_error($result) ? $result->get_error_message() : 'Unknown error')); - } + if (!$result || is_wp_error($result)) { + throw new \Exception('Failed to save payment token: ' . + (is_wp_error($result) ? $result->get_error_message() : 'Unknown error')); + } - $saved_token = \WC_Payment_Tokens::get($token->get_id()); - if (!$saved_token || $saved_token->get_id() !== $token->get_id()) { - throw new \Exception('Token was not saved correctly'); - } + $saved_token = \WC_Payment_Tokens::get($token->get_id()); + if (!$saved_token || $saved_token->get_id() !== $token->get_id()) { + throw new \Exception('Token was not saved correctly'); + } - return $token; - } + return $token; + } - /** - * Helper method to create a subscription for testing. - * - * @param int $customer_id The customer ID - * @param string $payment_method The payment method - * @param string $sku - * @return WC_Subscription - * @throws \WC_Data_Exception - */ - public function createSubscription(int $customer_id = 1, string $payment_method = 'ppcp-gateway', $sku = 'DUMMY SUB SKU'): WC_Subscription - { - // Create a product if not provided - $product_id = $this->createAProductIfNotProvided($sku); + /** + * Helper method to create a subscription for testing. + * + * @param int $customer_id The customer ID + * @param string $payment_method The payment method + * @param string $sku + * @return WC_Subscription + * @throws \WC_Data_Exception + */ + public function createSubscription(int $customer_id = 1, string $payment_method = 'ppcp-gateway', $sku = 'DUMMY SUB SKU'): WC_Subscription + { + $product_id = wc_get_product_id_by_sku($sku); - $order = $this->getMockedOrder($customer_id, $payment_method, $product_id, $set_paid = true); + $order = $this->getConfiguredOrder( + $this->customer_id, + $payment_method, + ['subscription'] + ); + $subscription = new WC_Subscription(); + $subscription->set_customer_id($customer_id); + $subscription->set_payment_method($payment_method); + $subscription->set_status('active'); + $subscription->set_parent_id($order->get_id()); + $subscription->set_billing_period('month'); + $subscription->set_billing_interval(1); - $subscription = new WC_Subscription(); - $subscription->set_customer_id($customer_id); - $subscription->set_payment_method($payment_method); - $subscription->set_status('active'); - $subscription->set_parent_id($order->get_id()); - $subscription->set_billing_period('month'); - $subscription->set_billing_interval(1); + // Add a product to the subscription + $subscription_item = new WC_Order_Item_Product(); + $subscription_item->set_props([ + 'product_id' => $product_id, + 'quantity' => 1, + 'subtotal' => 10, + 'total' => 10, + ]); + $subscription->add_item($subscription_item); + $subscription->set_date_created(current_time('mysql')); + $subscription->set_start_date(current_time('mysql')); + $subscription->set_next_payment_date(date('Y-m-d H:i:s', strtotime('+1 month', current_time('timestamp')))); + $subscription->save(); - // Add a product to the subscription - $subscription_item = new WC_Order_Item_Product(); - $subscription_item->set_props([ - 'product_id' => $product_id, - 'quantity' => 1, - 'subtotal' => 10, - 'total' => 10, - ]); - $subscription->add_item($subscription_item); - $subscription->set_date_created(current_time('mysql')); - $subscription->set_start_date(current_time('mysql')); - $subscription->set_next_payment_date(date('Y-m-d H:i:s', strtotime('+1 month', current_time('timestamp')))); - $subscription->save(); + return $subscription; + } - return $subscription; - } + /** + * Creates a renewal order for testing + * + * @param int $customer_id + * @param string $gateway_id + * @param int $subscription_id + * @return WC_Order + */ + protected function createRenewalOrder(int $customer_id, string $gateway_id, int $subscription_id): WC_Order + { + $renewal_order = $this->getConfiguredOrder( + $customer_id, + $gateway_id, + ['subscription'], + [], + false + ); + $renewal_order->update_meta_data('_subscription_renewal', $subscription_id); + $renewal_order->update_meta_data('_subscription_renewal', $subscription_id); + $renewal_order->save(); - /** - * Creates a renewal order for testing - * - * @param int $customer_id - * @param string $gateway_id - * @param int $subscription_id - * @return WC_Order - */ - protected function createRenewalOrder(int $customer_id, string $gateway_id, int $subscription_id): WC_Order - { - $renewal_order = $this->getMockedOrder($customer_id, $gateway_id, $this->default_product_id, false); - $renewal_order->update_meta_data('_subscription_renewal', $subscription_id); - $renewal_order->save(); + return $renewal_order; + } - return $renewal_order; - } + /** + * Mocks the OrderEndpoint to return a successful/failed order. + * + * @param string $intent The order intent (CAPTURE or AUTHORIZE) + * @param bool $success Whether the order was successful + * @return object The mocked OrderEndpoint + */ + public function mockOrderEndpoint(string $intent = 'CAPTURE', bool $order_success = true, bool $capture_success = true): object + { + $order_endpoint = \Mockery::mock(OrderEndpoint::class); + $order = \Mockery::mock(Order::class)->shouldIgnoreMissing(); - /** - * Mocks the OrderEndpoint to return a successful/failed order. - * - * @param string $intent The order intent (CAPTURE or AUTHORIZE) - * @param bool $success Whether the order was successful - * @return object The mocked OrderEndpoint - */ - public function mockOrderEndpoint(string $intent = 'CAPTURE', bool $success = true): object - { - $order_endpoint = \Mockery::mock(OrderEndpoint::class)->shouldIgnoreMissing(); - $order = \Mockery::mock(Order::class)->shouldIgnoreMissing(); + $order->shouldReceive('id')->andReturn('TEST-ORDER-' . uniqid()); + $order->shouldReceive('intent')->andReturn($intent); - $order->shouldReceive('id')->andReturn('TEST-ORDER-' . uniqid()); - $order->shouldReceive('intent')->andReturn($intent); + $order_status = \Mockery::mock(OrderStatus::class); + $order_status->shouldReceive('is')->andReturn($order_success); + $order_status->shouldReceive('name')->andReturn($order_success ? 'COMPLETED' : 'FAILED'); + $order->shouldReceive('status')->andReturn($order_status); + $card_properties = new \stdClass(); + $card_properties->brand = 'VISA'; + $card_properties->last_digits = '1234'; + $card_properties->expiry = '2026-12'; + $payment_source = \Mockery::mock(PaymentSource::class); + $payment_source->shouldReceive('name')->andReturn('card'); + $payment_source->shouldReceive('properties')->andReturn($card_properties); + $order->shouldReceive('payment_source')->andReturn($payment_source); - $order_status = \Mockery::mock(OrderStatus::class)->shouldIgnoreMissing(); - $order_status->shouldReceive('is')->andReturn($success); - $order_status->shouldReceive('name')->andReturn($success ? 'COMPLETED' : 'FAILED'); - $order->shouldReceive('status')->andReturn($order_status); + $purchase_unit = \Mockery::mock(PurchaseUnit::class)->shouldIgnoreMissing(); + $payments = \Mockery::mock(Payments::class)->shouldIgnoreMissing(); + $capture = \Mockery::mock(Capture::class)->shouldIgnoreMissing(); - $payment_source = \Mockery::mock(PaymentSource::class)->shouldIgnoreMissing(); - $payment_source->shouldReceive('name')->andReturn('card'); - $order->shouldReceive('payment_source')->andReturn($payment_source); + $capture->shouldReceive('id')->andReturn('TEST-CAPTURE-' . uniqid()); + $capture_status = \Mockery::mock(CaptureStatus::class)->shouldIgnoreMissing(); - $purchase_unit = \Mockery::mock(PurchaseUnit::class)->shouldIgnoreMissing(); - $payments = \Mockery::mock(Payments::class)->shouldIgnoreMissing(); - $capture = \Mockery::mock(Capture::class)->shouldIgnoreMissing(); + $capture_status->shouldReceive('name')->andReturn($capture_success ? 'COMPLETED' : 'DECLINED'); + $capture_status->shouldReceive('details')->andReturn(null); + $capture->shouldReceive('status')->andReturn($capture_status); - $capture->shouldReceive('id')->andReturn('TEST-CAPTURE-' . uniqid()); - $capture_status = \Mockery::mock(CaptureStatus::class)->shouldIgnoreMissing(); + // Mock authorizations for AUTHORIZE intent + if ($intent === 'AUTHORIZE') { + $authorization = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization::class)->shouldIgnoreMissing(); - $capture_status->shouldReceive('name')->andReturn($success ? 'COMPLETED' : 'DECLINED'); - $capture->shouldReceive('status')->andReturn($capture_status); + $authorization->shouldReceive('id')->andReturn('TEST-AUTH-' . uniqid()); + $auth_status = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus::class)->shouldIgnoreMissing(); - // Mock authorizations for AUTHORIZE intent - if ($intent === 'AUTHORIZE') { - $authorization = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization::class)->shouldIgnoreMissing(); + $auth_status->shouldReceive('name')->andReturn($capture_success ? 'CREATED' : 'DENIED'); + $auth_status->shouldReceive('is')->andReturn($capture_success); + $authorization->shouldReceive('status')->andReturn($auth_status); + $payments->shouldReceive('authorizations')->andReturn([$authorization]); + $payments->shouldReceive('captures')->andReturn([]); + } else { + // For CAPTURE intent, set up captures but no authorizations + $payments->shouldReceive('captures')->andReturn([$capture]); + $payments->shouldReceive('authorizations')->andReturn([]); + } - $authorization->shouldReceive('id')->andReturn('TEST-AUTH-' . uniqid()); - $auth_status = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus::class)->shouldIgnoreMissing(); + $purchase_unit->shouldReceive('payments')->andReturn($payments); + $order->shouldReceive('purchase_units')->andReturn([$purchase_unit]); - $auth_status->shouldReceive('name')->andReturn($success ? 'CREATED' : 'DENIED'); - $auth_status->shouldReceive('is')->andReturn($success); - $authorization->shouldReceive('status')->andReturn($auth_status); - $payments->shouldReceive('authorizations')->andReturn([$authorization]); - $payments->shouldReceive('captures')->andReturn([]); - } else { - // For CAPTURE intent, set up captures but no authorizations - $payments->shouldReceive('captures')->andReturn([$capture]); - $payments->shouldReceive('authorizations')->andReturn([]); - } - - $purchase_unit->shouldReceive('payments')->andReturn($payments); - $order->shouldReceive('purchase_units')->andReturn([$purchase_unit]); - - // Set up the order endpoint methods - $order_endpoint->shouldReceive('create')->andReturn($order); - if ($intent === 'AUTHORIZE') { - $order_endpoint->shouldReceive('authorize')->andReturn($order); - } else { - $order_endpoint->shouldReceive('capture')->andReturn($order); - } - $order_endpoint->shouldReceive('order')->andReturn($order); - - return $order_endpoint; - } + // Set up the order endpoint methods + $order_endpoint->shouldReceive('create')->andReturn($order); + if ($intent === 'AUTHORIZE') { + $order_endpoint->shouldReceive('authorize')->andReturn($order); + } else { + $order_endpoint->shouldReceive('capture')->andReturn($order); + } + $order_endpoint->shouldReceive('order')->andReturn($order); + $order_endpoint->shouldReceive('patch_order_with')->andReturn($order); + return $order_endpoint; + } } diff --git a/tests/integration/PHPUnit/Traits/CleansTestData.php b/tests/integration/PHPUnit/Traits/CleansTestData.php new file mode 100644 index 000000000..8f0d911c8 --- /dev/null +++ b/tests/integration/PHPUnit/Traits/CleansTestData.php @@ -0,0 +1,25 @@ +order_factory)) { + $this->order_factory->cleanup(); + } + + if (isset($this->product_factory)) { + $this->product_factory->cleanup(); + } + + if (isset($this->coupon_factory)) { + $this->coupon_factory->cleanup(); + } + } +} diff --git a/tests/integration/PHPUnit/Traits/CreateTestOrders.php b/tests/integration/PHPUnit/Traits/CreateTestOrders.php new file mode 100644 index 000000000..273f90977 --- /dev/null +++ b/tests/integration/PHPUnit/Traits/CreateTestOrders.php @@ -0,0 +1,50 @@ +product_factory)) { + $this->initializeFactories(); + } + + $this->order_factory = new OrderFactory($this->product_factory, $this->coupon_factory); + } + + /** + * Create a configured order using presets + */ + protected function getConfiguredOrder( + int $customer_id, + string $payment_method, + array $product_presets, + array $discount_presets = [], + bool $set_paid = true + ): WC_Order + { + if (!isset($this->order_factory)) { + $this->initializeOrderFactory(); + } + + return $this->order_factory->create( + $customer_id, + $payment_method, + $product_presets, + $discount_presets, + $set_paid + ); + } +} diff --git a/tests/integration/PHPUnit/Traits/CreateTestProducts.php b/tests/integration/PHPUnit/Traits/CreateTestProducts.php new file mode 100644 index 000000000..4020b8289 --- /dev/null +++ b/tests/integration/PHPUnit/Traits/CreateTestProducts.php @@ -0,0 +1,60 @@ +product_factory = new ProductFactory(); + $this->coupon_factory = new CouponFactory(); + } + + /** + * Create all test products from presets + * @throws \WC_Data_Exception + */ + protected function createTestProducts(): void + { + if (!isset($this->product_factory)) { + $this->initializeFactories(); + } + + foreach (array_keys(ProductPresets::get()) as $preset_name) { + $preset = ProductPresets::get()[$preset_name]; + // Only create if doesn't exist + if (!$this->product_factory->exists($preset['sku'])) { + $this->product_factory->createFromPreset($preset_name); + } + } + } + + /** + * Create all test coupons from presets + */ + protected function createTestCoupons(): void + { + if (!isset($this->coupon_factory)) { + $this->initializeFactories(); + } + + foreach (DiscountPresets::get() as $preset_name => $preset) { + // Only create coupons (skip manual fees) + if (isset($preset['coupon_code']) && !$this->coupon_factory->exists($preset['coupon_code'])) { + $this->coupon_factory->createFromPreset($preset_name); + } + } + } +} diff --git a/tests/integration/PHPUnit/Transaction_tests/CreditcardTransactionTest.php b/tests/integration/PHPUnit/Transaction_tests/CreditcardTransactionTest.php new file mode 100644 index 000000000..76a3fdd55 --- /dev/null +++ b/tests/integration/PHPUnit/Transaction_tests/CreditcardTransactionTest.php @@ -0,0 +1,184 @@ +mockPaymentTokensEndpoint = \Mockery::mock(PaymentTokensEndpoint::class); + } + + /** + * Sets up a test container with common mocks + * + * @param OrderEndpoint $orderEndpoint + * @param array $additionalServices Additional services to override + * @return ContainerInterface + */ + protected function setupTestContainer(OrderEndpoint $orderEndpoint, array $additionalServices = []): ContainerInterface + { + $services = [ + 'api.endpoint.order' => function () use ($orderEndpoint) { + return $orderEndpoint; + }, + ]; + + return $this->bootstrapModule(array_merge($services, $additionalServices)); + } + + /** + * Creates a payment token and configures the mock endpoint to return it + * + * @param int $customer_id + * @param string $gateway_id + * @return WC_Payment_Token + */ + protected function setupPaymentToken(int $customer_id, string $gateway_id = PayPalGateway::ID): WC_Payment_Token + { + $paymentToken = $this->createAPaymentTokenForTheCustomer($customer_id, $gateway_id); + + $this->mockPaymentTokensEndpoint->shouldReceive('payment_tokens_for_customer') + ->andReturn([ + [ + 'id' => $paymentToken->get_token(), + 'payment_source' => new PaymentSource( + 'card', + (object)[ + 'last_digits' => $paymentToken->get_last4(), + 'brand' => $paymentToken->get_card_type(), + 'expiry' => $paymentToken->get_expiry_year() . '-' . $paymentToken->get_expiry_month() + ] + ) + ] + ]); + + return $paymentToken; + } + + /** + * Data provider for different product and discount combinations + */ + public function paymentProcessingDataProvider(): array + { + return [ + 'simple product only' => [ + 'products' => ['simple'], + 'discounts' => [], + 'expected_status' => 'processing' + ], + 'expensive product' => [ + 'products' => ['simple_expensive'], + 'discounts' => [], + 'expected_status' => 'processing' + ], + 'multiple products' => [ + 'products' => [ + ['preset' => 'simple', 'quantity' => 2], + 'simple_expensive' + ], + 'discounts' => [], + 'expected_status' => 'processing' + ],//TODO fix the discount logic is failing due to taxes + /*'simple product with percentage discount' => [ + 'products' => ['simple'], + 'discounts' => ['percentage_10'], + 'expected_status' => 'processing' + ], + 'simple product with fixed discount' => [ + 'products' => ['simple'], + 'discounts' => ['fixed_5'], + 'expected_status' => 'processing' + ],*/ + ]; + } + + /** + * Tests credit card payment processing with different product combinations. + * + * GIVEN a WooCommerce order with various product and discount combinations + * AND valid PayPal order ID in POST data + * AND valid credit card form data + * WHEN the payment is processed through the credit card gateway + * THEN the payment should be successfully captured + * AND the order status should change to the expected status + * AND a transaction ID should be set on the order + * + * @dataProvider paymentProcessingDataProvider + */ + public function testProcessPayment(array $products, array $discounts, string $expected_status) + { + // Mock successful PayPal API response + $mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false, true); + $this->setupTestContainer($mockOrderEndpoint); + + // Create order with provided products and discounts + $order = $this->getConfiguredOrder( + $this->customer_id, + 'ppcp-credit-card-gateway', + $products, + $discounts, + false + ); + + $paypal_order_id = 'TEST-PAYPAL-ORDER-' . uniqid(); + + // Set the PayPal order ID in POST data (simulating frontend submission) + $_POST['paypal_order_id'] = $paypal_order_id; + $order->update_meta_data('_paypal_order_id', $paypal_order_id); + $order->save(); + + // Mock the session handler to return null (forcing fallback to POST/meta) + $sessionHandler = \Mockery::mock(\WooCommerce\PayPalCommerce\Session\SessionHandler::class); + $sessionHandler->shouldReceive('order')->andReturn(null); + $sessionHandler->shouldReceive('destroy_session_data')->once(); + + // Add session handler to container overrides + $additionalServices = [ + 'session.handler' => function() use ($sessionHandler) { + return $sessionHandler; + } + ]; + + $c = $this->setupTestContainer($mockOrderEndpoint, $additionalServices); + + // Simulate credit card form data + $_POST['ppcp-credit-card-gateway-card-number'] = '4111111111111111'; + $_POST['ppcp-credit-card-gateway-card-expiry'] = '12/25'; + $_POST['ppcp-credit-card-gateway-card-cvc'] = '123'; + + // Get the gateway instance + $gateway = $c->get('wcgateway.credit-card-gateway'); + + // Process payment + $result = $gateway->process_payment($order->get_id()); + + // Assertions + $this->assertEquals('success', $result['result']); + $this->assertArrayHasKey('redirect', $result); + + // Verify order status changed + $order = wc_get_order($order->get_id()); // Refresh order + $this->assertEquals($expected_status, $order->get_status()); + $this->assertNotEmpty($order->get_transaction_id()); + + // Clean up POST data + unset($_POST['paypal_order_id']); + unset($_POST['ppcp-credit-card-gateway-card-number']); + unset($_POST['ppcp-credit-card-gateway-card-expiry']); + unset($_POST['ppcp-credit-card-gateway-card-cvc']); + } +} diff --git a/tests/integration/PHPUnit/VaultingSubscriptionsTest.php b/tests/integration/PHPUnit/VaultingSubscriptionsTest.php index e43db4fbf..bb994700e 100644 --- a/tests/integration/PHPUnit/VaultingSubscriptionsTest.php +++ b/tests/integration/PHPUnit/VaultingSubscriptionsTest.php @@ -21,78 +21,73 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; */ class VaultingSubscriptionsTest extends IntegrationMockedTestCase { - public function setUp(): void - { - parent::setUp(); + public function setUp(): void + { + parent::setUp(); - // Common mock setup $this->mockPaymentTokensEndpoint = \Mockery::mock(PaymentTokensEndpoint::class); + } - // Create customer and default product that can be reused - $this->customer_id = $this->createCustomerIfNotExists(); - $this->default_product_id = $this->createAProductIfNotProvided(); - } + /** + * Sets up a test container with common mocks + * + * @param OrderEndpoint $orderEndpoint + * @param array $additionalServices Additional services to override + * @return ContainerInterface + */ + protected function setupTestContainer(OrderEndpoint $orderEndpoint, array $additionalServices = []): ContainerInterface + { + $services = [ + 'api.endpoint.order' => function () use ($orderEndpoint) { + return $orderEndpoint; + }, + 'api.endpoint.payment-tokens' => function () { + return $this->mockPaymentTokensEndpoint; + } + ]; - /** - * Sets up a test container with common mocks - * - * @param OrderEndpoint $orderEndpoint - * @param array $additionalServices Additional services to override - * @return ContainerInterface - */ - protected function setupTestContainer(OrderEndpoint $orderEndpoint, array $additionalServices = []): ContainerInterface - { - $services = [ - 'api.endpoint.order' => function () use ($orderEndpoint) { - return $orderEndpoint; - }, - 'api.endpoint.payment-tokens' => function () { - return $this->mockPaymentTokensEndpoint; - } - ]; + return $this->bootstrapModule(array_merge($services, $additionalServices)); + } - return $this->bootstrapModule(array_merge($services, $additionalServices)); - } + /** + * Creates a payment token and configures the mock endpoint to return it + * + * @param int $customer_id + * @param string $gateway_id + * @return WC_Payment_Token + */ + protected function setupPaymentToken(int $customer_id, string $gateway_id = PayPalGateway::ID): WC_Payment_Token + { + $paymentToken = $this->createAPaymentTokenForTheCustomer($customer_id, $gateway_id); - /** - * Creates a payment token and configures the mock endpoint to return it - * - * @param int $customer_id - * @param string $gateway_id - * @return WC_Payment_Token - */ - protected function setupPaymentToken(int $customer_id, string $gateway_id = PayPalGateway::ID): WC_Payment_Token - { - $paymentToken = $this->createAPaymentTokenForTheCustomer($customer_id, $gateway_id); + $this->mockPaymentTokensEndpoint->shouldReceive('payment_tokens_for_customer') + ->andReturn([ + [ + 'id' => $paymentToken->get_token(), + 'payment_source' => new PaymentSource( + 'card', + (object)[ + 'last_digits' => $paymentToken->get_last4(), + 'brand' => $paymentToken->get_card_type(), + 'expiry' => $paymentToken->get_expiry_year() . '-' . $paymentToken->get_expiry_month() + ] + ) + ] + ]); - $this->mockPaymentTokensEndpoint->shouldReceive('payment_tokens_for_customer') - ->andReturn([ - [ - 'id' => $paymentToken->get_token(), - 'payment_source' => new PaymentSource( - 'card', - (object)[ - 'last_digits' => $paymentToken->get_last4(), - 'brand' => $paymentToken->get_card_type(), - 'expiry' => $paymentToken->get_expiry_year() . '-' . $paymentToken->get_expiry_month() - ] - ) - ] - ]); - - return $paymentToken; - } + return $paymentToken; + } - /** - * Tests that vaulting is automatically enabled when subscription mode is set to vaulting_api. - * - * GIVEN a PayPal account with Reference Transactions enabled - * WHEN the subscription mode is set to "vaulting_api" - * THEN vaulting should be automatically enabled for the PayPal gateway - */ - public function test_vaulting_is_enabled_when_subscription_mode_is_vaulting_api() - { + /** + * Tests that vaulting is automatically enabled when subscription mode is set to vaulting_api. + * + * GIVEN a PayPal account with Reference Transactions enabled + * WHEN the subscription mode is set to "vaulting_api" + * THEN vaulting should be automatically enabled for the PayPal gateway + */ + public function test_vaulting_is_enabled_when_subscription_mode_is_vaulting_api() + { $user_has_cap_callback = function ($allcaps, $caps, $args) { if (isset($args[0]) && $args[0] === 'manage_woocommerce') { $allcaps['manage_woocommerce'] = true; @@ -164,100 +159,102 @@ class VaultingSubscriptionsTest extends IntegrationMockedTestCase remove_filter('user_has_cap', $user_has_cap_callback, 10); } - } - /** - * Data provider for payment gateway tests - */ - public function paymentGatewayProvider(): array - { - return [ - 'PayPal Gateway' => [PayPalGateway::ID], - 'Credit Card Gateway' => [CreditCardGateway::ID] - ]; - } + } - /** - * Tests PayPal renewal payment processing. - * - * GIVEN a subscription with a saved PayPal payment token due for renewal - * WHEN the renewal process is triggered - * THEN a new PayPal order should be created using the customer token - * - * @dataProvider paymentGatewayProvider - */ - public function test_renewal_payment_processing(string $gateway_id) - { - $mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', true); - $c = $this->setupTestContainer($mockOrderEndpoint); + /** + * Data provider for payment gateway tests + */ + public function paymentGatewayProvider(): array + { + return [ + 'PayPal Gateway' => [PayPalGateway::ID], + 'Credit Card Gateway' => [CreditCardGateway::ID] + ]; + } + + /** + * Tests PayPal renewal payment processing. + * + * GIVEN a subscription with a saved PayPal payment token due for renewal + * WHEN the renewal process is triggered + * THEN a new PayPal order should be created using the customer token + * + * @dataProvider paymentGatewayProvider + */ + public function test_renewal_payment_processing(string $gateway_id) + { + $mockOrderEndpoint = $this->mockOrderEndpoint(); + $c = $this->setupTestContainer($mockOrderEndpoint); $this->setupPaymentToken($this->customer_id, $gateway_id); - $subscription = $this->createSubscription($this->customer_id, $gateway_id); - $renewal_order = $this->createRenewalOrder($this->customer_id, $gateway_id, $subscription->get_id()); + $subscription = $this->createSubscription($this->customer_id, $gateway_id); + $renewal_order = $this->createRenewalOrder($this->customer_id, $gateway_id, $subscription->get_id()); - $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); - $renewal_handler->renew($renewal_order); + $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); + $renewal_handler->renew($renewal_order); - // Check that the order was processed - $this->assertEquals('processing', $renewal_order->get_status(), 'The renewal order should be processing after successful payment'); - $this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID'); - } - /** - * Tests that renewal processing handles failed payments correctly. - * - * GIVEN a subscription due for renewal - * WHEN the payment process fails with an exception - * THEN the renewal order should be marked as failed - */ - public function test_renewal_handles_failed_payment() - { - $mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false); - $c = $this->setupTestContainer($mockOrderEndpoint); + // Check that the order was processed + $this->assertEquals('processing', $renewal_order->get_status(), 'The renewal order should be processing after successful payment'); + $this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID'); + } + + /** + * Tests that renewal processing handles failed payments correctly. + * + * GIVEN a subscription due for renewal + * WHEN the payment process fails with an exception + * THEN the renewal order should be marked as failed + */ + public function test_renewal_handles_failed_payment() + { + $mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false, false); + $c = $this->setupTestContainer($mockOrderEndpoint); $this->setupPaymentToken($this->customer_id); - $subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID); - $renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id()); - $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); - $renewal_handler->renew($renewal_order); + $subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID); + $renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id()); + $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); + $renewal_handler->renew($renewal_order); - // Check that the order status is failed - $this->assertEquals('failed', $renewal_order->get_status(), 'The renewal order should be marked as failed when payment fails'); - } + // Check that the order status is failed + $this->assertEquals('failed', $renewal_order->get_status(), 'The renewal order should be marked as failed when payment fails'); + } - /** - * Tests authorization-only subscription renewals. - * - * GIVEN the payment intent is set to "AUTHORIZE" - * WHEN a subscription renewal payment is processed - * THEN the payment should be authorized but not captured - */ - public function test_authorize_only_subscription_renewal() - { - // Mock the OrderEndpoint with AUTHORIZE intent - $mockOrderEndpoint = $this->mockOrderEndpoint('AUTHORIZE', true); - $c = $this->setupTestContainer($mockOrderEndpoint); + /** + * Tests authorization-only subscription renewals. + * + * GIVEN the payment intent is set to "AUTHORIZE" + * WHEN a subscription renewal payment is processed + * THEN the payment should be authorized but not captured + */ + public function test_authorize_only_subscription_renewal() + { + // Mock the OrderEndpoint with AUTHORIZE intent + $mockOrderEndpoint = $this->mockOrderEndpoint('AUTHORIZE', false, true); + $c = $this->setupTestContainer($mockOrderEndpoint); - // Setup payment token and subscription + // Setup payment token and subscription $this->setupPaymentToken($this->customer_id); - $subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID); - $renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id()); + $subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID); + $renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id()); - // Override the intent setting to ensure it's set to AUTHORIZE - $settings = $c->get('wcgateway.settings'); - $original_intent = $settings->get('intent'); - $settings->set('intent', 'authorize'); - $settings->persist(); + // Override the intent setting to ensure it's set to AUTHORIZE + $settings = $c->get('wcgateway.settings'); + $original_intent = $settings->get('intent'); + $settings->set('intent', 'authorize'); + $settings->persist(); - try { - // Process the renewal - $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); - $renewal_handler->renew($renewal_order); + try { + // Process the renewal + $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); + $renewal_handler->renew($renewal_order); - // Check that the order was processed with authorization - $this->assertEquals('on-hold', $renewal_order->get_status(), 'The renewal order should be on-hold after successful authorization'); - $this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID'); - $this->assertEquals('AUTHORIZE', $mockOrderEndpoint->order('')->intent(), 'The order intent should be AUTHORIZE'); - } finally { - // Restore original settings - $settings->set('intent', $original_intent); - $settings->persist(); - } - } + // Check that the order was processed with authorization + $this->assertEquals('on-hold', $renewal_order->get_status(), 'The renewal order should be on-hold after successful authorization'); + $this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID'); + $this->assertEquals('AUTHORIZE', $mockOrderEndpoint->order('')->intent(), 'The order intent should be AUTHORIZE'); + } finally { + // Restore original settings + $settings->set('intent', $original_intent); + $settings->persist(); + } + } } diff --git a/woocommerce-paypal-payments.php b/woocommerce-paypal-payments.php index 320a4f64a..b2cca9bc9 100644 --- a/woocommerce-paypal-payments.php +++ b/woocommerce-paypal-payments.php @@ -3,7 +3,7 @@ * Plugin Name: WooCommerce PayPal Payments * Plugin URI: https://woocommerce.com/products/woocommerce-paypal-payments/ * Description: PayPal's latest complete payments processing solution. Accept PayPal, Pay Later, credit/debit cards, alternative digital wallets local payment types and bank accounts. Turn on only PayPal options or process a full suite of payment methods. Enable global transaction with extensive currency and country coverage. - * Version: 3.0.6 + * Version: 3.0.7 * Author: PayPal * Author URI: https://paypal.com/ * License: GPL-2.0 @@ -11,7 +11,7 @@ * Requires Plugins: woocommerce * Requires at least: 6.5 * WC requires at least: 9.6 - * WC tested up to: 9.8 + * WC tested up to: 9.9 * Text Domain: woocommerce-paypal-payments * * @package WooCommerce\PayPalCommerce @@ -27,7 +27,7 @@ define( 'PAYPAL_API_URL', 'https://api-m.paypal.com' ); define( 'PAYPAL_URL', 'https://www.paypal.com' ); define( 'PAYPAL_SANDBOX_API_URL', 'https://api-m.sandbox.paypal.com' ); define( 'PAYPAL_SANDBOX_URL', 'https://www.sandbox.paypal.com' ); -define( 'PAYPAL_INTEGRATION_DATE', '2025-05-14' ); +define( 'PAYPAL_INTEGRATION_DATE', '2025-06-25' ); define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); ! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );