From 09b6b411db2d64548ec68432242a9e090415f0ed Mon Sep 17 00:00:00 2001 From: Alex P Date: Tue, 7 Feb 2023 15:29:53 +0200 Subject: [PATCH 1/2] Call WC validation on free trial click --- modules/ppcp-button/resources/js/button.js | 9 +- .../modules/ActionHandler/FreeTrialHandler.js | 23 ++++ .../js/modules/Helper/FormValidator.js | 31 +++++ modules/ppcp-button/services.php | 15 ++- .../ppcp-button/src/Assets/SmartButton.php | 16 +++ modules/ppcp-button/src/ButtonModule.php | 12 +- .../src/Endpoint/ValidateCheckoutEndpoint.php | 107 ++++++++++++++++++ 7 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 modules/ppcp-button/resources/js/modules/Helper/FormValidator.js create mode 100644 modules/ppcp-button/src/Endpoint/ValidateCheckoutEndpoint.php diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index b77395657..8dc519a6c 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -18,6 +18,7 @@ import {hide, setVisible, setVisibleByClass} from "./modules/Helper/Hiding"; import {isChangePaymentPage} from "./modules/Helper/Subscriptions"; import FreeTrialHandler from "./modules/ActionHandler/FreeTrialHandler"; import FormSaver from './modules/Helper/FormSaver'; +import FormValidator from "./modules/Helper/FormValidator"; // TODO: could be a good idea to have a separate spinner for each gateway, // but I think we care mainly about the script loading, so one spinner should be enough. @@ -36,7 +37,13 @@ const bootstrap = () => { PayPalCommerceGateway.ajax.save_checkout_form.nonce, ); - const freeTrialHandler = new FreeTrialHandler(PayPalCommerceGateway, checkoutFormSelector, formSaver, spinner, errorHandler); + const formValidator = PayPalCommerceGateway.early_checkout_validation_enabled ? + new FormValidator( + PayPalCommerceGateway.ajax.validate_checkout.endpoint, + PayPalCommerceGateway.ajax.validate_checkout.nonce, + ) : null; + + const freeTrialHandler = new FreeTrialHandler(PayPalCommerceGateway, checkoutFormSelector, formSaver, formValidator, spinner, errorHandler); jQuery('form.woocommerce-checkout input').on('keydown', e => { if (e.key === 'Enter' && [ diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js index 1caf2db80..e24de3bee 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js @@ -1,14 +1,24 @@ class FreeTrialHandler { + /** + * @param config + * @param formSelector + * @param {FormSaver} formSaver + * @param {FormValidator|null} formValidator + * @param {Spinner} spinner + * @param {ErrorHandler} errorHandler + */ constructor( config, formSelector, formSaver, + formValidator, spinner, errorHandler ) { this.config = config; this.formSelector = formSelector; this.formSaver = formSaver; + this.formValidator = formValidator; this.spinner = spinner; this.errorHandler = errorHandler; } @@ -24,6 +34,19 @@ class FreeTrialHandler { } try { + if (this.formValidator) { + try { + const errors = await this.formValidator.validate(document.querySelector(this.formSelector)); + if (errors.length > 0) { + this.spinner.unblock(); + this.errorHandler.messages(errors); + return; + } + } catch (error) { + console.error(error); + } + } + const res = await fetch(this.config.ajax.vault_paypal.endpoint, { method: 'POST', credentials: 'same-origin', diff --git a/modules/ppcp-button/resources/js/modules/Helper/FormValidator.js b/modules/ppcp-button/resources/js/modules/Helper/FormValidator.js new file mode 100644 index 000000000..af68be7dc --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/FormValidator.js @@ -0,0 +1,31 @@ +export default class FormValidator { + constructor(url, nonce) { + this.url = url; + this.nonce = nonce; + } + + async validate(form) { + const formData = new FormData(form); + const formJsonObj = Object.fromEntries(formData.entries()); + + const res = await fetch(this.url, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + nonce: this.nonce, + form: formJsonObj, + }), + }); + + const data = await res.json(); + + if (!data.success) { + if (data.data.errors) { + return data.data.errors; + } + throw Error(data.data.message); + } + + return []; + } +} diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index 527ce8bf9..6e8fe8a18 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -9,8 +9,10 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Button; -use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; use WooCommerce\PayPalCommerce\Button\Helper\CheckoutFormSaver; +use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; +use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator; +use WooCommerce\PayPalCommerce\Button\Endpoint\ValidateCheckoutEndpoint; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Button\Assets\DisabledSmartButton; use WooCommerce\PayPalCommerce\Button\Assets\SmartButton; @@ -106,6 +108,7 @@ return array( $currency, $container->get( 'wcgateway.all-funding-sources' ), $container->get( 'button.basic-checkout-validation-enabled' ), + $container->get( 'button.early-wc-checkout-validation-enabled' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, @@ -210,6 +213,13 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, + 'button.endpoint.validate-checkout' => static function ( ContainerInterface $container ): ValidateCheckoutEndpoint { + return new ValidateCheckoutEndpoint( + $container->get( 'button.request-data' ), + $container->get( 'button.validation.wc-checkout-validator' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, 'button.helper.three-d-secure' => static function ( ContainerInterface $container ): ThreeDSecure { $logger = $container->get( 'woocommerce.logger.woocommerce' ); return new ThreeDSecure( $logger ); @@ -246,4 +256,7 @@ return array( */ return (bool) apply_filters( 'woocommerce_paypal_payments_early_wc_checkout_validation_enabled', true ); }, + 'button.validation.wc-checkout-validator' => static function ( ContainerInterface $container ): CheckoutFormValidator { + return new CheckoutFormValidator(); + }, ); diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index 53af6033e..9635ad5ee 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -23,6 +23,7 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\DataClientIdEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData; use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\StartPayPalVaultingEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\ValidateCheckoutEndpoint; use WooCommerce\PayPalCommerce\Button\Helper\MessagesApply; use WooCommerce\PayPalCommerce\Onboarding\Environment; use WooCommerce\PayPalCommerce\Session\SessionHandler; @@ -155,6 +156,13 @@ class SmartButton implements SmartButtonInterface { */ private $basic_checkout_validation_enabled; + /** + * Whether to execute WC validation of the checkout form. + * + * @var bool + */ + protected $early_validation_enabled; + /** * The logger. * @@ -188,6 +196,7 @@ class SmartButton implements SmartButtonInterface { * @param string $currency 3-letter currency code of the shop. * @param array $all_funding_sources All existing funding sources. * @param bool $basic_checkout_validation_enabled Whether the basic JS validation of the form iss enabled. + * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form. * @param LoggerInterface $logger The logger. */ public function __construct( @@ -207,6 +216,7 @@ class SmartButton implements SmartButtonInterface { string $currency, array $all_funding_sources, bool $basic_checkout_validation_enabled, + bool $early_validation_enabled, LoggerInterface $logger ) { @@ -226,6 +236,7 @@ class SmartButton implements SmartButtonInterface { $this->currency = $currency; $this->all_funding_sources = $all_funding_sources; $this->basic_checkout_validation_enabled = $basic_checkout_validation_enabled; + $this->early_validation_enabled = $early_validation_enabled; $this->logger = $logger; } @@ -782,6 +793,10 @@ class SmartButton implements SmartButtonInterface { 'endpoint' => \WC_AJAX::get_endpoint( SaveCheckoutFormEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( SaveCheckoutFormEndpoint::nonce() ), ), + 'validate_checkout' => array( + 'endpoint' => \WC_AJAX::get_endpoint( ValidateCheckoutEndpoint::ENDPOINT ), + 'nonce' => wp_create_nonce( ValidateCheckoutEndpoint::nonce() ), + ), ), 'enforce_vault' => $this->has_subscriptions(), 'can_save_vault_token' => $this->can_save_vault_token(), @@ -871,6 +886,7 @@ class SmartButton implements SmartButtonInterface { 'single_product_buttons_enabled' => $this->settings_status->is_smart_button_enabled_for_location( 'product' ), 'mini_cart_buttons_enabled' => $this->settings_status->is_smart_button_enabled_for_location( 'mini-cart' ), 'basic_checkout_validation_enabled' => $this->basic_checkout_validation_enabled, + 'early_checkout_validation_enabled' => $this->early_validation_enabled, ); if ( $this->style_for_context( 'layout', 'mini-cart' ) !== 'horizontal' ) { diff --git a/modules/ppcp-button/src/ButtonModule.php b/modules/ppcp-button/src/ButtonModule.php index 1bb9d4fa6..573c7012c 100644 --- a/modules/ppcp-button/src/ButtonModule.php +++ b/modules/ppcp-button/src/ButtonModule.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Button; use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\ValidateCheckoutEndpoint; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface; @@ -96,7 +97,7 @@ class ButtonModule implements ModuleInterface { * * @param ContainerInterface $container The Container. */ - private function register_ajax_endpoints( ContainerInterface $container ) { + private function register_ajax_endpoints( ContainerInterface $container ): void { add_action( 'wc_ajax_' . DataClientIdEndpoint::ENDPOINT, static function () use ( $container ) { @@ -167,6 +168,15 @@ class ButtonModule implements ModuleInterface { $endpoint->handle_request(); } ); + + add_action( + 'wc_ajax_' . ValidateCheckoutEndpoint::ENDPOINT, + static function () use ( $container ) { + $endpoint = $container->get( 'button.endpoint.validate-checkout' ); + assert( $endpoint instanceof ValidateCheckoutEndpoint ); + $endpoint->handle_request(); + } + ); } /** diff --git a/modules/ppcp-button/src/Endpoint/ValidateCheckoutEndpoint.php b/modules/ppcp-button/src/Endpoint/ValidateCheckoutEndpoint.php new file mode 100644 index 000000000..7c99bb063 --- /dev/null +++ b/modules/ppcp-button/src/Endpoint/ValidateCheckoutEndpoint.php @@ -0,0 +1,107 @@ +request_data = $request_data; + $this->checkout_form_validator = $checkout_form_validator; + $this->logger = $logger; + } + + /** + * Returns the nonce. + * + * @return string + */ + public static function nonce(): string { + return self::ENDPOINT; + } + + /** + * Handles the request. + * + * @return bool + */ + public function handle_request(): bool { + try { + $data = $this->request_data->read_request( $this->nonce() ); + + $form_fields = $data['form']; + + $this->checkout_form_validator->validate( $form_fields ); + + wp_send_json_success(); + + return true; + } catch ( ValidationException $exception ) { + wp_send_json_error( + array( + 'message' => $exception->getMessage(), + 'errors' => $exception->errors(), + ) + ); + return false; + } catch ( Throwable $error ) { + $this->logger->error( "Form validation execution failed. {$error->getMessage()} {$error->getFile()}:{$error->getLine()}" ); + + wp_send_json_error( + array( + 'message' => $error->getMessage(), + ) + ); + return false; + } + } +} From 50560a96c4b4e1ec7895ed5ee2e123dacc18f399 Mon Sep 17 00:00:00 2001 From: Alex P Date: Tue, 7 Feb 2023 15:30:59 +0200 Subject: [PATCH 2/2] Add tests --- .../Endpoint/ValidateCheckoutEndpointTest.php | 72 +++++++++++++++++++ tests/PHPUnit/bootstrap.php | 1 + .../e2e/PHPUnit/Validation/ValidationTest.php | 70 ++++++++++++++++++ tests/stubs/WC_Checkout.php | 11 +++ 4 files changed, 154 insertions(+) create mode 100644 tests/PHPUnit/Button/Endpoint/ValidateCheckoutEndpointTest.php create mode 100644 tests/e2e/PHPUnit/Validation/ValidationTest.php create mode 100644 tests/stubs/WC_Checkout.php diff --git a/tests/PHPUnit/Button/Endpoint/ValidateCheckoutEndpointTest.php b/tests/PHPUnit/Button/Endpoint/ValidateCheckoutEndpointTest.php new file mode 100644 index 000000000..39d38bfb0 --- /dev/null +++ b/tests/PHPUnit/Button/Endpoint/ValidateCheckoutEndpointTest.php @@ -0,0 +1,72 @@ +requestData = Mockery::mock(RequestData::class); + $this->formValidator = Mockery::mock(CheckoutFormValidator::class); + $this->logger = Mockery::mock(LoggerInterface::class); + + $this->sut = new ValidateCheckoutEndpoint( + $this->requestData, + $this->formValidator, + $this->logger + ); + + $this->requestData->expects('read_request')->andReturn(['form' => ['field1' => 'value']]); + } + + public function testValid() + { + $this->formValidator->expects('validate')->once(); + + expect('wp_send_json_success')->once(); + + $this->sut->handle_request(); + } + + public function testInvalid() + { + $exception = new ValidationException(['Invalid value']); + $this->formValidator->expects('validate')->once() + ->andThrow($exception); + + expect('wp_send_json_error')->once() + ->with(['message' => $exception->getMessage(), 'errors' => ['Invalid value']]); + + $this->sut->handle_request(); + } + + public function testFailure() + { + $exception = new Exception('BOOM'); + $this->formValidator->expects('validate')->once() + ->andThrow($exception); + + expect('wp_send_json_error')->once() + ->with(['message' => $exception->getMessage()]); + + $this->logger->expects('error')->once(); + + $this->sut->handle_request(); + } +} diff --git a/tests/PHPUnit/bootstrap.php b/tests/PHPUnit/bootstrap.php index a674e4167..75145ac11 100644 --- a/tests/PHPUnit/bootstrap.php +++ b/tests/PHPUnit/bootstrap.php @@ -8,5 +8,6 @@ require_once ROOT_DIR . '/vendor/autoload.php'; require_once TESTS_ROOT_DIR . '/stubs/WC_Payment_Gateway.php'; require_once TESTS_ROOT_DIR . '/stubs/WC_Payment_Gateway_CC.php'; require_once TESTS_ROOT_DIR . '/stubs/WC_Ajax.php'; +require_once TESTS_ROOT_DIR . '/stubs/WC_Checkout.php'; Hamcrest\Util::registerGlobalFunctions(); diff --git a/tests/e2e/PHPUnit/Validation/ValidationTest.php b/tests/e2e/PHPUnit/Validation/ValidationTest.php new file mode 100644 index 000000000..cf6d87f36 --- /dev/null +++ b/tests/e2e/PHPUnit/Validation/ValidationTest.php @@ -0,0 +1,70 @@ +container = $this->getContainer(); + + $this->sut = $this->container->get( 'button.validation.wc-checkout-validator' ); + assert($this->sut instanceof CheckoutFormValidator); + } + + public function testValid() + { + $this->sut->validate([ + 'billing_first_name'=>'John', + 'billing_last_name'=>'Doe', + 'billing_company'=>'', + 'billing_country'=>'DE', + 'billing_address_1'=>'1 Main St', + 'billing_address_2'=>'city1', + 'billing_postcode'=>'11111', + 'billing_city'=>'city1', + 'billing_state'=>'DE-BW', + 'billing_phone'=>'12345678', + 'billing_email'=>'a@gmail.com', + 'terms-field'=>'1', + 'terms'=>'on', + ]); + } + + public function testInvalid() + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessageMatches('/.+First name.+Postcode/i'); + + $this->sut->validate([ + 'billing_first_name'=>'', + 'billing_postcode'=>'ABCDE', + + 'billing_last_name'=>'Doe', + 'billing_company'=>'', + 'billing_country'=>'DE', + 'billing_address_1'=>'1 Main St', + 'billing_address_2'=>'city1', + 'billing_city'=>'city1', + 'billing_state'=>'DE-BW', + 'billing_phone'=>'12345678', + 'billing_email'=>'a@gmail.com', + 'terms-field'=>'1', + 'terms'=>'on', + ]); + } +} diff --git a/tests/stubs/WC_Checkout.php b/tests/stubs/WC_Checkout.php new file mode 100644 index 000000000..85cd8419e --- /dev/null +++ b/tests/stubs/WC_Checkout.php @@ -0,0 +1,11 @@ +