From 6650a0ef2228ac31392e5cdd8d8cc1b85e24ee3e Mon Sep 17 00:00:00 2001 From: ThbPS Date: Thu, 26 Mar 2026 15:43:37 +0100 Subject: [PATCH 01/69] feat: migrate Core OPC to native module --- .gitignore | 3 + composer.json | 3 +- .../AdminPsOnePageCheckoutController.php | 19 + .../front/AbstractOpcJsonFrontController.php | 62 ++ controllers/front/addresseslist.php | 38 + .../{AddressForm.php => addressform.php} | 77 +- controllers/front/carriers.php | 55 ++ controllers/front/deleteaddress.php | 39 + .../front/{GuestInit.php => guestinit.php} | 48 +- controllers/front/paymentmethods.php | 61 ++ controllers/front/saveaddress.php | 39 + controllers/front/selectcarrier.php | 46 + controllers/front/selectpayment.php | 34 + controllers/front/states.php | 34 + docs/CORE_PORTING_PLAYBOOK.md | 82 ++ docs/DECISIONS.md | 27 + docs/E2E_RUNBOOK.md | 63 ++ docs/RULES.md | 14 + ps_onepagecheckout.php | 125 ++- scripts/run-tests.sh | 5 +- .../OnePageCheckoutAddressFormHandler.php | 93 ++ .../OnePageCheckoutAddressesListHandler.php | 45 + .../OnePageCheckoutDeleteAddressHandler.php | 159 ++++ .../OnePageCheckoutSaveAddressHandler.php | 153 ++++ .../Address/OnePageCheckoutStatesHandler.php | 33 + src/Checkout/Ajax/Address/OpcTempAddress.php | 90 ++ .../OnePageCheckoutCarriersHandler.php | 141 ++++ .../OnePageCheckoutSelectCarrierHandler.php | 101 +++ .../TempAddressCarrierSelectionStorage.php | 44 + .../CheckoutCustomerContextResolver.php | 55 ++ .../OnePageCheckoutGuestInitHandler.php | 38 +- .../OnePageCheckoutAddressFormHandler.php | 48 -- .../OnePageCheckoutPaymentMethodsHandler.php | 217 +++++ .../OnePageCheckoutSelectPaymentHandler.php | 51 ++ .../Ajax/Shared/CartPresenterHelper.php | 24 + .../Ajax/Shared/CheckoutAjaxResponse.php | 32 + .../Ajax/Shared/CheckoutSessionFactory.php | 46 + src/Checkout/CheckoutOnePageStep.php | 136 ++- src/Checkout/PaymentSelectionKeyBuilder.php | 91 ++ src/Form/AddressFieldsFormatTrait.php | 2 +- src/Form/BackOfficeConfigurationForm.php | 6 + src/Form/OnePageCheckoutAddressForm.php | 156 ++++ src/Form/OnePageCheckoutAddressFormatter.php | 81 ++ src/Form/OnePageCheckoutForm.php | 444 ++++++++-- src/Form/OnePageCheckoutFormFactory.php | 10 + src/Form/OnePageCheckoutFormatter.php | 26 +- .../OpcAddressFormHandlerIntegrationTest.php | 49 ++ ...OpcAddressesListHandlerIntegrationTest.php | 76 ++ ...OpcDeleteAddressHandlerIntegrationTest.php | 114 +++ ...estInitHandlerGuestFlowIntegrationTest.php | 13 +- ...OpcGuestInitHandlerIntegrationTestCase.php | 74 +- ...pcPaymentMethodsHandlerIntegrationTest.php | 304 +++++++ .../OpcSaveAddressHandlerIntegrationTest.php | 155 ++++ .../OpcSaveAddressSpe54IntegrationTest.php | 11 + ...OpcSelectPaymentHandlerIntegrationTest.php | 43 + .../Ajax/OpcStatesHandlerIntegrationTest.php | 38 + .../Ajax/OpcTempAddressIntegrationTest.php | 155 ++++ ...eckoutGuestEmailUpdateConcurrentWorker.php | 11 +- ...CheckoutFormSyncContextIntegrationTest.php | 13 +- ...OfficeConfigurationFormIntegrationTest.php | 20 + ...OfficeConfigurationFormIntegrationTest.php | 11 + tests/php/Mocks/LegacyConfiguration.php | 18 + tests/php/Mocks/LegacyEntityMapper.php | 11 + tests/php/Mocks/bootstrap.php | 2 + .../Ajax/OpcAddressesListHandlerTest.php | 43 + .../Checkout/Ajax/OpcCarriersHandlerTest.php | 60 ++ .../Checkout/Ajax/OpcGuestInitHandlerTest.php | 47 +- .../Ajax/OpcPaymentMethodsHandlerTest.php | 101 +++ .../Ajax/OpcSaveAddressHandlerTest.php | 45 + .../Ajax/OpcSelectPaymentHandlerTest.php | 51 ++ .../Checkout/Ajax/OpcStatesHandlerTest.php | 21 + ...TempAddressCarrierSelectionStorageTest.php | 54 ++ .../CheckoutOnePageStepRenderTest.php | 184 ++++ ...ckoutOnePageStepRequestPersistenceTest.php | 129 +++ .../Checkout/CheckoutOnePageStepSpe7Test.php | 39 + .../Unit/Checkout/CheckoutOnePageStepTest.php | 25 - .../PaymentSelectionKeyBuilderTest.php | 56 ++ .../Controller/AddressFormControllerTest.php | 87 +- .../AddressesListControllerTest.php | 45 + .../AdminPsOnePageCheckoutControllerTest.php | 29 + .../Controller/CarriersControllerTest.php | 45 + .../PaymentMethodsControllerTest.php | 50 ++ .../Controller/SaveAddressControllerTest.php | 45 + .../SelectCarrierControllerTest.php | 45 + .../SelectPaymentControllerTest.php | 45 + .../Unit/Controller/StatesControllerTest.php | 45 + .../Form/BackOfficeConfigurationFormTest.php | 49 ++ ...OnePageCheckoutFormSpe7FinalSubmitTest.php | 11 + .../php/Unit/Form/OnePageCheckoutFormTest.php | 225 ++++- .../Js/OpcAddressModalSpe54ContractTest.php | 27 + .../php/Unit/Js/OpcJavascriptContractTest.php | 64 +- .../Module/PsOnepagecheckoutModuleTest.php | 19 + tests/php/bootstrap-autoload.php | 28 +- tests/php/bootstrap-unit.php | 13 + views/js/events.js | 19 + views/js/opc-address-modal.js | 795 ++++++++++++++++++ views/js/opc-address.js | 187 ++-- views/js/opc-carrier-list.js | 139 +++ views/js/opc-carrier-select.js | 91 ++ views/js/opc-guest-init.js | 99 ++- views/js/opc-payment-list.js | 133 +++ views/js/opc-payment-select.js | 114 +++ views/js/opc-submit.js | 229 +++++ views/js/runtime/opc-runtime.js | 89 ++ views/js/selectors.js | 34 + views/public/opc-address-modal.bundle.js | 1 + views/public/opc-address.bundle.js | 2 +- views/public/opc-carrier-list.bundle.js | 1 + views/public/opc-carrier-select.bundle.js | 1 + views/public/opc-guest-init.bundle.js | 2 +- views/public/opc-payment-list.bundle.js | 1 + views/public/opc-payment-select.bundle.js | 1 + views/public/opc-submit.bundle.js | 1 + views/webpack.config.js | 6 + 114 files changed, 7542 insertions(+), 444 deletions(-) create mode 100644 controllers/front/AbstractOpcJsonFrontController.php create mode 100644 controllers/front/addresseslist.php rename controllers/front/{AddressForm.php => addressform.php} (51%) create mode 100644 controllers/front/carriers.php create mode 100644 controllers/front/deleteaddress.php rename controllers/front/{GuestInit.php => guestinit.php} (66%) create mode 100644 controllers/front/paymentmethods.php create mode 100644 controllers/front/saveaddress.php create mode 100644 controllers/front/selectcarrier.php create mode 100644 controllers/front/selectpayment.php create mode 100644 controllers/front/states.php create mode 100644 docs/CORE_PORTING_PLAYBOOK.md create mode 100644 src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php create mode 100644 src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php create mode 100644 src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php create mode 100644 src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php create mode 100644 src/Checkout/Ajax/Address/OnePageCheckoutStatesHandler.php create mode 100644 src/Checkout/Ajax/Address/OpcTempAddress.php create mode 100644 src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php create mode 100644 src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php create mode 100644 src/Checkout/Ajax/Carrier/TempAddressCarrierSelectionStorage.php create mode 100644 src/Checkout/Ajax/Customer/CheckoutCustomerContextResolver.php rename src/Checkout/Ajax/{ => Customer}/OnePageCheckoutGuestInitHandler.php (96%) delete mode 100644 src/Checkout/Ajax/OnePageCheckoutAddressFormHandler.php create mode 100644 src/Checkout/Ajax/Payment/OnePageCheckoutPaymentMethodsHandler.php create mode 100644 src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php create mode 100644 src/Checkout/Ajax/Shared/CartPresenterHelper.php create mode 100644 src/Checkout/Ajax/Shared/CheckoutAjaxResponse.php create mode 100644 src/Checkout/Ajax/Shared/CheckoutSessionFactory.php create mode 100644 src/Checkout/PaymentSelectionKeyBuilder.php create mode 100644 src/Form/OnePageCheckoutAddressForm.php create mode 100644 src/Form/OnePageCheckoutAddressFormatter.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcDeleteAddressHandlerIntegrationTest.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcPaymentMethodsHandlerIntegrationTest.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcSaveAddressSpe54IntegrationTest.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcSelectPaymentHandlerIntegrationTest.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcStatesHandlerIntegrationTest.php create mode 100644 tests/php/Integration/Checkout/Ajax/OpcTempAddressIntegrationTest.php create mode 100644 tests/php/Integration/Form/Spe10BackOfficeConfigurationFormIntegrationTest.php create mode 100644 tests/php/Mocks/LegacyConfiguration.php create mode 100644 tests/php/Mocks/LegacyEntityMapper.php create mode 100644 tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php create mode 100644 tests/php/Unit/Checkout/Ajax/OpcCarriersHandlerTest.php create mode 100644 tests/php/Unit/Checkout/Ajax/OpcPaymentMethodsHandlerTest.php create mode 100644 tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php create mode 100644 tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php create mode 100644 tests/php/Unit/Checkout/Ajax/OpcStatesHandlerTest.php create mode 100644 tests/php/Unit/Checkout/Ajax/TempAddressCarrierSelectionStorageTest.php create mode 100644 tests/php/Unit/Checkout/CheckoutOnePageStepRenderTest.php create mode 100644 tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php create mode 100644 tests/php/Unit/Checkout/CheckoutOnePageStepSpe7Test.php delete mode 100644 tests/php/Unit/Checkout/CheckoutOnePageStepTest.php create mode 100644 tests/php/Unit/Checkout/PaymentSelectionKeyBuilderTest.php create mode 100644 tests/php/Unit/Controller/AddressesListControllerTest.php create mode 100644 tests/php/Unit/Controller/CarriersControllerTest.php create mode 100644 tests/php/Unit/Controller/PaymentMethodsControllerTest.php create mode 100644 tests/php/Unit/Controller/SaveAddressControllerTest.php create mode 100644 tests/php/Unit/Controller/SelectCarrierControllerTest.php create mode 100644 tests/php/Unit/Controller/SelectPaymentControllerTest.php create mode 100644 tests/php/Unit/Controller/StatesControllerTest.php create mode 100644 tests/php/Unit/Form/OnePageCheckoutFormSpe7FinalSubmitTest.php create mode 100644 tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php create mode 100644 views/js/events.js create mode 100644 views/js/opc-address-modal.js create mode 100644 views/js/opc-carrier-list.js create mode 100644 views/js/opc-carrier-select.js create mode 100644 views/js/opc-payment-list.js create mode 100644 views/js/opc-payment-select.js create mode 100644 views/js/opc-submit.js create mode 100644 views/js/runtime/opc-runtime.js create mode 100644 views/js/selectors.js create mode 100644 views/public/opc-address-modal.bundle.js create mode 100644 views/public/opc-carrier-list.bundle.js create mode 100644 views/public/opc-carrier-select.bundle.js create mode 100644 views/public/opc-payment-list.bundle.js create mode 100644 views/public/opc-payment-select.bundle.js create mode 100644 views/public/opc-submit.bundle.js diff --git a/.gitignore b/.gitignore index 692b703..11f6c75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ /config_*.xml /translations/*.php /node_modules +/tests/e2e/node_modules +/tests/e2e/artifacts +/tests/e2e/.env /vendor /views/node_modules /.php_cs.cache diff --git a/composer.json b/composer.json index 059b3d7..edb88cd 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ }, "classmap": [ "ps_onepagecheckout.php", - "controllers/" + "controllers/", + "src/Checkout/Ajax/" ] }, "scripts": { diff --git a/controllers/admin/AdminPsOnePageCheckoutController.php b/controllers/admin/AdminPsOnePageCheckoutController.php index b515f77..1f1ab1b 100644 --- a/controllers/admin/AdminPsOnePageCheckoutController.php +++ b/controllers/admin/AdminPsOnePageCheckoutController.php @@ -20,6 +20,10 @@ public function initContent() { parent::initContent(); + if (!$this->viewAccess()) { + return; + } + $configurationContent = $this->getBackOfficeConfigurationContent(); if ($configurationContent !== '') { $this->content .= $configurationContent; @@ -36,6 +40,11 @@ protected function getBackOfficeConfigurationContent(): string return $this->module->getBackOfficeConfigurationContent(); } + public function viewAccess($disable = false) + { + return $this->hasLegacyViewAccess((bool) $disable) && $this->hasModuleConfigurePermission(); + } + public function getTwig(): ?Environment { try { @@ -50,4 +59,14 @@ public function getTwig(): ?Environment return $legacyControllerContext->getTwig(); } + + protected function hasLegacyViewAccess(bool $disable = false): bool + { + return parent::viewAccess($disable); + } + + protected function hasModuleConfigurePermission(): bool + { + return $this->module->getPermission('configure', $this->context->employee ?? null); + } } diff --git a/controllers/front/AbstractOpcJsonFrontController.php b/controllers/front/AbstractOpcJsonFrontController.php new file mode 100644 index 0000000..058a733 --- /dev/null +++ b/controllers/front/AbstractOpcJsonFrontController.php @@ -0,0 +1,62 @@ +renderJsonResponse($this->handleOpcRequest()); + } + + /** + * @return array + */ + abstract protected function handleOpcRequest(): array; + + protected function isOpcAvailable(): bool + { + return $this->module instanceof Ps_Onepagecheckout + && $this->module->isOnePageCheckoutEnabled(); + } + + /** + * @return array + */ + protected function buildTechnicalErrorResponse(): array + { + return $this->getTechnicalErrorResponseExtra() + [ + 'success' => false, + 'errors' => [ + '' => [ + $this->trans('One-page checkout is currently unavailable.', [], 'Shop.Notifications.Error'), + ], + ], + ]; + } + + /** + * @return array + */ + protected function getTechnicalErrorResponseExtra(): array + { + return []; + } + + /** + * @param array $response + */ + protected function renderJsonResponse(array $response): void + { + if (ob_get_level() > 0) { + ob_end_clean(); + } + + header('Content-Type: application/json'); + $this->ajaxRender(json_encode($response)); + exit; + } +} diff --git a/controllers/front/addresseslist.php b/controllers/front/addresseslist.php new file mode 100644 index 0000000..f0d8227 --- /dev/null +++ b/controllers/front/addresseslist.php @@ -0,0 +1,38 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutAddressesListHandler( + $this->context, + new CheckoutCustomerContextResolver($this->context) + ); + + return $handler->handle(Tools::getAllValues()); + } + + /** + * @return array + */ + protected function handleAddressesList(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/controllers/front/AddressForm.php b/controllers/front/addressform.php similarity index 51% rename from controllers/front/AddressForm.php rename to controllers/front/addressform.php index 950bb09..e73a89a 100644 --- a/controllers/front/AddressForm.php +++ b/controllers/front/addressform.php @@ -4,28 +4,20 @@ * AJAX endpoint for module-owned OPC address form refresh. */ +use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerContextResolver; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutAddressFormHandler; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutFormFactory; -class Ps_OnepagecheckoutAddressFormModuleFrontController extends ModuleFrontController -{ - /** @var bool */ - public $ssl = true; - - public function initContent() - { - parent::initContent(); - - $response = $this->handleAddressFormRefresh(); - $this->renderJsonResponse($response); - } +require_once __DIR__ . '/AbstractOpcJsonFrontController.php'; +class Ps_OnepagecheckoutAddressFormModuleFrontController extends Ps_OnepagecheckoutAbstractOpcJsonFrontController +{ /** * @return array */ - protected function handleAddressFormRefresh(): array + protected function handleOpcRequest(): array { - if (!$this->module instanceof Ps_Onepagecheckout || !$this->module->isOnePageCheckoutEnabled()) { + if (!$this->isOpcAvailable()) { return $this->buildTechnicalErrorResponse(); } @@ -33,10 +25,11 @@ protected function handleAddressFormRefresh(): array $opcFormFactory = $this->getOpcFormFactory(); $handler = $this->createAddressFormHandler($opcFormFactory); $templateVariables = $handler->getTemplateVariables(Tools::getAllValues()); + $this->context->smarty->assign('customer', $this->buildCheckoutCustomerTemplateVar()); return [ - 'address_form' => $this->render( - 'checkout/_partials/one-page-checkout-form', + 'addresses_section' => $this->render( + 'checkout/_partials/one-page-checkout/addresses-section', $templateVariables ), ]; @@ -54,6 +47,14 @@ protected function handleAddressFormRefresh(): array } } + /** + * @return array + */ + protected function handleAddressFormRefresh(): array + { + return $this->handleOpcRequest(); + } + protected function getOpcFormFactory(): OnePageCheckoutFormFactory { assert($this->module instanceof Ps_Onepagecheckout); @@ -63,34 +64,38 @@ protected function getOpcFormFactory(): OnePageCheckoutFormFactory protected function createAddressFormHandler(OnePageCheckoutFormFactory $opcFormFactory): OnePageCheckoutAddressFormHandler { - return new OnePageCheckoutAddressFormHandler($opcFormFactory->create()); + return new OnePageCheckoutAddressFormHandler( + $opcFormFactory->create(), + $this->context, + new CheckoutCustomerContextResolver($this->context) + ); } /** + * Rebuild the checkout customer template variable so AJAX refreshes do not + * keep stale address lists from the initial page render. + * * @return array */ - protected function buildTechnicalErrorResponse(): array + private function buildCheckoutCustomerTemplateVar(): array { - return [ - 'success' => false, - 'errors' => [ - '' => [ - $this->trans('One-page checkout is currently unavailable.', [], 'Shop.Notifications.Error'), - ], - ], - ]; - } + $customer = (new CheckoutCustomerContextResolver($this->context))->resolve(); - /** - * @param array $response - */ - protected function renderJsonResponse(array $response): void - { - if (ob_get_level() > 0) { - ob_end_clean(); + if (!$customer instanceof Customer || !Validate::isLoadedObject($customer)) { + $customer = $this->context->customer; } - header('Content-Type: application/json'); - $this->ajaxRender(json_encode($response)); + if (!$customer instanceof Customer) { + return []; + } + + $originalCustomer = $this->context->customer; + $this->context->customer = $customer; + + try { + return $this->getTemplateVarCustomer($customer); + } finally { + $this->context->customer = $originalCustomer; + } } } diff --git a/controllers/front/carriers.php b/controllers/front/carriers.php new file mode 100644 index 0000000..b2d63ea --- /dev/null +++ b/controllers/front/carriers.php @@ -0,0 +1,55 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutCarriersHandler($this->context, $this->module->getTranslator()); + $response = $handler->handle(Tools::getAllValues()); + + if (!empty($response['success'])) { + $response['carriers_html'] = $this->render( + 'checkout/_partials/one-page-checkout/carriers', + [ + 'delivery_options' => $response['delivery_options'] ?? [], + 'delivery_option' => $response['delivery_option'] ?? '', + ] + ); + if (isset($response['cart_preview'])) { + $response['preview'] = $this->render( + 'checkout/_partials/cart-summary', + [ + 'cart' => $response['cart_preview'], + 'static_token' => Tools::getToken(false), + ] + ); + unset($response['cart_preview']); + } + } + + return $response; + } + + /** + * @return array + */ + protected function handleCarriers(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/controllers/front/deleteaddress.php b/controllers/front/deleteaddress.php new file mode 100644 index 0000000..143bb4d --- /dev/null +++ b/controllers/front/deleteaddress.php @@ -0,0 +1,39 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutDeleteAddressHandler( + $this->context, + $this->module->getTranslator(), + new CheckoutCustomerContextResolver($this->context) + ); + + return $handler->handle(Tools::getAllValues()); + } + + /** + * @return array + */ + protected function handleDeleteAddress(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/controllers/front/GuestInit.php b/controllers/front/guestinit.php similarity index 66% rename from controllers/front/GuestInit.php rename to controllers/front/guestinit.php index 00035f7..65736ae 100644 --- a/controllers/front/GuestInit.php +++ b/controllers/front/guestinit.php @@ -7,25 +7,16 @@ use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutGuestInitHandler; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutFormFactory; -class Ps_OnepagecheckoutGuestInitModuleFrontController extends ModuleFrontController -{ - /** @var bool */ - public $ssl = true; - - public function initContent() - { - parent::initContent(); - - $response = $this->handleGuestInit(); - $this->renderJsonResponse($response); - } +require_once __DIR__ . '/AbstractOpcJsonFrontController.php'; +class Ps_OnepagecheckoutGuestInitModuleFrontController extends Ps_OnepagecheckoutAbstractOpcJsonFrontController +{ /** * @return array */ - protected function handleGuestInit(): array + protected function handleOpcRequest(): array { - if (!$this->module instanceof Ps_Onepagecheckout || !$this->module->isOnePageCheckoutEnabled()) { + if (!$this->isOpcAvailable()) { return $this->buildTechnicalErrorResponse(); } @@ -48,6 +39,14 @@ protected function handleGuestInit(): array } } + /** + * @return array + */ + protected function handleGuestInit(): array + { + return $this->handleOpcRequest(); + } + protected function getOpcFormFactory(): OnePageCheckoutFormFactory { assert($this->module instanceof Ps_Onepagecheckout); @@ -69,32 +68,13 @@ protected function createGuestInitHandler(OnePageCheckoutFormFactory $opcFormFac /** * @return array */ - protected function buildTechnicalErrorResponse(): array + protected function getTechnicalErrorResponseExtra(): array { return [ - 'success' => false, 'customer_created' => false, 'id_customer' => 0, - 'errors' => [ - '' => [ - $this->trans('One-page checkout is currently unavailable.', [], 'Shop.Notifications.Error'), - ], - ], 'token' => Tools::getToken(false), 'static_token' => Tools::getToken(false), ]; } - - /** - * @param array $response - */ - protected function renderJsonResponse(array $response): void - { - if (ob_get_level() > 0) { - ob_end_clean(); - } - - header('Content-Type: application/json'); - $this->ajaxRender(json_encode($response)); - } } diff --git a/controllers/front/paymentmethods.php b/controllers/front/paymentmethods.php new file mode 100644 index 0000000..9a9844a --- /dev/null +++ b/controllers/front/paymentmethods.php @@ -0,0 +1,61 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + try { + $handler = new OnePageCheckoutPaymentMethodsHandler($this->context); + $response = $handler->handle(Tools::getAllValues()); + + if (!empty($response['success'])) { + $response['payment_html'] = $this->render( + 'checkout/_partials/one-page-checkout/payment-methods', + [ + 'payment_options' => $response['payment_options'] ?? [], + 'is_free' => $response['is_free'] ?? false, + 'selected_payment_module' => $response['selected_payment_module'] ?? '', + 'selected_payment_selection_key' => $response['selected_payment_selection_key'] ?? '', + ] + ); + unset($response['payment_options']); + } + + return $response; + } catch (Throwable $exception) { + PrestaShopLogger::addLog( + sprintf('ps_onepagecheckout paymentMethods runtime exception: %s', $exception->getMessage()), + 3, + null, + 'Module', + (int) $this->module->id, + true + ); + + return $this->buildTechnicalErrorResponse(); + } + } + + /** + * @return array + */ + protected function handlePaymentMethods(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/controllers/front/saveaddress.php b/controllers/front/saveaddress.php new file mode 100644 index 0000000..657ab1a --- /dev/null +++ b/controllers/front/saveaddress.php @@ -0,0 +1,39 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutSaveAddressHandler( + $this->context, + $this->module->getTranslator(), + new CheckoutCustomerContextResolver($this->context) + ); + + return $handler->handle(Tools::getAllValues()); + } + + /** + * @return array + */ + protected function handleSaveAddress(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/controllers/front/selectcarrier.php b/controllers/front/selectcarrier.php new file mode 100644 index 0000000..8950c6e --- /dev/null +++ b/controllers/front/selectcarrier.php @@ -0,0 +1,46 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutSelectCarrierHandler($this->context, $this->module->getTranslator()); + $response = $handler->handle(Tools::getAllValues()); + + if (!empty($response['success']) && isset($response['cart_preview'])) { + $response['preview'] = $this->render( + 'checkout/_partials/cart-summary', + [ + 'cart' => $response['cart_preview'], + 'static_token' => Tools::getToken(false), + ] + ); + unset($response['cart_preview']); + } + + return $response; + } + + /** + * @return array + */ + protected function handleSelectCarrier(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/controllers/front/selectpayment.php b/controllers/front/selectpayment.php new file mode 100644 index 0000000..60ed873 --- /dev/null +++ b/controllers/front/selectpayment.php @@ -0,0 +1,34 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutSelectPaymentHandler($this->context); + + return $handler->handle(Tools::getAllValues()); + } + + /** + * @return array + */ + protected function handleSelectPayment(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/controllers/front/states.php b/controllers/front/states.php new file mode 100644 index 0000000..7c818cb --- /dev/null +++ b/controllers/front/states.php @@ -0,0 +1,34 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutStatesHandler(); + + return $handler->handle(Tools::getAllValues()); + } + + /** + * @return array + */ + protected function handleStates(): array + { + return $this->handleOpcRequest(); + } +} diff --git a/docs/CORE_PORTING_PLAYBOOK.md b/docs/CORE_PORTING_PLAYBOOK.md new file mode 100644 index 0000000..deb5bb5 --- /dev/null +++ b/docs/CORE_PORTING_PLAYBOOK.md @@ -0,0 +1,82 @@ +# Core Porting Playbook + +## Goal + +Use this playbook when a checkout behavior exists in PrestaShop Core and must be migrated to `ps_onepagecheckout` with minimal Core changes. + +## Ownership rules + +Put a change in the module when it is: +- checkout business logic, +- OPC AJAX endpoint logic, +- checkout runtime JS, +- module BO configuration, +- test coverage specific to the module. + +The module also owns runtime event emission needed by its checkout flow, including `opcFinalSubmitStarted`. +When a Core checkout event already exists, document separately: +- listener compatibility already expected by the existing runtime, +- emitter ownership once the module becomes responsible for triggering the event. + +Put a change in Hummingbird when it is: +- template structure, +- section placeholders, +- visual states, +- style-only behavior. + +Put a change in Core only when: +- the module would otherwise need an override, +- the module cannot inject itself through an existing hook or service contract, +- fallback native checkout behavior must stay consistent when the module is disabled. + +Keep Core ownership for registration and authentication entry points that are not module-specific. `RegistrationController` and its success messaging remain Core responsibilities unless a future scope explicitly changes that architecture. +Do not introduce a module `RegistrationController` or override the Core controller as part of OPC migration work. +Do not port legacy guest-init behaviors that reattach a fresh anonymous cart to an older guest account. When parity conflicts with the validated product rule `1 anonymous cart = 1 guest customer`, keep the module on the validated product rule and document the divergence. + +## Analysis procedure + +1. Identify the Core PR behavior and its observable contracts. +2. Classify each change as `Core`, `module`, or `theme`. +3. Compare the current module behavior to the Core behavior already in production. +4. Correct parity gaps already present in the module before adding new features. +5. Add tests before or alongside each functional lot. + +## Mandatory parity checklist + +Before porting a new Core change, verify: +- PHP payload shape, +- AJAX JSON keys, +- template variables, +- JS event names, +- runtime URL definitions, +- native checkout fallback when the provider module is disabled, +- BO access control for both the dedicated module tab and the Module Manager `Configure` entry, +- whether the behavior belongs to module runtime or must remain Core-owned. + +## Recommended lot order + +1. parity fixes, +2. address modal, +3. delivery dynamic, +4. payment dynamic, +5. documentation and runbook updates. + +## Test workflow + +Each functional lot must provide: +- at least one unit test for the new handler/controller or runtime contract, +- at least one integration test for the business behavior, +- explicit coverage for any JS event contract introduced or migrated into the module, +- rebuilt bundles when `views/js/*` changes, +- a runbook update when FO verification points change. + +## Expected output for future plans + +Every future migration plan should state: +- source PR or Core behavior, +- target ownership (`Core`, `module`, `theme`), +- blocking points, +- files to add or update, +- unit tests, +- integration tests, +- regression risks. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2256aa9..da08d58 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -73,3 +73,30 @@ - Context: `PS_ONE_PAGE_CHECKOUT_ENABLED` is no longer provisioned by Core and must be fully owned by the module lifecycle. - Decision: create `PS_ONE_PAGE_CHECKOUT_ENABLED` during module install with value `0`, and remove it during module uninstall instead of recreating it with value `0`. - Impact: module installation remains self-sufficient without activating OPC by default, and uninstall leaves no stale OPC configuration entry behind. + +## 2026-03-23 + +### D-015 +- Context: `opcFinalSubmitStarted` was still treated as a Core-side runtime dependency while the checkout flow had already moved into `ps_onepagecheckout`. +- Decision: the module owns the emission of `opcFinalSubmitStarted`, and must ship the runtime asset that emits it during final checkout submit. +- Impact: the JS contract required by guest-init and final-submit protections stays available even when the module owns the checkout process. + +### D-016 +- Context: registration success messaging was discussed during OPC migration, but the module does not own the registration controller lifecycle. +- Decision: `RegistrationController` and the `Account successfully created` success message remain Core-owned and must not be duplicated or overridden by `ps_onepagecheckout`. +- Impact: the module stays focused on checkout behavior and avoids reintroducing registration logic outside Core. + +### D-017 +- Context: the dedicated BO tab `AdminPsOnePageCheckout` appends module configuration content after the legacy admin controller initialization step. +- Decision: only append configuration content when BO view access is granted. +- Impact: unauthorized employees cannot render the module configuration content through the dedicated BO controller. + +### D-018 +- Context: the migrated checkout runtime depends on an existing JS event contract that has two distinct responsibilities. +- Decision: document and test `opcFinalSubmitStarted` as both a listener contract for existing runtime code and an emitter contract owned by `ps_onepagecheckout`. +- Impact: the module preserves compatibility with current listeners while making ownership of the final-submit event explicit. + +### D-019 +- Context: guest-init legacy behavior could rebind a brand new anonymous cart to an older guest account when the submitted email already matched an existing guest. +- Decision: ownership is `1 anonymous cart = 1 guest customer`. A guest may be reused only for the same cart already linked to that guest, never for a fresh anonymous cart. +- Impact: a new anonymous cart must create a new guest even when the submitted email matches an older guest account; legacy reuse scenarios must not be reintroduced. diff --git a/docs/E2E_RUNBOOK.md b/docs/E2E_RUNBOOK.md index 35545ef..0d8539a 100644 --- a/docs/E2E_RUNBOOK.md +++ b/docs/E2E_RUNBOOK.md @@ -20,6 +20,59 @@ docker compose exec -T prestashop-git sh -lc 'chown -R www-data:www-data var && - `DB_PASSWD=prestashop` - `DB_PREFIX=ps_` +5. Ensure the checkout runtime loads the module-owned final submit asset: +- `views/public/opc-submit.bundle.js` +- browser console should expose the runtime event `opcFinalSubmitStarted` during final submit + +6. Before moving to a new checkout migration lot, run the module unit suite: +```bash +cd modules/ps_onepagecheckout +./scripts/run-tests.sh unit +``` + +## Functional parity checkpoints + +When validating a Core-to-module port, verify at minimum: + +1. Guest init: +- anonymous cart + new email, +- anonymous cart + same email as an older guest must still create a new guest for the new cart, +- same anonymous cart + refresh or consent toggles must not create a new guest, +- anonymous cart + existing registered email, +- existing guest email update, +- invalid token, +- missing persisted cart row. + +2. Address form refresh: +- country switch refreshes the form, +- delivery and billing form state are preserved, +- both `updatedOpcAddressForm` and structured OPC address events are emitted. + +3. Address modal flow: +- delivery modal opens in create and edit mode, +- billing modal opens in create and edit mode, +- changing the country reloads state options in the modal, +- saving a delivery address refreshes the OPC form and keeps the selected address on cart, +- saving an invoice address refreshes the OPC form and keeps `use_same_address=0` visible when applicable. + +4. Delivery dynamic: +- carriers reload on initial page load, +- carriers reload after a delivery address refresh, +- selecting a carrier refreshes the checkout summary preview, +- the selected carrier survives the refresh when still valid, +- an invalid carrier selection is reset when the delivery address changes. + +5. Final submit parity: +- module JS emits `opcFinalSubmitStarted` on the final OPC submit, +- guest-init stops reacting once `opcFinalSubmitStarted` is emitted, +- delivery option persists before final submit, +- delivery message, recyclable, gift, and gift message persist on final submit, +- payment methods load after the delivery state becomes valid, +- selecting a payment method persists `selected_payment_module` across payment refreshes, +- payment methods refresh after delivery-address and carrier updates without leaving stale panels open, +- free carts render the payment section without blocking final submit, +- checkout still falls back to native flow when the provider module is disabled. + ## Sandbox-specific workaround In restricted environments, `maildev` import can fail with: @@ -46,3 +99,13 @@ EOF 2. Selector drift in FO summary blocks can create false negatives (`.cart-summary` vs legacy totals selectors). When these happen, fix the E2E selector/assertion to be locale-agnostic and structure-agnostic, not the checkout behavior. + +## Mandatory verification points + +Before declaring Core-to-module migration behavior green, verify: +- final OPC submit emits `opcFinalSubmitStarted` exactly once per submit attempt, +- `opc-guest-init.js` still listens to `opcFinalSubmitStarted` and stops guest-init side effects after the event, +- guest init listeners stop reacting once final submit has started, +- the dedicated BO tab `AdminPsOnePageCheckout` is only usable by an employee with view access, +- the Module Manager `Configure` entry stays reachable for authorized employees and denied for unauthorized ones according to BO permissions, +- no module controller, override, or module UI path attempts to replace `RegistrationController` or its success flash. diff --git a/docs/RULES.md b/docs/RULES.md index f8b1975..ecc1463 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -3,6 +3,7 @@ ## Scope These rules are mandatory for one-page checkout implementation in the native `ps_onepagecheckout` module. +The migration reference document is [`docs/CORE_PORTING_PLAYBOOK.md`](./CORE_PORTING_PLAYBOOK.md). ## Architecture @@ -36,6 +37,19 @@ Both entry points must render the same module-owned configuration flow (no redir - `npm run build` 3. When changing files under `views/js`, developers must regenerate and commit the built assets shipped by the module from `views/public` (including `*.LICENSE.txt` files). See [`README.md` → Front assets](../README.md#front-assets). +## Core to module migration + +1. Triage every future checkout change using the playbook before coding. +2. Correct existing parity gaps in the module before porting new Core behavior. +3. Keep Core changes to the minimal no-override surface only. +4. Keep checkout business logic in the module and DOM/visual ownership in Hummingbird. + +## Test workflow + +1. Each migration lot must be implemented with a story/test pair and incremental automated verification. +2. Every lot must ship unit tests for local logic and integration tests for observable behavior. +3. After JS changes, rebuild `views/public/*` and verify the runtime contracts through tests. + ## Delivery checklist 1. Unit tests updated for changed behavior. diff --git a/ps_onepagecheckout.php b/ps_onepagecheckout.php index 3ef3c57..202f195 100644 --- a/ps_onepagecheckout.php +++ b/ps_onepagecheckout.php @@ -56,7 +56,18 @@ public function __construct() 'Modules.Psonepagecheckout.Admin' ); $this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => _PS_VERSION_]; - $this->controllers = ['GuestInit', 'AddressForm']; + $this->controllers = [ + 'guestinit', + 'addressform', + 'addresseslist', + 'states', + 'saveaddress', + 'deleteaddress', + 'carriers', + 'selectcarrier', + 'paymentmethods', + 'selectpayment', + ]; } public function install() @@ -129,7 +140,7 @@ public function hookActionFrontControllerSetMedia(): void 'urls' => [ 'guestInit' => $this->context->link->getModuleLink( $this->name, - 'GuestInit', + 'guestinit', ['ajax' => 1, 'action' => 'opcGuestInit'], null, null, @@ -138,13 +149,95 @@ public function hookActionFrontControllerSetMedia(): void ), 'addressForm' => $this->context->link->getModuleLink( $this->name, - 'AddressForm', + 'addressform', ['ajax' => 1, 'action' => 'opcAddressForm'], null, null, null, true ), + 'addressesList' => $this->context->link->getModuleLink( + $this->name, + 'addresseslist', + ['ajax' => 1, 'action' => 'opcAddressesList'], + null, + null, + null, + true + ), + 'states' => $this->context->link->getModuleLink( + $this->name, + 'states', + ['ajax' => 1, 'action' => 'getStatesByCountry'], + null, + null, + null, + true + ), + 'saveAddress' => $this->context->link->getModuleLink( + $this->name, + 'saveaddress', + ['ajax' => 1, 'action' => 'saveOpcAddress'], + null, + null, + null, + true + ), + 'deleteAddress' => $this->context->link->getModuleLink( + $this->name, + 'deleteaddress', + ['ajax' => 1, 'action' => 'deleteOpcAddress'], + null, + null, + null, + true + ), + 'carriers' => $this->context->link->getModuleLink( + $this->name, + 'carriers', + ['ajax' => 1, 'action' => 'opcCarriers'], + null, + null, + null, + true + ), + 'selectCarrier' => $this->context->link->getModuleLink( + $this->name, + 'selectcarrier', + ['ajax' => 1, 'action' => 'opcSelectCarrier'], + null, + null, + null, + true + ), + 'paymentMethods' => $this->context->link->getModuleLink( + $this->name, + 'paymentmethods', + ['ajax' => 1, 'action' => 'opcPaymentMethods'], + null, + null, + null, + true + ), + 'selectPayment' => $this->context->link->getModuleLink( + $this->name, + 'selectpayment', + ['ajax' => 1, 'action' => 'opcSelectPayment'], + null, + null, + null, + true + ), + ], + 'i18n' => [ + 'deleteAddressConfirmTitle' => $this->trans('Delete this address?', [], 'Shop.Theme.Actions'), + 'deleteAddressConfirmMessage' => $this->trans( + 'This action will remove the selected address from your checkout.', + [], + 'Shop.Theme.Checkout' + ), + 'deleteAddressConfirmLabel' => $this->trans('Delete', [], 'Shop.Theme.Actions'), + 'deleteAddressCancelLabel' => $this->trans('Cancel', [], 'Shop.Theme.Actions'), ], ]; @@ -202,6 +295,32 @@ protected function registerOpcJavascriptAssets(): void 'priority' => 151, ] ); + + $this->context->controller->registerJavascript( + 'module-ps-onepagecheckout-submit', + 'modules/' . $this->name . '/views/public/opc-submit.bundle.js', + [ + 'position' => 'bottom', + 'priority' => 149, + ] + ); + + foreach ([ + ['module-ps-onepagecheckout-address-modal', 'views/public/opc-address-modal.bundle.js', 152], + ['module-ps-onepagecheckout-carriers', 'views/public/opc-carrier-list.bundle.js', 153], + ['module-ps-onepagecheckout-select-carrier', 'views/public/opc-carrier-select.bundle.js', 154], + ['module-ps-onepagecheckout-payment-methods', 'views/public/opc-payment-list.bundle.js', 155], + ['module-ps-onepagecheckout-select-payment', 'views/public/opc-payment-select.bundle.js', 156], + ] as [$id, $path, $priority]) { + $this->context->controller->registerJavascript( + $id, + 'modules/' . $this->name . '/' . $path, + [ + 'position' => 'bottom', + 'priority' => $priority, + ] + ); + } } protected function addOpcJavascriptDefinition(array $javascriptDefinition): void diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index c25ff88..85e62b5 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -63,10 +63,13 @@ prepare_prestashop_volume() { docker run --rm \ -v "${PS_ROOT_DIR_HOST}:/source:ro" \ + -v "${REPO_DIR}:/module-source:ro" \ -v "${PS_VOLUME}:/var/www/html" \ alpine sh -lc ' cp -a /source/. /var/www/html/ && rm -rf /var/www/html/modules/ps_onepagecheckout && + mkdir -p /var/www/html/modules/ps_onepagecheckout && + cp -a /module-source/. /var/www/html/modules/ps_onepagecheckout/ && chown -R 33:33 /var/www/html ' } @@ -223,7 +226,7 @@ run_phpunit() { fi docker run "${docker_run_args[@]}" "${PHPUNIT_IMAGE}" \ - -lc "SYMFONY_DEPRECATIONS_HELPER=disabled /var/www/html/vendor/bin/phpunit -c ${PHPUNIT_CONFIG}" + -lc "if command -v composer >/dev/null 2>&1; then composer dump-autoload -o --no-interaction; fi; SYMFONY_DEPRECATIONS_HELPER=disabled /var/www/html/vendor/bin/phpunit -c ${PHPUNIT_CONFIG}" } case "${TEST_SUITE}" in diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php new file mode 100644 index 0000000..bae76a2 --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php @@ -0,0 +1,93 @@ +opcForm = $opcForm; + $this->context = $context ?? \Context::getContext(); + $this->customerResolver = $customerResolver ?? new CheckoutCustomerContextResolver($this->context); + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function getTemplateVariables(array $requestParameters): array + { + $ownedDeliveryAddress = null; + if (isset($requestParameters['id_address']) && (int) $requestParameters['id_address'] > 0) { + $ownedDeliveryAddress = $this->loadOwnedAddress((int) $requestParameters['id_address']); + if ($ownedDeliveryAddress instanceof \Address) { + $this->opcForm->fillFromAddress($ownedDeliveryAddress); + } + } + + $formParams = []; + + foreach (['id_country', 'invoice_id_country', 'use_same_address', 'id_address', 'id_address_invoice'] as $name) { + if (!isset($requestParameters[$name])) { + continue; + } + + if (in_array($name, ['id_address', 'id_address_invoice'], true) && (int) $requestParameters[$name] <= 0) { + continue; + } + + if ($name === 'id_address' && !$ownedDeliveryAddress instanceof \Address) { + continue; + } + + if ($name === 'id_address_invoice' && !$this->isOwnedAddressId((int) $requestParameters[$name])) { + continue; + } + + $formParams[$name] = $requestParameters[$name]; + } + + if (!empty($formParams)) { + $this->opcForm->fillWith($formParams); + } + + return $this->opcForm->getTemplateVariables(); + } + + private function loadOwnedAddress(int $addressId): ?\Address + { + $customerId = $this->customerResolver->resolveId(); + if ($customerId <= 0) { + return null; + } + + $address = new \Address($addressId, (int) $this->context->language->id); + if (!\Validate::isLoadedObject($address) || (int) $address->id_customer !== $customerId) { + return null; + } + + return $address; + } + + private function isOwnedAddressId(int $addressId): bool + { + return $this->loadOwnedAddress($addressId) instanceof \Address; + } +} diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php new file mode 100644 index 0000000..5a6a028 --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php @@ -0,0 +1,45 @@ +context = $context; + $this->customerResolver = $customerResolver; + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + $customer = $this->customerResolver->resolve(); + if (!$customer instanceof \Customer) { + return CheckoutAjaxResponse::error('Unable to resolve checkout customer.'); + } + + $addresses = $customer->getAddresses((int) $this->context->language->id); + $selectedAddressId = (int) ($requestParameters['id_address'] ?? 0); + $selectedAddress = null; + + foreach ($addresses as $address) { + if ((int) ($address['id_address'] ?? 0) === $selectedAddressId) { + $selectedAddress = $address; + break; + } + } + + return [ + 'success' => true, + 'addresses' => $addresses, + 'address' => $selectedAddress, + ]; + } +} diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php new file mode 100644 index 0000000..d398edf --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php @@ -0,0 +1,159 @@ +context = $context; + $this->translator = $translator; + $this->customerResolver = $customerResolver; + $this->checkoutSessionFactory = $checkoutSessionFactory ?? new CheckoutSessionFactory($context, $translator); + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + $customer = $this->customerResolver->resolve(); + if (!$customer instanceof \Customer) { + return CheckoutAjaxResponse::error('Unable to resolve checkout customer.'); + } + + $address = $this->loadOwnedAddress($customer, (int) ($requestParameters['id_address'] ?? 0)); + if (!$address instanceof \Address) { + return CheckoutAjaxResponse::error('Unable to load the requested address.'); + } + + $addressId = (int) $address->id; + + $deletedActiveDeliveryAddress = \Validate::isLoadedObject($this->context->cart) + && (int) $this->context->cart->id_address_delivery === $addressId; + $deletedActiveInvoiceAddress = \Validate::isLoadedObject($this->context->cart) + && (int) $this->context->cart->id_address_invoice === $addressId; + + if (!$this->buildAddressPersister($customer)->delete($address, \Tools::getToken(true, $this->context))) { + return CheckoutAjaxResponse::error('Unable to delete address.'); + } + + $remainingAddresses = $customer->getAddresses((int) $this->context->language->id); + $remainingAddressIds = array_map(static function (array $customerAddress): int { + return (int) ($customerAddress['id_address'] ?? 0); + }, $remainingAddresses); + $fallbackAddressId = !empty($remainingAddressIds) ? (int) reset($remainingAddressIds) : 0; + + $this->synchronizeCartAddressesAfterDeletion( + $addressId, + $remainingAddressIds, + $fallbackAddressId, + $deletedActiveDeliveryAddress, + $deletedActiveInvoiceAddress + ); + + return [ + 'success' => true, + 'id_address' => $addressId, + 'message' => $this->translator->trans( + 'Address successfully deleted.', + [], + 'Shop.Notifications.Success' + ), + ]; + } + + private function buildAddressPersister(\Customer $customer): \CustomerAddressPersister + { + $cart = \Validate::isLoadedObject($this->context->cart) ? $this->context->cart : new \Cart(); + + return new \CustomerAddressPersister( + $customer, + $cart, + \Tools::getToken(true, $this->context) + ); + } + + private function loadOwnedAddress(\Customer $customer, int $addressId): ?\Address + { + if ($addressId <= 0) { + return null; + } + + $address = new \Address($addressId, (int) $this->context->language->id); + if (!\Validate::isLoadedObject($address)) { + return null; + } + + if ((int) $address->id_customer !== (int) $customer->id) { + return null; + } + + return $address; + } + + /** + * @param list $remainingAddressIds + */ + private function synchronizeCartAddressesAfterDeletion( + int $deletedAddressId, + array $remainingAddressIds, + int $fallbackAddressId, + bool $deletedActiveDeliveryAddress, + bool $deletedActiveInvoiceAddress, + ): void { + if (!\Validate::isLoadedObject($this->context->cart)) { + return; + } + + $deliveryAddressId = $this->resolveRemainingCartAddressId( + (int) $this->context->cart->id_address_delivery, + $deletedAddressId, + $remainingAddressIds, + $fallbackAddressId + ); + $invoiceAddressId = $this->resolveRemainingCartAddressId( + (int) $this->context->cart->id_address_invoice, + $deletedAddressId, + $remainingAddressIds, + $fallbackAddressId + ); + + $checkoutSession = $this->checkoutSessionFactory->create(); + $checkoutSession->setIdAddressDelivery($deliveryAddressId); + $checkoutSession->setIdAddressInvoice($invoiceAddressId); + + if ($deletedActiveDeliveryAddress || $deletedActiveInvoiceAddress) { + $this->context->cart->setDeliveryOption(null); + } + } + + /** + * @param list $remainingAddressIds + */ + private function resolveRemainingCartAddressId( + int $currentAddressId, + int $deletedAddressId, + array $remainingAddressIds, + int $fallbackAddressId, + ): int { + if ($currentAddressId === $deletedAddressId || !in_array($currentAddressId, $remainingAddressIds, true)) { + return $fallbackAddressId; + } + + return $currentAddressId; + } +} diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php new file mode 100644 index 0000000..f59c54d --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php @@ -0,0 +1,153 @@ +context = $context; + $this->translator = $translator; + $this->customerResolver = $customerResolver; + $this->checkoutSessionFactory = $checkoutSessionFactory ?? new CheckoutSessionFactory($context, $translator); + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + $customerId = $this->customerResolver->resolveId(); + if ($customerId <= 0) { + return CheckoutAjaxResponse::error('Unable to resolve checkout customer.'); + } + + $addressType = (string) ($requestParameters['address_type'] ?? 'delivery'); + $prefix = $addressType === 'invoice' ? 'invoice_' : ''; + $addressId = (int) ($requestParameters[$prefix . 'id_address'] ?? $requestParameters['id_address'] ?? 0); + $isUpdate = $addressId > 0; + $address = $isUpdate ? new \Address($addressId, (int) $this->context->language->id) : new \Address(); + + if ($addressId > 0 && (!\Validate::isLoadedObject($address) || (int) $address->id_customer !== $customerId)) { + return CheckoutAjaxResponse::error('Unable to load the requested address.'); + } + + $addressForm = $this->createAddressForm(); + $addressForm->fillFromRequest($requestParameters, $prefix); + if (!$addressForm->validate()) { + return CheckoutAjaxResponse::validation($addressForm->getErrors()); + } + + $this->hydrateAddressFromForm($address, $addressForm, $addressType, $customerId); + + if (!$this->buildAddressPersister($customerId)->save($address, \Tools::getToken(true, $this->context))) { + return CheckoutAjaxResponse::error('Unable to save address.'); + } + + $deliveryAddressId = (int) $this->context->cart->id_address_delivery; + $invoiceAddressId = (int) $this->context->cart->id_address_invoice; + + if ($addressType === 'invoice') { + $invoiceAddressId = (int) $address->id; + } else { + $deliveryAddressId = (int) $address->id; + if ((string) ($requestParameters['use_same_address'] ?? '1') !== '0') { + $invoiceAddressId = (int) $address->id; + } + } + + if (\Validate::isLoadedObject($this->context->cart)) { + $checkoutSession = $this->checkoutSessionFactory->create(); + $checkoutSession->setIdAddressDelivery($deliveryAddressId); + $checkoutSession->setIdAddressInvoice($invoiceAddressId); + } + + return [ + 'success' => true, + 'id_address' => (int) $address->id, + 'id_address_delivery' => $deliveryAddressId, + 'id_address_invoice' => $invoiceAddressId, + 'address_type' => $addressType, + 'message' => $this->translator->trans( + $isUpdate ? 'Address successfully updated.' : 'Address successfully added.', + [], + 'Shop.Notifications.Success' + ), + ]; + } + + private function createAddressForm(): OnePageCheckoutAddressForm + { + return new OnePageCheckoutAddressForm( + $this->context->smarty, + $this->context->language, + $this->translator, + $this->createAddressFormatter() + ); + } + + private function createAddressFormatter(): OnePageCheckoutAddressFormatter + { + $country = $this->context->country; + if (!$country instanceof \Country) { + $country = new \Country( + (int) \Configuration::get('PS_COUNTRY_DEFAULT'), + (int) ($this->context->language->id ?? 0) + ); + } + + $availableCountries = \Configuration::get('PS_RESTRICT_DELIVERED_COUNTRIES') + ? \Carrier::getDeliveredCountries((int) $this->context->language->id, true, true) + : \Country::getCountries((int) $this->context->language->id, true); + + return new OnePageCheckoutAddressFormatter( + $country, + $this->translator, + $availableCountries + ); + } + + private function hydrateAddressFromForm( + \Address $address, + OnePageCheckoutAddressForm $addressForm, + string $addressType, + int $customerId, + ): void { + $address = $addressForm->buildAddress($address); + + $address->id_customer = $customerId; + $address->alias = trim((string) ($address->alias ?: ($addressType === 'invoice' + ? $this->translator->trans('Invoice address', [], 'Shop.Theme.Checkout') + : $this->translator->trans('My Address', [], 'Shop.Theme.Checkout')))); + $address->id_country = (int) $address->id_country; + $address->id_state = (int) ($address->id_state ?: 0); + \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$address]); + } + + private function buildAddressPersister(int $customerId): \CustomerAddressPersister + { + $customer = new \Customer($customerId); + $cart = \Validate::isLoadedObject($this->context->cart) ? $this->context->cart : new \Cart(); + + return new \CustomerAddressPersister( + $customer, + $cart, + \Tools::getToken(true, $this->context) + ); + } +} diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutStatesHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutStatesHandler.php new file mode 100644 index 0000000..451c375 --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutStatesHandler.php @@ -0,0 +1,33 @@ + $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + $countryId = (int) ($requestParameters['id_country'] ?? 0); + if ($countryId <= 0) { + return [ + 'success' => true, + 'id_country' => 0, + 'contains_states' => false, + 'states' => [], + ]; + } + + $states = \State::getStatesByIdCountry($countryId); + + return [ + 'success' => true, + 'id_country' => $countryId, + 'contains_states' => !empty($states), + 'states' => $states, + ]; + } +} diff --git a/src/Checkout/Ajax/Address/OpcTempAddress.php b/src/Checkout/Ajax/Address/OpcTempAddress.php new file mode 100644 index 0000000..601bf48 --- /dev/null +++ b/src/Checkout/Ajax/Address/OpcTempAddress.php @@ -0,0 +1,90 @@ +context = $context; + } + + /** + * @param array $requestParameters + */ + public function createFromRequest(array $requestParameters = []): int + { + $idCountry = (int) ($requestParameters['id_country'] ?? $requestParameters['delivery_id_country'] ?? 0); + if ($idCountry <= 0) { + return 0; + } + + $tempAddressId = $this->insert( + $idCountry, + (int) ($requestParameters['id_state'] ?? $requestParameters['delivery_id_state'] ?? 0), + (string) ($requestParameters['postcode'] ?? $requestParameters['delivery_postcode'] ?? '00000'), + (string) ($requestParameters['city'] ?? $requestParameters['delivery_city'] ?? '-') + ); + + $this->context->cart->id_address_delivery = $tempAddressId; + + if ((string) \Configuration::get('PS_TAX_ADDRESS_TYPE') === 'id_address_invoice') { + $invoiceIdCountry = (int) ($requestParameters['invoice_id_country'] ?? 0) ?: $idCountry; + $this->originalInvoiceAddressId = (int) $this->context->cart->id_address_invoice; + $this->tempInvoiceAddressId = $this->insert( + $invoiceIdCountry, + (int) ($requestParameters['invoice_id_state'] ?? $requestParameters['id_state'] ?? $requestParameters['delivery_id_state'] ?? 0), + (string) ($requestParameters['invoice_postcode'] ?? $requestParameters['postcode'] ?? $requestParameters['delivery_postcode'] ?? '00000'), + (string) ($requestParameters['invoice_city'] ?? $requestParameters['city'] ?? $requestParameters['delivery_city'] ?? '-') + ); + $this->context->cart->id_address_invoice = $this->tempInvoiceAddressId; + } + + $this->context->cart->save(); + + return $tempAddressId; + } + + public function cleanup(int $tempAddressId, int $originalAddressId): void + { + $this->context->cart->id_address_delivery = $originalAddressId; + + if ($this->tempInvoiceAddressId > 0) { + $this->context->cart->id_address_invoice = $this->originalInvoiceAddressId; + } + + $this->context->cart->save(); + \Db::getInstance()->delete('address', 'id_address = ' . (int) $tempAddressId); + + if ($this->tempInvoiceAddressId > 0) { + \Db::getInstance()->delete('address', 'id_address = ' . (int) $this->tempInvoiceAddressId); + $this->tempInvoiceAddressId = 0; + $this->originalInvoiceAddressId = 0; + } + } + + private function insert(int $idCountry, int $idState, string $postcode, string $city): int + { + \Db::getInstance()->insert('address', [ + 'id_country' => $idCountry, + 'id_state' => $idState, + 'id_customer' => (int) $this->context->customer->id, + 'alias' => 'temp_opc_' . bin2hex(random_bytes(8)), + 'firstname' => '-', + 'lastname' => '-', + 'address1' => '-', + 'city' => $city ?: '-', + 'postcode' => $postcode ?: '00000', + 'active' => 1, + 'deleted' => 0, + 'date_add' => date('Y-m-d H:i:s'), + 'date_upd' => date('Y-m-d H:i:s'), + ]); + + return (int) \Db::getInstance()->Insert_ID(); + } +} diff --git a/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php b/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php new file mode 100644 index 0000000..6fe5c80 --- /dev/null +++ b/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php @@ -0,0 +1,141 @@ +context = $context; + $this->translator = $translator; + $this->customerResolver = $customerResolver ?? new CheckoutCustomerContextResolver($context); + $this->checkoutSessionFactory = $checkoutSessionFactory ?? new CheckoutSessionFactory($context, $translator, $deliveryOptionsFinder); + $this->cartPresenterHelper = $cartPresenterHelper ?? new CartPresenterHelper($context); + $this->tempCarrierSelectionStorage = $tempCarrierSelectionStorage ?? new TempAddressCarrierSelectionStorage($context); + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + if (!\Validate::isLoadedObject($this->context->cart)) { + return CheckoutAjaxResponse::error('Unable to resolve checkout cart.'); + } + + $originalAddressId = (int) $this->context->cart->id_address_delivery; + $tempAddress = new OpcTempAddress($this->context); + $tempAddressId = 0; + + try { + if (!empty($requestParameters['id_address_delivery'])) { + $requestedAddressId = (int) $requestParameters['id_address_delivery']; + if (!$this->isOwnedCheckoutAddress($requestedAddressId)) { + return CheckoutAjaxResponse::error( + $this->translator->trans('Invalid delivery address.', [], 'Shop.Notifications.Error'), + 'id_address_delivery' + ); + } + + $this->context->cart->id_address_delivery = $requestedAddressId; + $this->context->cart->save(); + $this->tempCarrierSelectionStorage->clear(); + } else { + $tempAddressId = $tempAddress->createFromRequest($requestParameters); + } + + if ((int) $this->context->cart->id_address_delivery <= 0) { + return [ + 'success' => true, + 'delivery_options' => [], + 'delivery_option' => '', + 'selected_delivery_option' => '', + 'id_address_delivery' => 0, + ]; + } + + $persistedTempOption = ''; + if ($tempAddressId > 0 && $originalAddressId <= 0) { + $persistedTempOption = $this->tempCarrierSelectionStorage->get(); + if ($persistedTempOption !== '') { + $this->checkoutSessionFactory->create()->setDeliveryOption([ + (int) $this->context->cart->id_address_delivery => $persistedTempOption, + ]); + } + } + + $finder = $this->checkoutSessionFactory->createDeliveryOptionsFinder(); + $deliveryOptions = $finder->getDeliveryOptions(); + $selectedDeliveryOption = $finder->getSelectedDeliveryOption(); + $hadSelectedDeliveryOption = (bool) $selectedDeliveryOption; + + if ($selectedDeliveryOption && !isset($deliveryOptions[$selectedDeliveryOption])) { + $selectedDeliveryOption = null; + } + + if ($persistedTempOption !== '') { + if (isset($deliveryOptions[$persistedTempOption])) { + $selectedDeliveryOption = $persistedTempOption; + } else { + $this->tempCarrierSelectionStorage->clear(); + } + } + + $deliveryAddressId = $tempAddressId ?: (int) $this->context->cart->id_address_delivery; + + if ($selectedDeliveryOption && $deliveryAddressId > 0) { + $this->checkoutSessionFactory->create()->setDeliveryOption([$deliveryAddressId => $selectedDeliveryOption]); + } elseif ($hadSelectedDeliveryOption && $deliveryAddressId > 0) { + $this->context->cart->setDeliveryOption(null); + } + + $cartPreview = $this->cartPresenterHelper->presentCart(); + + return [ + 'success' => true, + 'delivery_options' => $deliveryOptions, + 'delivery_option' => $selectedDeliveryOption ?: '', + 'selected_delivery_option' => $selectedDeliveryOption ?: '', + 'id_address_delivery' => $tempAddressId > 0 ? 0 : (int) $this->context->cart->id_address_delivery, + 'cart_preview' => $cartPreview, + 'totals' => $cartPreview['totals'], + ]; + } finally { + if ($tempAddressId > 0) { + $tempAddress->cleanup($tempAddressId, $originalAddressId); + } + } + } + + protected function isOwnedCheckoutAddress(int $addressId): bool + { + if ($addressId <= 0) { + return false; + } + + $customerId = $this->customerResolver->resolveId(); + if ($customerId <= 0) { + return false; + } + + return \Customer::customerHasAddress($customerId, $addressId); + } +} diff --git a/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php b/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php new file mode 100644 index 0000000..70511e7 --- /dev/null +++ b/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php @@ -0,0 +1,101 @@ +context = $context; + $this->translator = $translator; + $this->checkoutSessionFactory = $checkoutSessionFactory ?? new CheckoutSessionFactory($context, $translator, $deliveryOptionsFinder); + $this->cartPresenterHelper = $cartPresenterHelper ?? new CartPresenterHelper($context); + $this->tempCarrierSelectionStorage = $tempCarrierSelectionStorage ?? new TempAddressCarrierSelectionStorage($context); + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + $deliveryOption = (string) ($requestParameters['delivery_option'] ?? ''); + if ($deliveryOption === '') { + return CheckoutAjaxResponse::error( + $this->translator->trans('Missing delivery option.', [], 'Shop.Notifications.Error'), + 'delivery_option' + ); + } + + if (!\Validate::isLoadedObject($this->context->cart)) { + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to resolve the current cart.', [], 'Shop.Notifications.Error') + ); + } + + $originalAddressId = (int) $this->context->cart->id_address_delivery; + $tempAddress = new OpcTempAddress($this->context); + $tempAddressId = 0; + + try { + $tempAddressId = $tempAddress->createFromRequest($requestParameters); + $deliveryAddressId = $tempAddressId ?: $originalAddressId; + + if ($deliveryAddressId <= 0) { + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to resolve the current delivery address.', [], 'Shop.Notifications.Error') + ); + } + + $this->persistCarrierSelection($deliveryAddressId, $deliveryOption); + $this->persistTemporaryCarrierSelection($deliveryOption, $tempAddressId > 0 && $originalAddressId <= 0); + + $cartPreview = $this->cartPresenterHelper->presentCart(); + + return [ + 'success' => true, + 'delivery_option' => $deliveryOption, + 'id_address_delivery' => $tempAddressId > 0 ? 0 : $deliveryAddressId, + 'cart_preview' => $cartPreview, + 'totals' => $cartPreview['totals'], + ]; + } finally { + if ($tempAddressId > 0) { + $tempAddress->cleanup($tempAddressId, $originalAddressId); + } + } + } + + private function persistCarrierSelection(int $deliveryAddressId, string $deliveryOption): void + { + $this->checkoutSessionFactory->create()->setDeliveryOption([ + $deliveryAddressId => $deliveryOption, + ]); + } + + private function persistTemporaryCarrierSelection(string $deliveryOption, bool $shouldPersist): void + { + if ($shouldPersist) { + $this->tempCarrierSelectionStorage->save($deliveryOption); + + return; + } + + $this->tempCarrierSelectionStorage->clear(); + } +} diff --git a/src/Checkout/Ajax/Carrier/TempAddressCarrierSelectionStorage.php b/src/Checkout/Ajax/Carrier/TempAddressCarrierSelectionStorage.php new file mode 100644 index 0000000..0af2a80 --- /dev/null +++ b/src/Checkout/Ajax/Carrier/TempAddressCarrierSelectionStorage.php @@ -0,0 +1,44 @@ +context = $context; + } + + public function get(): string + { + if (!isset($this->context->cookie)) { + return ''; + } + + return (string) ($this->context->cookie->__get(self::COOKIE_KEY) ?: ''); + } + + public function save(string $deliveryOption): void + { + if (!isset($this->context->cookie)) { + return; + } + + $this->context->cookie->__set(self::COOKIE_KEY, $deliveryOption); + $this->context->cookie->write(); + } + + public function clear(): void + { + if (!isset($this->context->cookie)) { + return; + } + + $this->context->cookie->__unset(self::COOKIE_KEY); + $this->context->cookie->write(); + } +} diff --git a/src/Checkout/Ajax/Customer/CheckoutCustomerContextResolver.php b/src/Checkout/Ajax/Customer/CheckoutCustomerContextResolver.php new file mode 100644 index 0000000..e212cc6 --- /dev/null +++ b/src/Checkout/Ajax/Customer/CheckoutCustomerContextResolver.php @@ -0,0 +1,55 @@ +context = $context; + } + + public function resolve(): ?\Customer + { + $persistedOwner = $this->resolvePersistedCartOwner(); + if ($persistedOwner instanceof \Customer) { + return $persistedOwner; + } + + if (\Validate::isLoadedObject($this->context->customer) && (int) $this->context->customer->id > 0) { + return $this->context->customer; + } + + return null; + } + + public function resolveId(): int + { + $customer = $this->resolve(); + + return $customer instanceof \Customer ? (int) $customer->id : 0; + } + + private function resolvePersistedCartOwner(): ?\Customer + { + if (!\Validate::isLoadedObject($this->context->cart)) { + return null; + } + + $cartId = (int) $this->context->cart->id; + if ($cartId <= 0) { + return null; + } + + $freshCart = new \Cart($cartId); + if (!\Validate::isLoadedObject($freshCart) || (int) $freshCart->id_customer <= 0) { + return null; + } + + $customer = new \Customer((int) $freshCart->id_customer); + + return \Validate::isLoadedObject($customer) ? $customer : null; + } +} diff --git a/src/Checkout/Ajax/OnePageCheckoutGuestInitHandler.php b/src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php similarity index 96% rename from src/Checkout/Ajax/OnePageCheckoutGuestInitHandler.php rename to src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php index e620cb3..832da11 100644 --- a/src/Checkout/Ajax/OnePageCheckoutGuestInitHandler.php +++ b/src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php @@ -145,6 +145,10 @@ public function handle(array $requestParameters): array ); } + if (!$this->hasFreshPersistedCartRow()) { + return $this->cartSyncErrorResponse(); + } + $existingCustomerState = $this->resolveExistingCustomerState(); $existingCustomerResponse = $this->handleExistingCustomer($existingCustomerState); @@ -284,28 +288,20 @@ private function isLoadedCustomerId(int $customerId): bool */ private function resolveExistingCustomerId(): int { - $contextCustomerId = (int) $this->context->customer->id; if (!\Validate::isLoadedObject($this->context->cart)) { - return $contextCustomerId; + return (int) $this->context->customer->id; } - // Read latest persisted owner before using in-memory cart value. $freshCartCustomerId = $this->getFreshCartCustomerId(); if ($freshCartCustomerId > 0 && $this->isLoadedCustomerId($freshCartCustomerId)) { return $freshCartCustomerId; } if ($freshCartCustomerId > 0) { - // Ignore stale persisted owner and continue with current request context. - return $contextCustomerId > 0 ? $contextCustomerId : self::CUSTOMER_ID_NONE; - } - - $contextCartCustomerId = (int) $this->context->cart->id_customer; - if ($contextCartCustomerId > 0) { - return $contextCartCustomerId; + return self::CUSTOMER_ID_NONE; } - return $contextCustomerId; + return self::CUSTOMER_ID_NONE; } /** @@ -337,6 +333,20 @@ protected function getFreshCartCustomerId(): int return (int) $customerId; } + protected function hasFreshPersistedCartRow(): bool + { + if (!\Validate::isLoadedObject($this->context->cart)) { + return false; + } + + $cartId = (int) $this->context->cart->id; + if ($cartId <= 0) { + return false; + } + + return \Validate::isLoadedObject($this->loadCartById($cartId)); + } + /** * Guest init applies only when cart exists and has products. * @@ -444,6 +454,10 @@ private function resolveGuestEmail(string $submittedEmail, ExistingCustomerState return null; } + if (!$existingCustomerState->hasCustomer()) { + return null; + } + if (!$existingCustomerState->isGuestCustomer()) { return $this->resolveEmailForNonGuest($submittedEmail, $existingCustomerId); } @@ -501,7 +515,7 @@ private function resolveEmailForNonGuest(string $submittedEmail, int $existingCu } if ($existingCustomerId === self::CUSTOMER_ID_NONE) { - return $this->successResponse(); + return null; } // Keep current owner if email points to another account. diff --git a/src/Checkout/Ajax/OnePageCheckoutAddressFormHandler.php b/src/Checkout/Ajax/OnePageCheckoutAddressFormHandler.php deleted file mode 100644 index 08d3b78..0000000 --- a/src/Checkout/Ajax/OnePageCheckoutAddressFormHandler.php +++ /dev/null @@ -1,48 +0,0 @@ -opcForm = $opcForm; - } - - /** - * @param array $requestParameters - * - * @return array - */ - public function getTemplateVariables(array $requestParameters): array - { - if (isset($requestParameters['id_address']) && (int) $requestParameters['id_address'] > 0) { - $this->opcForm->fillFromAddress(new \Address((int) $requestParameters['id_address'], \Context::getContext()->language->id)); - } - - $formParams = []; - - foreach (['id_country', 'invoice_id_country', 'use_same_address'] as $name) { - if (isset($requestParameters[$name])) { - $formParams[$name] = $requestParameters[$name]; - } - } - - if (!empty($formParams)) { - $this->opcForm->fillWith($formParams); - } - - return $this->opcForm->getTemplateVariables(); - } -} diff --git a/src/Checkout/Ajax/Payment/OnePageCheckoutPaymentMethodsHandler.php b/src/Checkout/Ajax/Payment/OnePageCheckoutPaymentMethodsHandler.php new file mode 100644 index 0000000..9ed7430 --- /dev/null +++ b/src/Checkout/Ajax/Payment/OnePageCheckoutPaymentMethodsHandler.php @@ -0,0 +1,217 @@ +context = $context; + $this->paymentOptionsFinder = $paymentOptionsFinder ?? new \PaymentOptionsFinder(); + $this->selectionKeyBuilder = $selectionKeyBuilder ?? new PaymentSelectionKeyBuilder(); + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + if (!\Validate::isLoadedObject($this->context->cart)) { + return CheckoutAjaxResponse::error('Unable to load cart.'); + } + + $originalCountry = $this->context->country; + + try { + $this->resolveCountryContext($requestParameters); + + $isFree = 0.0 == (float) $this->context->cart->getOrderTotal(true, \Cart::BOTH); + $paymentOptions = $this->paymentOptionsFinder->present($isFree); + $paymentOptions = $this->filterByCountry($paymentOptions); + $paymentOptions = $this->selectionKeyBuilder->enrichPaymentOptions($paymentOptions); + [$selectedPaymentModule, $selectedPaymentSelectionKey] = $this->resolvePersistedSelection($paymentOptions); + + return [ + 'success' => true, + 'payment_options' => $paymentOptions, + 'is_free' => $isFree, + 'selected_payment_module' => $selectedPaymentModule, + 'selected_payment_selection_key' => $selectedPaymentSelectionKey, + ]; + } finally { + $this->context->country = $originalCountry; + } + } + + /** + * @param array $paymentOptions + * + * @return array + */ + private function filterByCountry(array $paymentOptions): array + { + if ($paymentOptions === []) { + return $paymentOptions; + } + + if (!\Validate::isLoadedObject($this->context->country)) { + return $paymentOptions; + } + + $moduleNames = array_keys($paymentOptions); + $rows = \Db::getInstance()->executeS( + 'SELECT m.`name`, mc.`id_country` + FROM `' . _DB_PREFIX_ . 'module` m + INNER JOIN `' . _DB_PREFIX_ . 'module_country` mc ON mc.`id_module` = m.`id_module` + WHERE mc.`id_shop` = ' . (int) $this->context->shop->id . ' + AND m.`name` IN (\'' . implode("','", array_map('pSQL', $moduleNames)) . '\')' + ); + + if ($rows === false) { + return $paymentOptions; + } + + $hasRestriction = []; + $allowedForCountry = []; + foreach ($rows as $row) { + $moduleName = (string) ($row['name'] ?? ''); + if ($moduleName === '') { + continue; + } + + $hasRestriction[$moduleName] = true; + if ((int) $row['id_country'] === (int) $this->context->country->id) { + $allowedForCountry[$moduleName] = true; + } + } + + return array_filter( + $paymentOptions, + static function ($moduleName) use ($hasRestriction, $allowedForCountry): bool { + return !isset($hasRestriction[$moduleName]) || isset($allowedForCountry[$moduleName]); + }, + ARRAY_FILTER_USE_KEY + ); + } + + /** + * @param array $requestParameters + */ + private function resolveCountryContext(array $requestParameters): void + { + $taxAddressType = (string) \Configuration::get('PS_TAX_ADDRESS_TYPE'); + + if ((int) ($this->context->cart->{$taxAddressType} ?? 0) > 0) { + return; + } + + if ($taxAddressType === 'id_address_invoice') { + $idCountry = (int) ($requestParameters['invoice_id_country'] ?? 0) ?: (int) ($requestParameters['id_country'] ?? 0); + } else { + $idCountry = (int) ($requestParameters['id_country'] ?? 0) ?: (int) ($requestParameters['delivery_id_country'] ?? 0); + } + + if ($idCountry <= 0) { + return; + } + + $country = new \Country($idCountry); + if (\Validate::isLoadedObject($country)) { + $this->context->country = $country; + } + } + + private function getSelectedPaymentModule(): string + { + if (!isset($this->context->cookie)) { + return ''; + } + + return (string) ($this->context->cookie->__get('opc_selected_payment_module') ?: ''); + } + + private function getSelectedPaymentSelectionKey(): string + { + if (!isset($this->context->cookie)) { + return ''; + } + + return (string) ($this->context->cookie->__get('opc_selected_payment_selection_key') ?: ''); + } + + /** + * @param array $paymentOptions + * + * @return array{0:string,1:string} + */ + private function resolvePersistedSelection(array $paymentOptions): array + { + $selectedPaymentModule = $this->getSelectedPaymentModule(); + $selectedPaymentSelectionKey = $this->getSelectedPaymentSelectionKey(); + + if ($selectedPaymentModule === '' && $selectedPaymentSelectionKey === '') { + return ['', '']; + } + + if ($this->hasValidPersistedSelection($paymentOptions, $selectedPaymentSelectionKey, $selectedPaymentModule)) { + return [$selectedPaymentModule, $selectedPaymentSelectionKey]; + } + + $this->clearPersistedSelection(); + + return ['', '']; + } + + /** + * @param array $paymentOptions + */ + private function hasValidPersistedSelection( + array $paymentOptions, + string $selectedPaymentSelectionKey, + string $selectedPaymentModule, + ): bool { + if ($selectedPaymentSelectionKey !== '') { + foreach ($paymentOptions as $moduleOptions) { + if (!is_array($moduleOptions)) { + continue; + } + + foreach ($moduleOptions as $paymentOption) { + if ( + is_array($paymentOption) + && (string) ($paymentOption['selection_key'] ?? '') === $selectedPaymentSelectionKey + ) { + return true; + } + } + } + + return false; + } + + return $selectedPaymentModule !== '' && array_key_exists($selectedPaymentModule, $paymentOptions); + } + + private function clearPersistedSelection(): void + { + if (!isset($this->context->cookie)) { + return; + } + + $this->context->cookie->__unset('opc_selected_payment_option'); + $this->context->cookie->__unset('opc_selected_payment_module'); + $this->context->cookie->__unset('opc_selected_payment_selection_key'); + $this->context->cookie->write(); + } +} diff --git a/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php b/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php new file mode 100644 index 0000000..4afde7d --- /dev/null +++ b/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php @@ -0,0 +1,51 @@ +context = $context; + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + $paymentOption = $requestParameters['payment_option'] ?? null; + $paymentModule = $requestParameters['payment_module'] ?? null; + $paymentSelectionKey = $requestParameters['payment_selection_key'] ?? null; + + if ($this->hasMissingPaymentSelectionPayload($paymentOption, $paymentModule, $paymentSelectionKey)) { + return CheckoutAjaxResponse::error('Missing payment selection payload'); + } + + $this->context->cookie->__set('opc_selected_payment_option', $paymentOption); + $this->context->cookie->__set('opc_selected_payment_module', $paymentModule); + $this->context->cookie->__set('opc_selected_payment_selection_key', $paymentSelectionKey); + $this->context->cookie->write(); + + return [ + 'success' => true, + 'payment_option' => $paymentOption, + 'payment_module' => $paymentModule, + 'payment_selection_key' => $paymentSelectionKey, + ]; + } + + private function hasMissingPaymentSelectionPayload($paymentOption, $paymentModule, $paymentSelectionKey): bool + { + return !is_string($paymentOption) + || $paymentOption === '' + || !is_string($paymentModule) + || $paymentModule === '' + || !is_string($paymentSelectionKey) + || $paymentSelectionKey === ''; + } +} diff --git a/src/Checkout/Ajax/Shared/CartPresenterHelper.php b/src/Checkout/Ajax/Shared/CartPresenterHelper.php new file mode 100644 index 0000000..60b63ab --- /dev/null +++ b/src/Checkout/Ajax/Shared/CartPresenterHelper.php @@ -0,0 +1,24 @@ +context = $context; + } + + public function presentCart(): CartLazyArray + { + $this->context->cart->resetProductRelatedStaticCache(); + \Cache::clean('presentedCart_*'); + + return (new CartPresenter())->present($this->context->cart, true); + } +} diff --git a/src/Checkout/Ajax/Shared/CheckoutAjaxResponse.php b/src/Checkout/Ajax/Shared/CheckoutAjaxResponse.php new file mode 100644 index 0000000..8016865 --- /dev/null +++ b/src/Checkout/Ajax/Shared/CheckoutAjaxResponse.php @@ -0,0 +1,32 @@ + + */ + public static function error(string $message, string $field = ''): array + { + return [ + 'success' => false, + 'errors' => [ + $field => [$message], + ], + ]; + } + + /** + * @param array> $errors + * + * @return array + */ + public static function validation(array $errors): array + { + return [ + 'success' => false, + 'errors' => $errors, + ]; + } +} diff --git a/src/Checkout/Ajax/Shared/CheckoutSessionFactory.php b/src/Checkout/Ajax/Shared/CheckoutSessionFactory.php new file mode 100644 index 0000000..6de9e2a --- /dev/null +++ b/src/Checkout/Ajax/Shared/CheckoutSessionFactory.php @@ -0,0 +1,46 @@ +context = $context; + $this->translator = $translator; + $this->deliveryOptionsFinder = $deliveryOptionsFinder; + } + + public function create(): \CheckoutSession + { + return new \CheckoutSession( + $this->context, + $this->createDeliveryOptionsFinder() + ); + } + + public function createDeliveryOptionsFinder(): \DeliveryOptionsFinder + { + if ($this->deliveryOptionsFinder) { + return $this->deliveryOptionsFinder; + } + + return new \DeliveryOptionsFinder( + $this->context, + $this->translator, + new ObjectPresenter(), + new PriceFormatter() + ); + } +} diff --git a/src/Checkout/CheckoutOnePageStep.php b/src/Checkout/CheckoutOnePageStep.php index 37d31f4..dd531fe 100644 --- a/src/Checkout/CheckoutOnePageStep.php +++ b/src/Checkout/CheckoutOnePageStep.php @@ -46,6 +46,8 @@ class CheckoutOnePageStep extends \AbstractCheckoutStep */ public $paymentOptionsFinder; + private PaymentSelectionKeyBuilder $paymentSelectionKeyBuilder; + /** * @var \ConditionsToApproveFinder */ @@ -66,6 +68,7 @@ class CheckoutOnePageStep extends \AbstractCheckoutStep * @param OnePageCheckoutForm $opcForm * @param \PaymentOptionsFinder $paymentOptionsFinder * @param \ConditionsToApproveFinder $conditionsToApproveFinder + * @param PaymentSelectionKeyBuilder|null $paymentSelectionKeyBuilder */ public function __construct( \Context $context, @@ -73,11 +76,13 @@ public function __construct( OnePageCheckoutForm $opcForm, \PaymentOptionsFinder $paymentOptionsFinder, \ConditionsToApproveFinder $conditionsToApproveFinder, + ?PaymentSelectionKeyBuilder $paymentSelectionKeyBuilder = null, ) { parent::__construct($context, $translator); $this->opcForm = $opcForm; $this->paymentOptionsFinder = $paymentOptionsFinder; $this->conditionsToApproveFinder = $conditionsToApproveFinder; + $this->paymentSelectionKeyBuilder = $paymentSelectionKeyBuilder ?? new PaymentSelectionKeyBuilder(); } // Delivery options setters (like CheckoutDeliveryStep) @@ -151,6 +156,15 @@ public function handleRequest(array $requestParameters = []) $this->hydrateOpcFromSession(); } + if ( + !$this->context->cart->isVirtualCart() + && isset($requestParameters['delivery_option']) + && is_array($requestParameters['delivery_option']) + && !isset($requestParameters['submitOnePageCheckout']) + ) { + $this->getCheckoutSession()->setDeliveryOption($requestParameters['delivery_option']); + } + // Handle submission if (isset($requestParameters['submitOnePageCheckout'])) { $this->handleOnePageCheckoutSubmit($requestParameters); @@ -193,6 +207,8 @@ private function hydrateOpcFromSession(): void private function handleOnePageCheckoutSubmit(array $requestParameters): void { + $this->hydrateOpcFromSubmittedAddresses($requestParameters); + $validationResult = $this->validateAllSections($requestParameters); if (!$this->isAllSectionsValid($validationResult)) { $this->getCheckoutProcess()->setHasErrors(true); @@ -226,8 +242,19 @@ private function validateAllSections(array $requestParameters) 'conditions' => true, ]; - // 1. Identity validation: email only - if (empty($requestParameters['email']) || !\Validate::isEmail($requestParameters['email'])) { + // 1. Identity validation: registered customers reuse the email already stored in session. + $customer = $this->context->customer; + $email = (string) ($requestParameters['email'] ?? ''); + if ( + $email === '' + && $customer instanceof \Customer + && $customer->isLogged() + && !$customer->isGuest() + ) { + $email = (string) $customer->email; + } + + if ($email === '' || !\Validate::isEmail($email)) { $result['identity'] = false; $this->validationErrors['identity'] = [ 'email' => $this->getTranslator()->trans( @@ -298,6 +325,22 @@ function ($carry, $item) { $this->getCheckoutSession()->setIdAddressDelivery($addressIds['id_address_delivery']); $this->getCheckoutSession()->setIdAddressInvoice($addressIds['id_address_invoice']); + if (isset($requestParameters['delivery_message'])) { + $this->getCheckoutSession()->setMessage($requestParameters['delivery_message']); + } + + if ($this->isRecyclablePackAllowed()) { + $this->getCheckoutSession()->setRecyclable($requestParameters['recyclable'] ?? false); + } + + if ($this->isGiftAllowed()) { + $useGift = $requestParameters['gift'] ?? false; + $this->getCheckoutSession()->setGift( + $useGift, + $useGift ? ($requestParameters['gift_message'] ?? '') : '' + ); + } + // Sync customer name from delivery address if needed $customer = $this->getCheckoutSession()->getCustomer(); if ($customer && ($customer->isGuest() || empty($customer->firstname) || empty($customer->lastname))) { @@ -313,6 +356,29 @@ function ($carry, $item) { return true; } + private function hydrateOpcFromSubmittedAddresses(array $requestParameters): void + { + $deliveryAddressId = (int) ($requestParameters['id_address'] ?? 0); + $invoiceAddressId = (int) ($requestParameters['id_address_invoice'] ?? 0); + $customerId = (int) ($this->context->customer->id ?? 0); + + if (($deliveryAddressId <= 0 && $invoiceAddressId <= 0) || $customerId <= 0) { + return; + } + + $languageId = (int) $this->context->language->id; + $deliveryAddress = $this->resolveSubmittedAddress($deliveryAddressId, $customerId, $languageId); + $invoiceAddress = null; + + if ($invoiceAddressId > 0 && $invoiceAddressId !== $deliveryAddressId) { + $invoiceAddress = $this->resolveSubmittedAddress($invoiceAddressId, $customerId, $languageId); + } + + if ($deliveryAddress || $invoiceAddress) { + $this->opcForm->fillFromAddresses($deliveryAddress, $invoiceAddress); + } + } + /** * Get validation errors * @@ -323,20 +389,12 @@ public function getValidationErrors() return $this->validationErrors; } - /** - * Keep step identifier stable to avoid persistence regressions in checkout_session_data. - * - * @return string - */ - public function getIdentifier() - { - return 'checkout-one-page-step'; - } - public function render(array $extraParams = []) { $isFree = 0 == (float) $this->getCheckoutSession()->getCart()->getOrderTotal(true, \Cart::BOTH); - $paymentOptions = $this->paymentOptionsFinder->present($isFree); + $paymentOptions = $this->paymentSelectionKeyBuilder->enrichPaymentOptions( + $this->paymentOptionsFinder->present($isFree) + ); $conditionsToApprove = $this->conditionsToApproveFinder->getConditionsToApproveForTemplate(); $deliveryOptions = $this->getCheckoutSession()->getDeliveryOptions(); $deliveryOptionKey = $this->getCheckoutSession()->getSelectedDeliveryOption(); @@ -352,13 +410,14 @@ public function render(array $extraParams = []) } $assignedVars = [ - 'opc_form' => $this->opcForm->getProxy(), 'hookDisplayBeforeCarrier' => \Hook::exec('displayBeforeCarrier', ['cart' => $this->getCheckoutSession()->getCart()]), 'hookDisplayAfterCarrier' => \Hook::exec('displayAfterCarrier', ['cart' => $this->getCheckoutSession()->getCart()]), 'delivery_options' => $deliveryOptions, 'delivery_option' => $deliveryOptionKey, 'selected_delivery_option' => $selectedDeliveryOption, 'payment_options' => $paymentOptions, + 'selected_payment_module' => $this->getSelectedPaymentModule(), + 'selected_payment_selection_key' => $this->getSelectedPaymentSelectionKey(), 'conditions_to_approve' => $conditionsToApprove, 'validation_errors' => $this->validationErrors, 'recyclable' => $this->getCheckoutSession()->isRecyclable(), @@ -370,8 +429,55 @@ public function render(array $extraParams = []) 'message' => $this->getCheckoutSession()->getGift()['message'], ], 'is_virtual_cart' => $this->context->cart->isVirtualCart(), - ]; + 'configuration' => $this->getTemplateConfiguration(), + ] + $this->opcForm->getTemplateVariables(); return $this->renderTemplate($this->getTemplate(), $extraParams, $assignedVars); } + + private function getSelectedPaymentModule(): string + { + if (!isset($this->context->cookie)) { + return ''; + } + + return (string) ($this->context->cookie->__get('opc_selected_payment_module') ?: ''); + } + + private function getSelectedPaymentSelectionKey(): string + { + if (!isset($this->context->cookie)) { + return ''; + } + + return (string) ($this->context->cookie->__get('opc_selected_payment_selection_key') ?: ''); + } + + private function resolveSubmittedAddress(int $addressId, int $customerId, int $languageId): ?\Address + { + if ($addressId <= 0 || !\Customer::customerHasAddress($customerId, $addressId)) { + return null; + } + + $address = new \Address($addressId, $languageId); + + return \Validate::isLoadedObject($address) ? $address : null; + } + + /** + * @return array + */ + private function getTemplateConfiguration(): array + { + $configuration = []; + $controller = $this->context->controller ?? null; + + if (is_object($controller) && method_exists($controller, 'getTemplateVarConfiguration')) { + $configuration = (array) $controller->getTemplateVarConfiguration(); + } + + $configuration['is_guest_checkout_enabled'] = (bool) \Configuration::get('PS_GUEST_CHECKOUT_ENABLED'); + + return $configuration; + } } diff --git a/src/Checkout/PaymentSelectionKeyBuilder.php b/src/Checkout/PaymentSelectionKeyBuilder.php new file mode 100644 index 0000000..3e1ae5f --- /dev/null +++ b/src/Checkout/PaymentSelectionKeyBuilder.php @@ -0,0 +1,91 @@ + $paymentOptions + * + * @return array + */ + public function enrichPaymentOptions(array $paymentOptions): array + { + foreach ($paymentOptions as $moduleName => $moduleOptions) { + if (!is_array($moduleOptions)) { + continue; + } + + foreach ($moduleOptions as $optionIndex => $option) { + if (!is_array($option)) { + continue; + } + + $paymentOptions[$moduleName][$optionIndex]['selection_key'] = $this->buildSelectionKey($option); + } + } + + return $paymentOptions; + } + + /** + * Build a stable payment selection key from business-level option attributes. + * `option.id` is intentionally excluded because it is only a render identifier. + * + * @param array $option + */ + public function buildSelectionKey(array $option): string + { + $moduleName = (string) ($option['module_name'] ?? ''); + $signature = [ + 'module_name' => $moduleName, + 'action' => (string) ($option['action'] ?? ''), + 'binary' => !empty($option['binary']), + 'call_to_action_text' => $this->normalizeString((string) ($option['call_to_action_text'] ?? '')), + 'inputs' => $this->normalizeInputs($option['inputs'] ?? []), + ]; + + return sprintf( + '%s:%s', + $moduleName !== '' ? $moduleName : 'unknown', + substr(hash('sha256', json_encode($signature)), 0, 24) + ); + } + + private function normalizeString(string $value): string + { + return preg_replace('/\s+/', ' ', trim($value)) ?: ''; + } + + /** + * @param mixed $inputs + * + * @return array> + */ + private function normalizeInputs($inputs): array + { + if (!is_array($inputs)) { + return []; + } + + $normalized = []; + foreach ($inputs as $input) { + if (!is_array($input)) { + continue; + } + + $normalized[] = [ + 'name' => (string) ($input['name'] ?? ''), + 'type' => (string) ($input['type'] ?? ''), + ]; + } + + usort($normalized, static function (array $left, array $right): int { + return [$left['name'], $left['type']] <=> [$right['name'], $right['type']]; + }); + + return $normalized; + } +} diff --git a/src/Form/AddressFieldsFormatTrait.php b/src/Form/AddressFieldsFormatTrait.php index 9dd09b3..6503a5c 100644 --- a/src/Form/AddressFieldsFormatTrait.php +++ b/src/Form/AddressFieldsFormatTrait.php @@ -81,7 +81,7 @@ protected function getAddressFieldsFormat($prefix = '', $aliasRequired = false) ); } elseif ($field === 'phone') { $formField->setType('tel'); - } elseif ($field === 'dni' && null !== $this->country) { + } elseif ($field === 'dni') { if ($this->country->need_identification_number) { $formField->setRequired(true); } diff --git a/src/Form/BackOfficeConfigurationForm.php b/src/Form/BackOfficeConfigurationForm.php index fc0a48c..d623aec 100644 --- a/src/Form/BackOfficeConfigurationForm.php +++ b/src/Form/BackOfficeConfigurationForm.php @@ -50,6 +50,12 @@ public function renderBackOfficeConfiguration(): string $output = ''; if (\Tools::isSubmit(self::FORM_SUBMIT_ACTION)) { + if (!$this->isSingleShopContext()) { + $this->redirectToConfigurationForm(); + + return ''; + } + $isEnabled = (int) \Tools::getValue($this->configurationKey, 0) === 1; $this->persistConfigurationValue((int) $isEnabled); $this->storeSuccessFlash(); diff --git a/src/Form/OnePageCheckoutAddressForm.php b/src/Form/OnePageCheckoutAddressForm.php new file mode 100644 index 0000000..0d501d7 --- /dev/null +++ b/src/Form/OnePageCheckoutAddressForm.php @@ -0,0 +1,156 @@ +language = $language; + $this->formatter = $formatter; + } + + /** + * @param array $requestParameters + */ + public function fillFromRequest(array $requestParameters, string $prefix = ''): self + { + $this->syncFormatterCountryFromRequest($requestParameters, $prefix); + + $params = []; + foreach ($this->getAllowedFieldNames() as $fieldName) { + $params[$fieldName] = $requestParameters[$prefix . $fieldName] ?? $requestParameters[$fieldName] ?? null; + } + + return $this->fillWith($params); + } + + public function fillWith(array $params = []) + { + if (isset($params['id_country']) && (int) $params['id_country'] > 0) { + $country = (int) $params['id_country'] !== (int) $this->formatter->getCountry()->id + ? new \Country((int) $params['id_country'], (int) $this->language->id) + : $this->formatter->getCountry(); + + $this->formatter->setCountry($country); + } + + return parent::fillWith($params); + } + + /** + * @param array $requestParameters + */ + private function syncFormatterCountryFromRequest(array $requestParameters, string $prefix): void + { + $requestedCountryId = (int) ($requestParameters[$prefix . 'id_country'] ?? $requestParameters['id_country'] ?? 0); + if ($requestedCountryId <= 0) { + return; + } + + if ((int) $this->formatter->getCountry()->id !== $requestedCountryId) { + $this->formatter->setCountry(new \Country($requestedCountryId, (int) $this->language->id)); + } + + // Rebuild the allowed fields from the requested country before extracting request values. + $this->formFields = []; + } + + public function validate() + { + $country = $this->formatter->getCountry(); + $isValid = $this->validateAddressPostcode( + $this->getField('postcode'), + $country + ); + + if ($isValid) { + $isValid = $this->validateAddressFormHook(); + } + + return $isValid && parent::validate(); + } + + /** + * @return \Address|false + */ + public function submit() + { + if (!$this->validate()) { + return false; + } + + return $this->buildAddress(); + } + + public function buildAddress(?\Address $address = null): \Address + { + $address = $address ?? new \Address(null, (int) $this->language->id); + + foreach ($this->formFields as $field) { + $fieldName = $field->getName(); + if (property_exists($address, $fieldName)) { + $address->{$fieldName} = $field->getValue(); + } + } + + if (!$this->getField('id_state')) { + $address->id_state = 0; + } + + return $address; + } + + /** + * @return list + */ + public function getAllowedFieldNames(): array + { + if (!$this->formFields) { + $this->formFields = $this->formatter->getFormat(); + } + + return array_keys($this->formFields); + } + + /** + * @return array + */ + public function getTemplateVariables() + { + if (!$this->formFields) { + $this->formFields = $this->formatter->getFormat(); + } + + return [ + 'errors' => $this->getErrors(), + 'formFields' => array_map( + static function (\FormField $field): array { + return $field->toArray(); + }, + $this->formFields + ), + ]; + } +} diff --git a/src/Form/OnePageCheckoutAddressFormatter.php b/src/Form/OnePageCheckoutAddressFormatter.php new file mode 100644 index 0000000..8d5fbdf --- /dev/null +++ b/src/Form/OnePageCheckoutAddressFormatter.php @@ -0,0 +1,81 @@ + + */ + protected array $availableCountries; + + /** + * @var array + */ + protected array $definition; + + /** + * @param array $availableCountries + */ + public function __construct( + \Country $country, + TranslatorInterface $translator, + array $availableCountries, + ) { + $this->country = $country; + $this->translator = $translator; + $this->availableCountries = $availableCountries; + $this->definition = \Address::$definition['fields']; + } + + public function setCountry(\Country $country): self + { + $this->country = $country; + + return $this; + } + + public function getCountry(): \Country + { + return $this->country; + } + + /** + * @return array + */ + public function getFormat(): array + { + $format = $this->addConstraints( + $this->addMaxLength( + $this->getAddressFieldsFormat('', true) + ) + ); + + $additionalAddressFormFields = \Hook::exec('additionalCustomerAddressFields', ['fields' => &$format], null, true); + if (is_array($additionalAddressFormFields)) { + foreach ($additionalAddressFormFields as $moduleName => $additionalFormFields) { + if (!is_array($additionalFormFields)) { + continue; + } + + foreach ($additionalFormFields as $formField) { + if (!$formField instanceof \FormField) { + continue; + } + + $formField->moduleName = $moduleName; + $format[$moduleName . '_' . $formField->getName()] = $formField; + } + } + } + + return $format; + } +} diff --git a/src/Form/OnePageCheckoutForm.php b/src/Form/OnePageCheckoutForm.php index de636c7..680f09a 100644 --- a/src/Form/OnePageCheckoutForm.php +++ b/src/Form/OnePageCheckoutForm.php @@ -29,7 +29,6 @@ use Address; use Cart; -use Context; use Country; use Customer; use PrestaShop\PrestaShop\Core\Util\InternationalizedDomainNameConverter; @@ -40,7 +39,6 @@ class OnePageCheckoutForm extends \AbstractForm { use AddressFormValidationTrait; - protected $template = 'checkout/_partials/one-page-checkout-form.tpl'; private const GUEST_PLACEHOLDER_FIRSTNAME = 'Guest'; private const GUEST_PLACEHOLDER_LASTNAME = 'Guest'; @@ -115,7 +113,12 @@ public function fillWith(array $params = []) $this->formatter->setInvoiceCountry($invoiceCountry); } - return parent::fillWith($params); + parent::fillWith($params); + + $this->stripAdditionalCustomerFieldsForConnectedCustomer(); + $this->hydrateConnectedCustomerEmail(); + + return $this; } public function fillFromCustomer(\Customer $customer) @@ -210,6 +213,10 @@ public function validate() $is_valid = $this->validateAddressFormHook(); } + if ($is_valid) { + $this->validateCustomerFieldsByModules(); + } + return $is_valid && parent::validate(); } @@ -224,13 +231,11 @@ public function submit() return false; } - $this->syncContextCustomerFromCart(); - - // Get customer from form data - $customer = $this->getCustomer(); + $fieldsByGroup = $this->mapFieldsByGroup(); + $customer = $this->getCheckoutCustomerForSubmit($fieldsByGroup); - // If customer is not logged in, create/update guest customer - if (!$this->context->customer->isLogged() || $this->context->customer->isGuest()) { + if ((int) $customer->id <= 0 || $customer->isGuest()) { + $customer = $this->buildCustomerFromFields($this->getCustomerFields($fieldsByGroup)); $customer->is_guest = true; if (!$this->customerPersister->save($customer, '', '', false)) { @@ -247,11 +252,15 @@ public function submit() $this->context->updateCustomer($customer); } + $this->refreshAddressPersister(); + $token = \Tools::getToken(true, $this->context); $useSameAddress = $this->getField('use_same_address') && $this->getField('use_same_address')->getValue(); - // Create/update delivery address - $deliveryAddress = $this->buildAddressFromFields('', \Tools::getValue('id_address')); + $deliveryAddress = $this->buildAddressFromGroup( + $fieldsByGroup['deliveryFields'], + (int) \Tools::getValue('id_address') ?: null + ); $deliveryAddress->id_customer = $customer->id; if (empty($deliveryAddress->alias)) { $deliveryAddress->alias = $this->translator->trans('My Address', [], 'Shop.Theme.Checkout'); @@ -267,7 +276,11 @@ public function submit() // Create/update invoice address if different if (!$useSameAddress) { $idAddressInvoice = (int) \Tools::getValue('id_address_invoice'); - $invoiceAddress = $this->buildAddressFromFields('invoice_', $idAddressInvoice ?: null); + $invoiceAddress = $this->buildAddressFromGroup( + $fieldsByGroup['invoiceFields'], + $idAddressInvoice ?: null, + 'invoice_' + ); $invoiceAddress->id_customer = $customer->id; $invoiceAddress->alias = $invoiceAddress->alias ?: $this->translator->trans( 'Invoice address', @@ -288,38 +301,85 @@ public function submit() } /** - * In OPC, guest auto-save can attach a guest to the cart just before final submit. - * If `context->customer` is still empty, final submit may create a duplicate guest. - * Reload persisted cart owner and sync context only when that owner is a guest. + * Final submit must follow the cart owner stored in persistence, not the current session flags. + * When a registered customer already owns the cart, we keep that customer as checkout owner. + * + * @param array> $fieldsByGroup */ - private function syncContextCustomerFromCart(): void + private function getCheckoutCustomerForSubmit(array $fieldsByGroup): \Customer { - if ((int) $this->context->customer->id > 0 || !\Validate::isLoadedObject($this->context->cart)) { + $persistedCartOwner = $this->getPersistedCartOwner(); + if ($persistedCartOwner && !$persistedCartOwner->isGuest()) { + $this->context->updateCustomer($persistedCartOwner); + + return $persistedCartOwner; + } + + $this->syncContextCustomerFromCart(); + + return $this->buildCustomerFromFields($this->getCustomerFields($fieldsByGroup)); + } + + private function refreshAddressPersister(): void + { + if (get_class($this->addressPersister) !== \CustomerAddressPersister::class) { return; } - $cartId = (int) $this->context->cart->id; - if ($cartId <= 0) { + $this->addressPersister = new \CustomerAddressPersister( + $this->context->customer, + $this->context->cart, + \Tools::getToken(true, $this->context) + ); + } + + private function syncContextCustomerFromCart(): void + { + if (!isset($this->context->cart) || !\Validate::isUnsignedId((int) $this->context->cart->id)) { return; } - // Read cart owner from DB to align final submit with the real cart owner. - $freshCart = new \Cart($cartId); + $freshCart = new \Cart((int) $this->context->cart->id); if (!\Validate::isLoadedObject($freshCart)) { return; } - $cartCustomerId = (int) $freshCart->id_customer; - if ($cartCustomerId <= 0) { + $cartCustomer = $this->getPersistedCartOwner(); + if (!$cartCustomer) { return; } - $cartCustomer = new \Customer($cartCustomerId); - if (!\Validate::isLoadedObject($cartCustomer) || !$cartCustomer->isGuest()) { + if ( + !\Validate::isLoadedObject($this->context->cart) + || (int) $this->context->cart->id_customer !== (int) $freshCart->id_customer + || (int) $this->context->cart->id_currency <= 0 + || (int) $this->context->cart->id_lang <= 0 + || (int) $this->context->cart->id_shop <= 0 + ) { + $this->context->cart = $freshCart; + } + + if ((int) $this->context->customer->id === (int) $cartCustomer->id) { return; } - $this->context->customer = $cartCustomer; + $this->context->updateCustomer($cartCustomer); + } + + private function getPersistedCartOwner(): ?\Customer + { + if (!isset($this->context->cart) || !\Validate::isUnsignedId((int) $this->context->cart->id)) { + return null; + } + + $freshCart = new \Cart((int) $this->context->cart->id); + if (!\Validate::isLoadedObject($freshCart) || (int) $freshCart->id_customer <= 0) { + return null; + } + + $cartCustomer = new \Customer((int) $freshCart->id_customer); + + return \Validate::isLoadedObject($cartCustomer) ? $cartCustomer : null; } /** @@ -330,12 +390,13 @@ private function syncContextCustomerFromCart(): void public function submitGuestInit(array $params = []): bool { $this->fillWith($params); + $fieldsByGroup = $this->mapFieldsByGroup(); - if (!$this->validateGuestInit()) { + if (!$this->validateGuestInit($fieldsByGroup)) { return false; } - $customer = $this->getCustomer(); + $customer = $this->buildCustomerFromFields($this->getCustomerFields($fieldsByGroup)); $customer->is_guest = true; $customer->firstname = $customer->firstname ?: self::GUEST_PLACEHOLDER_FIRSTNAME; $customer->lastname = $customer->lastname ?: self::GUEST_PLACEHOLDER_LASTNAME; @@ -348,48 +409,65 @@ public function submitGuestInit(array $params = []): bool } /** - * Build Address object from form fields with given prefix - * - * @param string $prefix Field prefix ('' for delivery, 'invoice_' for invoice) - * @param int|null $idAddress Existing address ID or null for new - * - * @return \Address + * @param array> $fieldsByGroup */ - private function buildAddressFromFields($prefix, $idAddress) + private function validateGuestInit(array $fieldsByGroup): bool { - $address = new \Address($idAddress ? (int) $idAddress : null, $this->language->id); - - foreach ($this->formFields as $formField) { - $fieldName = $formField->getName(); - if (strpos($fieldName, $prefix) !== 0) { - continue; - } - $baseName = $prefix ? substr($fieldName, strlen($prefix)) : $fieldName; - if (property_exists($address, $baseName)) { - $address->{$baseName} = $formField->getValue(); - } + if (!$this->isGuestInitEmailValid()) { + return false; } - if (!isset($this->formFields[$prefix . 'id_state'])) { - $address->id_state = 0; + if (!$this->formFields) { + $this->formFields = $this->formatter->getFormat(); + $this->stripAdditionalCustomerFieldsForConnectedCustomer(); } - return $address; + $this->validateRequiredGuestConsentFields($fieldsByGroup); + + return !$this->hasErrors(); } - private function validateGuestInit(): bool + protected function validateCustomerFieldsByModules(): void { - if (!$this->isGuestInitEmailValid()) { - return false; - } + $formFieldsAssociated = []; + $formFieldKeysByModule = []; + $additionalCustomerFields = $this->mapFieldsByGroup()['additionalCustomerFields']; - if (!$this->formFields) { - $this->formFields = $this->formatter->getFormat(); + foreach ($additionalCustomerFields as $key => $formField) { + if (empty($formField->moduleName)) { + continue; + } + + $formFieldsAssociated[$formField->moduleName][] = $formField; + $formFieldKeysByModule[$formField->moduleName][$formField->getName()] = $key; } - $this->validateRequiredGuestConsentFields(); + foreach ($formFieldsAssociated as $moduleName => $formFields) { + $moduleId = \Module::getModuleIdByName($moduleName); + if (!$moduleId) { + continue; + } - return !$this->hasErrors(); + $validatedCustomerFormFields = \Hook::exec( + 'validateCustomerFormFields', + ['fields' => $formFields], + $moduleId, + true + ); + + if (!is_array($validatedCustomerFormFields)) { + continue; + } + + foreach ($validatedCustomerFormFields as $name => $field) { + if (!$field instanceof \FormFieldCore) { + continue; + } + + $targetKey = $formFieldKeysByModule[$moduleName][$name] ?? $name; + $this->formFields[$targetKey] = $field; + } + } } /** @@ -447,10 +525,14 @@ private function isGuestInitEmailValid(): bool * * @return void */ - private function validateRequiredGuestConsentFields(): void + /** + * @param array> $fieldsByGroup + */ + private function validateRequiredGuestConsentFields(array $fieldsByGroup): void { - foreach ($this->formFields as $field) { - if ($field->getType() !== 'checkbox' || !$field->isRequired()) { + $guestFields = $fieldsByGroup['contactFields'] + $fieldsByGroup['additionalCustomerFields']; + foreach ($guestFields as $field) { + if (!in_array($field->getType(), ['checkbox', 'radio-buttons'], true) || !$field->isRequired()) { continue; } @@ -467,16 +549,9 @@ private function validateRequiredGuestConsentFields(): void */ public function getCustomer() { - $customer = new \Customer($this->context->customer->id); - - foreach ($this->formFields as $field) { - $customerField = $field->getName(); - if (property_exists($customer, $customerField)) { - $customer->$customerField = $field->getValue(); - } - } - - return $customer; + return $this->buildCustomerFromFields( + $this->getCustomerFields($this->mapFieldsByGroup()) + ); } /** @@ -486,7 +561,9 @@ public function getCustomer() */ public function getAddress() { - return $this->buildAddressFromFields('', \Tools::getValue('id_address')); + $fieldsByGroup = $this->mapFieldsByGroup(); + + return $this->buildAddressFromGroup($fieldsByGroup['deliveryFields'], \Tools::getValue('id_address')); } /** @@ -496,28 +573,235 @@ public function getAddress() */ public function getInvoiceAddress() { - return $this->buildAddressFromFields('invoice_', null); + $fieldsByGroup = $this->mapFieldsByGroup(); + + return $this->buildAddressFromGroup($fieldsByGroup['invoiceFields'], null, 'invoice_'); } public function getTemplateVariables() { if (!$this->formFields) { - // This is usually done by fillWith but the form may be - // rendered before fillWith is called. $this->formFields = $this->formatter->getFormat(); + $this->stripAdditionalCustomerFieldsForConnectedCustomer(); } - $formFields = array_map( - function (\FormField $item) { - return $item->toArray(); - }, - $this->formFields - ); + $fieldsByGroup = $this->mapFieldsByGroup(); + $formFields = $this->convertFieldsToTemplateArray($this->formFields); return [ 'action' => $this->action, 'errors' => $this->getErrors(), 'formFields' => $formFields, + 'contactFields' => $this->convertFieldsToTemplateArray($fieldsByGroup['contactFields']), + 'additionalCustomerFields' => $this->convertFieldsToTemplateArray($fieldsByGroup['additionalCustomerFields']), + 'useSameAddressField' => $this->convertFieldToTemplateArray($fieldsByGroup['useSameAddressField']['use_same_address'] ?? null), + 'deliveryFields' => $this->convertFieldsToTemplateArray($fieldsByGroup['deliveryFields']), + 'invoiceFields' => $this->convertFieldsToTemplateArray($fieldsByGroup['invoiceFields']), + 'invoiceMetaFields' => $this->convertFieldsToTemplateArray($fieldsByGroup['invoiceMetaFields']), + 'token' => \Tools::getToken(true, $this->context), ]; } + + /** + * @return array> + */ + private function mapFieldsByGroup(): array + { + $fieldsByGroup = [ + 'contactFields' => [], + 'additionalCustomerFields' => [], + 'useSameAddressField' => [], + 'deliveryFields' => [], + 'invoiceFields' => [], + 'invoiceMetaFields' => [], + ]; + + foreach ($this->formFields as $key => $field) { + if ($this->isCustomerField($key, $field)) { + $fieldsByGroup['additionalCustomerFields'][$key] = $field; + continue; + } + + if ($this->isContactField($key)) { + $fieldsByGroup['contactFields'][$key] = $field; + continue; + } + + if ($this->isUseSameAddressField($key)) { + $fieldsByGroup['useSameAddressField'][$key] = $field; + continue; + } + + if ($this->isInvoiceMetaField($key)) { + $fieldsByGroup['invoiceMetaFields'][$key] = $field; + continue; + } + + if ($this->isInvoiceField($key)) { + $fieldsByGroup['invoiceFields'][$key] = $field; + continue; + } + + $fieldsByGroup['deliveryFields'][$key] = $field; + } + + return $fieldsByGroup; + } + + private function stripAdditionalCustomerFieldsForConnectedCustomer(): void + { + if (!$this->isRegisteredCustomer()) { + return; + } + + foreach ($this->formFields as $key => $field) { + if ($this->isCustomerField($key, $field)) { + unset($this->formFields[$key]); + } + } + } + + private function isRegisteredCustomer(): bool + { + $customer = $this->context->customer; + + return $customer instanceof \Customer + && !empty($customer->id) + && !$customer->isGuest(); + } + + private function hydrateConnectedCustomerEmail(): void + { + if (!$this->isRegisteredCustomer()) { + return; + } + + $emailField = $this->getField('email'); + if (!$emailField || $emailField->getValue()) { + return; + } + + $email = (string) $this->context->customer->email; + if (!\Validate::isEmail($email)) { + return; + } + + $emailField->setValue($email); + } + + private function isCustomerField(string $key, ?\FormField $field = null): bool + { + if ($this->formatter->getFieldGroup($key) === 'customer') { + return true; + } + + if (strpos($key, 'customer_') === 0) { + return true; + } + + if ($field && strpos((string) $field->getName(), '_customer_') !== false) { + return true; + } + + return false; + } + + private function isContactField(string $key): bool + { + return in_array($key, ['email', 'optin'], true); + } + + private function isUseSameAddressField(string $key): bool + { + return $key === 'use_same_address'; + } + + private function isInvoiceMetaField(string $key): bool + { + return $key === 'id_address_invoice'; + } + + private function isInvoiceField(string $key): bool + { + return strpos($key, 'invoice_') === 0; + } + + /** + * @param array> $fieldsByGroup + * + * @return array + */ + private function getCustomerFields(array $fieldsByGroup): array + { + return $fieldsByGroup['contactFields'] + + $fieldsByGroup['additionalCustomerFields'] + + $fieldsByGroup['deliveryFields']; + } + + /** + * @param array $fields + */ + private function buildCustomerFromFields(array $fields): \Customer + { + $customerId = (int) $this->context->customer->id; + $customer = $customerId > 0 ? new \Customer($customerId) : new \Customer(); + + foreach ($fields as $field) { + $customerField = $field->getName(); + if (property_exists($customer, $customerField)) { + $customer->$customerField = $field->getValue(); + } + } + + return $customer; + } + + /** + * @param array $fields + * @param int|null $idAddress + */ + private function buildAddressFromGroup(array $fields, $idAddress, string $prefix = ''): \Address + { + $address = new \Address($idAddress ? (int) $idAddress : null, $this->language->id); + + foreach ($fields as $formField) { + $fieldName = $formField->getName(); + $baseName = $prefix && strpos($fieldName, $prefix) === 0 + ? substr($fieldName, strlen($prefix)) + : $fieldName; + + if (property_exists($address, $baseName)) { + $address->{$baseName} = $formField->getValue(); + } + } + + if (!isset($fields[$prefix . 'id_state'])) { + $address->id_state = 0; + } + + return $address; + } + + /** + * @param array $fields + * + * @return array> + */ + private function convertFieldsToTemplateArray(array $fields): array + { + return array_map( + function (\FormField $field) { + return $field->toArray(); + }, + $fields + ); + } + + /** + * @return array|null + */ + private function convertFieldToTemplateArray(?\FormField $field): ?array + { + return $field ? $field->toArray() : null; + } } diff --git a/src/Form/OnePageCheckoutFormFactory.php b/src/Form/OnePageCheckoutFormFactory.php index cdcffba..ecf8a04 100644 --- a/src/Form/OnePageCheckoutFormFactory.php +++ b/src/Form/OnePageCheckoutFormFactory.php @@ -118,4 +118,14 @@ public function createAddressPersister(): \CustomerAddressPersister \Tools::getToken(true, $this->context) ); } + + public function createCustomerAddressFormatter(): OnePageCheckoutFormatter + { + return $this->createFormatter($this->getAvailableCountries()); + } + + public function createCustomerAddressForm(): OnePageCheckoutForm + { + return $this->create(); + } } diff --git a/src/Form/OnePageCheckoutFormatter.php b/src/Form/OnePageCheckoutFormatter.php index 0cb5255..939772e 100644 --- a/src/Form/OnePageCheckoutFormatter.php +++ b/src/Form/OnePageCheckoutFormatter.php @@ -35,10 +35,17 @@ class OnePageCheckoutFormatter implements \FormFormatterInterface { use AddressFieldsFormatTrait; + public const FIELD_GROUP_CUSTOMER = 'customer'; + public const FIELD_GROUP_ADDRESS = 'address'; + protected $country; protected $translator; protected $availableCountries; protected $definition; + /** + * @var array + */ + private array $fieldGroups = []; /** * Separate country for the billing (invoice) address section. @@ -88,6 +95,7 @@ public function setInvoiceCountry(\Country $country) public function getFormat() { $format = []; + $this->fieldGroups = []; // Identity section: email only $format['email'] = (new \FormField()) @@ -130,8 +138,10 @@ public function getFormat() continue; } + $fieldKey = $moduleName . '_' . $formField->getName(); $formField->moduleName = $moduleName; - $format[$moduleName . '_' . $formField->getName()] = $formField; + $this->fieldGroups[$fieldKey] = self::FIELD_GROUP_CUSTOMER; + $format[$fieldKey] = $formField; } } } @@ -149,6 +159,11 @@ public function getFormat() ) ->setValue(true); + // Hidden field to preserve delivery address ID when editing + $format['id_address'] = (new \FormField()) + ->setName('id_address') + ->setType('hidden'); + // Hidden field to preserve invoice address ID when editing $format['id_address_invoice'] = (new \FormField()) ->setName('id_address_invoice') @@ -184,8 +199,10 @@ public function getFormat() continue; } + $fieldKey = $moduleName . '_' . $formField->getName(); $formField->moduleName = $moduleName; - $format[$moduleName . '_' . $formField->getName()] = $formField; + $this->fieldGroups[$fieldKey] = self::FIELD_GROUP_ADDRESS; + $format[$fieldKey] = $formField; } } } @@ -204,4 +221,9 @@ protected function getDefinitionKey($name) { return strpos($name, 'invoice_') === 0 ? substr($name, 8) : $name; } + + public function getFieldGroup(string $key): ?string + { + return $this->fieldGroups[$key] ?? null; + } } diff --git a/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php index 8a3d47a..9b3f269 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php +++ b/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php @@ -32,6 +32,7 @@ public function testItLoadsAddressThenAppliesWhitelistedPayload(): void { $customer = $this->createCustomer($this->uniqueEmail('opc-address')); $address = $this->createAddressForCustomer((int) $customer->id); + self::getContext()->customer = $customer; $formSpy = new IntegrationOpcAddressFormSpy(); $formSpy->templateVars = ['address_form' => '
ok
']; @@ -51,6 +52,7 @@ public function testItLoadsAddressThenAppliesWhitelistedPayload(): void 'id_country' => '8', 'invoice_id_country' => '8', 'use_same_address' => '1', + 'id_address' => (string) $address->id, ], ], $formSpy->fillWithPayloads); self::assertSame(['address_form' => '
ok
'], $response); @@ -89,6 +91,53 @@ public function testItReturnsTemplateVariablesWithoutFormFillWhenPayloadIsIrrele self::assertSame(['address_form' => '
initial
'], $response); } + public function testItPreservesPositiveInvoiceAddressIdInRefreshPayload(): void + { + $customer = $this->createCustomer($this->uniqueEmail('opc-invoice')); + $invoiceAddress = $this->createAddressForCustomer((int) $customer->id); + self::getContext()->customer = $customer; + + $formSpy = new IntegrationOpcAddressFormSpy(); + $formSpy->templateVars = ['address_form' => '
invoice
']; + $handler = new OnePageCheckoutAddressFormHandler($formSpy); + + $response = $handler->getTemplateVariables([ + 'id_address_invoice' => (string) $invoiceAddress->id, + 'invoice_id_country' => '8', + ]); + + self::assertSame([ + [ + 'invoice_id_country' => '8', + 'id_address_invoice' => (string) $invoiceAddress->id, + ], + ], $formSpy->fillWithPayloads); + self::assertSame(['address_form' => '
invoice
'], $response); + } + + public function testItRejectsForeignAddressOwnershipDuringRefresh(): void + { + $owner = $this->createCustomer($this->uniqueEmail('opc-owner')); + $foreignCustomer = $this->createCustomer($this->uniqueEmail('opc-foreign')); + $foreignAddress = $this->createAddressForCustomer((int) $foreignCustomer->id); + self::getContext()->customer = $owner; + + $formSpy = new IntegrationOpcAddressFormSpy(); + $formSpy->templateVars = ['address_form' => '
safe
']; + $handler = new OnePageCheckoutAddressFormHandler($formSpy); + + $response = $handler->getTemplateVariables([ + 'id_address' => (string) $foreignAddress->id, + 'id_country' => '8', + ]); + + self::assertSame([], $formSpy->loadedAddressIds); + self::assertSame([ + ['id_country' => '8'], + ], $formSpy->fillWithPayloads); + self::assertSame(['address_form' => '
safe
'], $response); + } + private static function resetTables(): void { DatabaseDump::restoreTables([ diff --git a/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php new file mode 100644 index 0000000..88ffefc --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php @@ -0,0 +1,76 @@ +createCustomer(); + $firstAddress = $this->createAddressForCustomer((int) $customer->id, 'Home'); + $this->createAddressForCustomer((int) $customer->id, 'Office'); + + $context = self::getContext(); + $context->customer = $customer; + $context->language = new \Language((int) \Configuration::get('PS_LANG_DEFAULT')); + + $handler = new OnePageCheckoutAddressesListHandler($context, new CheckoutCustomerContextResolver($context)); + $response = $handler->handle(['id_address' => (string) $firstAddress->id]); + + self::assertTrue($response['success']); + self::assertCount(2, $response['addresses']); + self::assertSame((int) $firstAddress->id, (int) $response['address']['id_address']); + } + + private function createCustomer(): \Customer + { + $customer = new \Customer(); + $customer->firstname = 'Integration'; + $customer->lastname = 'Customer'; + $customer->email = sprintf('opc-addresses-%s@example.com', uniqid('', true)); + $customer->is_guest = true; + $customer->passwd = \Tools::hash('integration-password'); + self::assertTrue($customer->save()); + + return $customer; + } + + private function createAddressForCustomer(int $customerId, string $alias): \Address + { + $address = new \Address(); + $address->id_customer = $customerId; + $address->id_country = (int) \Configuration::get('PS_COUNTRY_DEFAULT'); + $address->firstname = 'Integration'; + $address->lastname = 'Customer'; + $address->address1 = '1 rue Integration'; + $address->alias = $alias . '_' . uniqid('', true); + $address->city = 'Paris'; + self::assertTrue($address->save()); + + return $address; + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcDeleteAddressHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcDeleteAddressHandlerIntegrationTest.php new file mode 100644 index 0000000..2779d22 --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcDeleteAddressHandlerIntegrationTest.php @@ -0,0 +1,114 @@ +createCustomer(); + $context = self::getContext(); + $context->customer = $customer; + $context->language = new \Language((int) \Configuration::get('PS_LANG_DEFAULT')); + + $activeAddress = $this->createAddress($customer, 'Active delivery'); + $fallbackAddress = $this->createAddress($customer, 'Fallback delivery'); + + $cart = new \Cart(); + $cart->id_currency = (int) \Configuration::get('PS_CURRENCY_DEFAULT'); + $cart->id_lang = (int) \Configuration::get('PS_LANG_DEFAULT'); + $cart->id_shop_group = 1; + $cart->id_shop = 1; + $cart->id_customer = (int) $customer->id; + $cart->id_address_delivery = (int) $activeAddress->id; + $cart->id_address_invoice = (int) $activeAddress->id; + self::assertTrue($cart->add()); + $context->cart = $cart; + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + $handler = new OnePageCheckoutDeleteAddressHandler($context, $translator, new CheckoutCustomerContextResolver($context)); + $response = $handler->handle([ + 'id_address' => (string) $activeAddress->id, + ]); + + self::assertTrue($response['success'], var_export($response, true)); + + $freshCart = new \Cart((int) $cart->id); + self::assertSame((int) $fallbackAddress->id, (int) $freshCart->id_address_delivery); + self::assertSame((int) $fallbackAddress->id, (int) $freshCart->id_address_invoice); + + $checkoutSession = $this->createCheckoutSession($context, $translator); + self::assertSame((int) $fallbackAddress->id, (int) $checkoutSession->getIdAddressDelivery()); + self::assertSame((int) $fallbackAddress->id, (int) $checkoutSession->getIdAddressInvoice()); + } + + private function createCustomer(): \Customer + { + $customer = new \Customer(); + $customer->firstname = 'Integration'; + $customer->lastname = 'Customer'; + $customer->email = sprintf('opc-delete-%s@example.com', uniqid('', true)); + $customer->is_guest = false; + $customer->passwd = \Tools::hash('integration-password'); + self::assertTrue($customer->save()); + + return $customer; + } + + private function createAddress(\Customer $customer, string $alias): \Address + { + $address = new \Address(); + $address->id_customer = (int) $customer->id; + $address->alias = $alias; + $address->firstname = $customer->firstname; + $address->lastname = $customer->lastname; + $address->address1 = '1 rue Integration'; + $address->city = 'Paris'; + $address->postcode = '75001'; + $address->id_country = (int) \Configuration::get('PS_COUNTRY_DEFAULT'); + self::assertTrue($address->add()); + + return $address; + } + + private function createCheckoutSession(\Context $context, TranslatorInterface $translator): \CheckoutSession + { + return new \CheckoutSession( + $context, + new \DeliveryOptionsFinder( + $context, + $translator, + new \PrestaShop\PrestaShop\Adapter\Presenter\Object\ObjectPresenter(), + new \PrestaShop\PrestaShop\Adapter\Product\PriceFormatter() + ) + ); + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerGuestFlowIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerGuestFlowIntegrationTest.php index 66ae405..ad47b21 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerGuestFlowIntegrationTest.php +++ b/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerGuestFlowIntegrationTest.php @@ -43,14 +43,14 @@ public function testItUsesFreshCartOwnerWhenContextCartSnapshotIsOutdated(): voi $this->scenarioItUsesFreshCartOwnerWhenContextCartSnapshotIsOutdated(); } - public function testItUsesContextCartOwnerWhenFreshCartRowIsMissing(): void + public function testItReturnsErrorWhenFreshCartRowIsMissing(): void { - $this->scenarioItUsesContextCartOwnerWhenFreshCartRowIsMissing(); + $this->scenarioItReturnsErrorWhenFreshCartRowIsMissing(); } - public function testItReturnsNoopWhenEmailBelongsToExistingAccountAndNoGuestIsLinked(): void + public function testItFallsBackToGuestCreationWhenEmailBelongsToExistingAccountAndNoGuestIsLinked(): void { - $this->scenarioItReturnsNoopWhenEmailBelongsToExistingAccountAndNoGuestIsLinked(); + $this->scenarioItFallsBackToGuestCreationWhenEmailBelongsToExistingAccountAndNoGuestIsLinked(); } public function testItCreatesGuestAndClaimsUnassignedCart(): void @@ -68,6 +68,11 @@ public function testItUpdatesExistingGuestEmailWithoutCreatingNewCustomer(): voi $this->scenarioItUpdatesExistingGuestEmailWithoutCreatingNewCustomer(); } + public function testItCreatesNewGuestForNewAnonymousCartEvenWhenGuestEmailAlreadyExists(): void + { + $this->scenarioItCreatesNewGuestForNewAnonymousCartEvenWhenGuestEmailAlreadyExists(); + } + public function testItUpdatesCartOwnerGuestAndRealignsContextWhenUpdatingEmailFromMismatchedContextCustomer(): void { $this->scenarioItUpdatesCartOwnerGuestAndRealignsContextWhenUpdatingEmailFromMismatchedContextCustomer(); diff --git a/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerIntegrationTestCase.php b/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerIntegrationTestCase.php index bffe87f..3c9c04c 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerIntegrationTestCase.php +++ b/tests/php/Integration/Checkout/Ajax/OpcGuestInitHandlerIntegrationTestCase.php @@ -307,7 +307,7 @@ protected function scenarioItUsesFreshCartOwnerWhenContextCartSnapshotIsOutdated self::assertSame((int) $winner->id, $response['id_customer']); } - protected function scenarioItUsesContextCartOwnerWhenFreshCartRowIsMissing(): void + protected function scenarioItReturnsErrorWhenFreshCartRowIsMissing(): void { $winner = $this->createCustomer($this->uniqueEmail('missing-row-owner'), false); $persistedCart = $this->prepareEligibleCartContext((int) $winner->id); @@ -330,12 +330,14 @@ protected function scenarioItUsesContextCartOwnerWhenFreshCartRowIsMissing(): vo 'token' => self::EXPECTED_TOKEN, ]); - self::assertTrue($response['success']); + self::assertFalse($response['success']); + self::assertArrayHasKey('', $response['errors']); + self::assertNotEmpty($response['errors']['']); self::assertFalse($response['customer_created']); - self::assertSame((int) $winner->id, $response['id_customer']); + self::assertSame(0, $response['id_customer']); } - protected function scenarioItReturnsNoopWhenEmailBelongsToExistingAccountAndNoGuestIsLinked(): void + protected function scenarioItFallsBackToGuestCreationWhenEmailBelongsToExistingAccountAndNoGuestIsLinked(): void { $registered = $this->createCustomer($this->uniqueEmail('existing-no-guest-linked'), false); $this->prepareEligibleCartContext(0); @@ -343,8 +345,16 @@ protected function scenarioItReturnsNoopWhenEmailBelongsToExistingAccountAndNoGu $opcForm = $this->buildOpcFormMock(); $opcForm - ->expects($this->never()) + ->expects($this->once()) ->method('submitGuestInit') + ->willReturn(false) + ; + $opcForm + ->expects($this->once()) + ->method('getErrors') + ->willReturn([ + 'email' => ['Unable to save guest customer'], + ]) ; $handler = $this->buildHandler($opcForm); @@ -354,9 +364,8 @@ protected function scenarioItReturnsNoopWhenEmailBelongsToExistingAccountAndNoGu 'token' => self::EXPECTED_TOKEN, ]); - self::assertTrue($response['success']); - self::assertFalse($response['customer_created']); - self::assertSame(0, $response['id_customer']); + self::assertFalse($response['success']); + self::assertSame(['Unable to save guest customer'], $response['errors']['email']); } protected function scenarioItCreatesGuestAndClaimsUnassignedCart(): void @@ -519,6 +528,42 @@ protected function scenarioItUpdatesExistingGuestEmailWithoutCreatingNewCustomer self::assertSame($updatedEmail, (string) $freshGuest->email); } + protected function scenarioItCreatesNewGuestForNewAnonymousCartEvenWhenGuestEmailAlreadyExists(): void + { + $guest = $this->createCustomer($this->uniqueEmail('existing-guest-anonymous-cart'), true); + $persistedCart = $this->prepareEligibleCartContext(0); + self::getContext()->customer = new \Customer(); + + $createdGuest = $this->createCustomer((string) $guest->email, true); + + $opcForm = $this->buildOpcFormMock(); + $opcForm + ->expects($this->once()) + ->method('submitGuestInit') + ->willReturnCallback(function () use ($createdGuest): bool { + self::getContext()->customer = $createdGuest; + + return true; + }) + ; + + $handler = $this->buildHandler($opcForm); + + $response = $handler->handle([ + 'email' => (string) $guest->email, + 'token' => self::EXPECTED_TOKEN, + ]); + + self::assertTrue($response['success']); + self::assertTrue($response['customer_created']); + self::assertSame((int) $createdGuest->id, $response['id_customer']); + self::assertSame((int) $createdGuest->id, $this->getPersistedCartCustomerId((int) $persistedCart->id)); + self::assertNotSame((int) $guest->id, (int) $createdGuest->id); + self::assertSame((int) $createdGuest->id, (int) self::getContext()->customer->id); + self::assertSame((int) $createdGuest->id, (int) self::getContext()->cookie->id_customer); + self::assertSame((string) $guest->email, (string) self::getContext()->cookie->email); + } + protected function scenarioItUpdatesCartOwnerGuestAndRealignsContextWhenUpdatingEmailFromMismatchedContextCustomer(): void { $guestOwner = $this->createCustomer($this->uniqueEmail('guest-owner-mismatch'), true); @@ -1227,8 +1272,16 @@ protected function scenarioItReturnsErrorWhenCartOwnerReferenceIsStaleDuringGues $opcForm = $this->buildOpcFormMock(); $opcForm - ->expects($this->never()) + ->expects($this->once()) ->method('submitGuestInit') + ->willReturn(false) + ; + $opcForm + ->expects($this->once()) + ->method('getErrors') + ->willReturn([ + 'email' => ['Unable to save guest customer'], + ]) ; $handler = $this->buildHandler($opcForm); @@ -1239,8 +1292,7 @@ protected function scenarioItReturnsErrorWhenCartOwnerReferenceIsStaleDuringGues ]); self::assertFalse($response['success']); - self::assertArrayHasKey('', $response['errors']); - self::assertNotEmpty($response['errors']['']); + self::assertSame(['Unable to save guest customer'], $response['errors']['email']); self::assertSame(self::EXPECTED_TOKEN, $response['token']); self::assertSame(self::EXPECTED_TOKEN, $response['static_token']); } diff --git a/tests/php/Integration/Checkout/Ajax/OpcPaymentMethodsHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcPaymentMethodsHandlerIntegrationTest.php new file mode 100644 index 0000000..1c14234 --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcPaymentMethodsHandlerIntegrationTest.php @@ -0,0 +1,304 @@ + 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ]; + $selectionKey = (new PaymentSelectionKeyBuilder())->buildSelectionKey($paymentOption); + + $context = self::getContext(); + $context->cart = new class extends \Cart { + public function __construct() + { + $this->id = 1; + } + + public function getOrderTotal($withTaxes = true, $type = \Cart::BOTH, $products = null, $id_carrier = null, $use_cache = false, bool $keepOrderPrices = false) + { + return 42.0; + } + }; + $context->cookie->__set('opc_selected_payment_module', 'ps_wirepayment'); + $context->cookie->__set('opc_selected_payment_selection_key', $selectionKey); + + $finder = new class($paymentOption) extends \PaymentOptionsFinder { + private array $paymentOption; + + public function __construct(array $paymentOption) + { + $this->paymentOption = $paymentOption; + } + + public function present($free = false) + { + return $free ? [] : ['ps_wirepayment' => [0 => $this->paymentOption]]; + } + }; + + $handler = new OnePageCheckoutPaymentMethodsHandler($context, $finder); + $response = $handler->handle(); + + self::assertTrue($response['success']); + self::assertFalse($response['is_free']); + self::assertSame('ps_wirepayment', $response['selected_payment_module']); + self::assertSame($selectionKey, $response['selected_payment_selection_key']); + self::assertSame('ps_wirepayment', $response['payment_options']['ps_wirepayment'][0]['module_name']); + self::assertNotSame('', $response['payment_options']['ps_wirepayment'][0]['selection_key']); + } + + public function testItFiltersPaymentOptionsByResolvedDeliveryCountry(): void + { + $context = self::getContext(); + $context->cart = new class extends \Cart { + public function __construct() + { + $this->id = 1; + } + + public function getOrderTotal($withTaxes = true, $type = \Cart::BOTH, $products = null, $id_carrier = null, $use_cache = false, bool $keepOrderPrices = false) + { + return 42.0; + } + }; + $context->country = new \Country((int) \Country::getByIso('FR')); + + $finder = new class extends \PaymentOptionsFinder { + public function __construct() + { + } + + public function present($free = false) + { + return [ + 'ps_wirepayment' => [ + 0 => [ + 'module_name' => 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ], + ], + 'ps_checkpayment' => [ + 0 => [ + 'module_name' => 'ps_checkpayment', + 'call_to_action_text' => 'Check payment', + 'action' => '/module/ps_checkpayment/validation', + ], + ], + ]; + } + }; + + $this->configureModuleCountryRestriction('ps_wirepayment', ['FR']); + $this->configureModuleCountryRestriction('ps_checkpayment', ['US']); + + $handler = new OnePageCheckoutPaymentMethodsHandler($context, $finder); + + $franceResponse = $handler->handle([ + 'id_country' => (string) \Country::getByIso('FR'), + ]); + self::assertArrayHasKey('ps_wirepayment', $franceResponse['payment_options']); + self::assertArrayNotHasKey('ps_checkpayment', $franceResponse['payment_options']); + self::assertSame((int) \Country::getByIso('FR'), (int) $context->country->id); + + $usResponse = $handler->handle([ + 'id_country' => (string) \Country::getByIso('US'), + ]); + self::assertArrayNotHasKey('ps_wirepayment', $usResponse['payment_options']); + self::assertArrayHasKey('ps_checkpayment', $usResponse['payment_options']); + self::assertSame((int) \Country::getByIso('FR'), (int) $context->country->id); + } + + public function testItFiltersPaymentOptionsByResolvedInvoiceCountryWhenTaxesUseInvoiceAddress(): void + { + \Configuration::updateValue('PS_TAX_ADDRESS_TYPE', 'id_address_invoice'); + + $context = self::getContext(); + $context->cart = new class extends \Cart { + public function __construct() + { + $this->id = 1; + $this->id_address_delivery = 0; + $this->id_address_invoice = 0; + } + + public function getOrderTotal($withTaxes = true, $type = \Cart::BOTH, $products = null, $id_carrier = null, $use_cache = false, bool $keepOrderPrices = false) + { + return 42.0; + } + }; + $context->country = new \Country((int) \Country::getByIso('FR')); + + $finder = new class extends \PaymentOptionsFinder { + public function __construct() + { + } + + public function present($free = false) + { + return [ + 'ps_wirepayment' => [ + 0 => [ + 'module_name' => 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ], + ], + 'ps_checkpayment' => [ + 0 => [ + 'module_name' => 'ps_checkpayment', + 'call_to_action_text' => 'Check payment', + 'action' => '/module/ps_checkpayment/validation', + ], + ], + ]; + } + }; + + $this->configureModuleCountryRestriction('ps_wirepayment', ['FR']); + $this->configureModuleCountryRestriction('ps_checkpayment', ['US']); + + $handler = new OnePageCheckoutPaymentMethodsHandler($context, $finder); + $response = $handler->handle([ + 'id_country' => (string) \Country::getByIso('FR'), + 'invoice_id_country' => (string) \Country::getByIso('US'), + ]); + + self::assertArrayNotHasKey('ps_wirepayment', $response['payment_options']); + self::assertArrayHasKey('ps_checkpayment', $response['payment_options']); + self::assertSame((int) \Country::getByIso('FR'), (int) $context->country->id); + } + + public function testItClearsPersistedPaymentSelectionWhenCountryFilteringMakesItInvalid(): void + { + $context = self::getContext(); + $context->cart = new class extends \Cart { + public function __construct() + { + $this->id = 1; + } + + public function getOrderTotal($withTaxes = true, $type = \Cart::BOTH, $products = null, $id_carrier = null, $use_cache = false, bool $keepOrderPrices = false) + { + return 42.0; + } + }; + $context->country = new \Country((int) \Country::getByIso('FR')); + $context->cookie->__set('opc_selected_payment_option', 'payment-option-1'); + $context->cookie->__set('opc_selected_payment_module', 'ps_wirepayment'); + $context->cookie->__set('opc_selected_payment_selection_key', 'ps_wirepayment::selection'); + + $finder = new class extends \PaymentOptionsFinder { + public function __construct() + { + } + + public function present($free = false) + { + return [ + 'ps_wirepayment' => [ + 0 => [ + 'module_name' => 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ], + ], + 'ps_checkpayment' => [ + 0 => [ + 'module_name' => 'ps_checkpayment', + 'call_to_action_text' => 'Check payment', + 'action' => '/module/ps_checkpayment/validation', + ], + ], + ]; + } + }; + + $this->configureModuleCountryRestriction('ps_wirepayment', ['FR']); + $this->configureModuleCountryRestriction('ps_checkpayment', ['US']); + + $handler = new OnePageCheckoutPaymentMethodsHandler($context, $finder); + $response = $handler->handle([ + 'id_country' => (string) \Country::getByIso('US'), + ]); + + self::assertSame('', $response['selected_payment_module']); + self::assertSame('', $response['selected_payment_selection_key']); + self::assertSame('', (string) $context->cookie->__get('opc_selected_payment_option')); + self::assertSame('', (string) $context->cookie->__get('opc_selected_payment_module')); + self::assertSame('', (string) $context->cookie->__get('opc_selected_payment_selection_key')); + self::assertArrayNotHasKey('ps_wirepayment', $response['payment_options']); + self::assertArrayHasKey('ps_checkpayment', $response['payment_options']); + } + + public function testItReturnsStructuredErrorWhenCartIsMissing(): void + { + $context = self::getContext(); + $context->cart = null; + + $handler = new OnePageCheckoutPaymentMethodsHandler($context); + $response = $handler->handle(); + + self::assertFalse($response['success']); + self::assertArrayHasKey('errors', $response); + } + + /** + * @param string[] $countryIsoCodes + */ + private function configureModuleCountryRestriction(string $moduleName, array $countryIsoCodes): void + { + $moduleId = (int) \Module::getModuleIdByName($moduleName); + if ($moduleId <= 0) { + self::assertTrue(\Db::getInstance()->insert('module', [ + 'name' => pSQL($moduleName), + 'active' => 1, + ])); + $moduleId = (int) \Db::getInstance()->Insert_ID(); + } + + self::assertGreaterThan(0, $moduleId, sprintf('Unable to resolve module "%s".', $moduleName)); + + $shopId = (int) self::getContext()->shop->id; + \Db::getInstance()->delete('module_country', sprintf('id_module = %d AND id_shop = %d', $moduleId, $shopId)); + + foreach ($countryIsoCodes as $isoCode) { + $countryId = (int) \Country::getByIso($isoCode); + self::assertGreaterThan(0, $countryId, sprintf('Unable to resolve country "%s".', $isoCode)); + + self::assertTrue(\Db::getInstance()->insert('module_country', [ + 'id_module' => $moduleId, + 'id_shop' => $shopId, + 'id_country' => $countryId, + ])); + } + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php new file mode 100644 index 0000000..3e1816b --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php @@ -0,0 +1,155 @@ +createCustomer(); + $cart = new \Cart(); + $cart->id_currency = (int) \Configuration::get('PS_CURRENCY_DEFAULT'); + $cart->id_lang = (int) \Configuration::get('PS_LANG_DEFAULT'); + $cart->id_shop_group = 1; + $cart->id_shop = 1; + $cart->id_customer = (int) $customer->id; + self::assertTrue($cart->add()); + + $context = self::getContext(); + $context->customer = $customer; + $context->cart = $cart; + $context->language = new \Language((int) \Configuration::get('PS_LANG_DEFAULT')); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + $countryId = (int) \Configuration::get('PS_COUNTRY_DEFAULT'); + $states = \State::getStatesByIdCountry($countryId); + $defaultStateId = !empty($states) ? (int) $states[0]['id_state'] : 0; + + $handler = new OnePageCheckoutSaveAddressHandler($context, $translator, new CheckoutCustomerContextResolver($context)); + $response = $handler->handle([ + 'address_type' => 'delivery', + 'firstname' => 'Integration', + 'lastname' => 'Customer', + 'address1' => '1 rue Integration', + 'city' => 'Paris', + 'postcode' => '75001', + 'id_country' => (string) $countryId, + 'id_state' => $defaultStateId > 0 ? (string) $defaultStateId : '', + 'alias' => 'Home', + 'use_same_address' => '1', + ]); + + self::assertTrue($response['success'], var_export($response, true)); + self::assertGreaterThan(0, $response['id_address']); + + $freshCart = new \Cart((int) $cart->id); + self::assertSame((int) $response['id_address'], (int) $freshCart->id_address_delivery); + self::assertSame((int) $response['id_address'], (int) $freshCart->id_address_invoice); + + $checkoutSession = $this->createCheckoutSession($context, $translator); + self::assertSame((int) $response['id_address'], (int) $checkoutSession->getIdAddressDelivery()); + self::assertSame((int) $response['id_address'], (int) $checkoutSession->getIdAddressInvoice()); + } + + public function testItCreatesDeliveryAddressForCountryWithStatesWhenRequestChangesCountry(): void + { + $customer = $this->createCustomer(); + $cart = new \Cart(); + $cart->id_currency = (int) \Configuration::get('PS_CURRENCY_DEFAULT'); + $cart->id_lang = (int) \Configuration::get('PS_LANG_DEFAULT'); + $cart->id_shop_group = 1; + $cart->id_shop = 1; + $cart->id_customer = (int) $customer->id; + self::assertTrue($cart->add()); + + $context = self::getContext(); + $context->customer = $customer; + $context->cart = $cart; + $context->language = new \Language((int) \Configuration::get('PS_LANG_DEFAULT')); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + $unitedStatesId = (int) \Country::getByIso('US'); + self::assertGreaterThan(0, $unitedStatesId); + + $stateRows = \State::getStatesByIdCountry($unitedStatesId); + $illinoisState = current(array_filter($stateRows, static function (array $stateRow): bool { + return isset($stateRow['name']) && $stateRow['name'] === 'Illinois'; + })); + self::assertNotFalse($illinoisState); + + $handler = new OnePageCheckoutSaveAddressHandler($context, $translator, new CheckoutCustomerContextResolver($context)); + $response = $handler->handle([ + 'address_type' => 'delivery', + 'firstname' => 'Integration', + 'lastname' => 'Customer', + 'address1' => '16 Main street', + 'city' => 'Chicago', + 'postcode' => '60601', + 'id_country' => (string) $unitedStatesId, + 'id_state' => (string) $illinoisState['id_state'], + 'alias' => 'Illinois address', + 'use_same_address' => '1', + ]); + + self::assertTrue($response['success'], var_export($response, true)); + + $savedAddress = new \Address((int) $response['id_address']); + self::assertSame($unitedStatesId, (int) $savedAddress->id_country); + self::assertSame((int) $illinoisState['id_state'], (int) $savedAddress->id_state); + } + + private function createCustomer(): \Customer + { + $customer = new \Customer(); + $customer->firstname = 'Integration'; + $customer->lastname = 'Customer'; + $customer->email = sprintf('opc-save-%s@example.com', uniqid('', true)); + $customer->is_guest = true; + $customer->passwd = \Tools::hash('integration-password'); + self::assertTrue($customer->save()); + + return $customer; + } + + private function createCheckoutSession(\Context $context, TranslatorInterface $translator): \CheckoutSession + { + return new \CheckoutSession( + $context, + new \DeliveryOptionsFinder( + $context, + $translator, + new \PrestaShop\PrestaShop\Adapter\Presenter\Object\ObjectPresenter(), + new \PrestaShop\PrestaShop\Adapter\Product\PriceFormatter() + ) + ); + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcSaveAddressSpe54IntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressSpe54IntegrationTest.php new file mode 100644 index 0000000..d122962 --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressSpe54IntegrationTest.php @@ -0,0 +1,11 @@ +handle([ + 'payment_option' => 'payment-option-1', + 'payment_module' => 'ps_wirepayment', + 'payment_selection_key' => 'ps_wirepayment::selection', + ]); + + self::assertTrue($response['success']); + self::assertSame('payment-option-1', (string) $context->cookie->__get('opc_selected_payment_option')); + self::assertSame('ps_wirepayment', (string) $context->cookie->__get('opc_selected_payment_module')); + self::assertSame('ps_wirepayment::selection', (string) $context->cookie->__get('opc_selected_payment_selection_key')); + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcStatesHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcStatesHandlerIntegrationTest.php new file mode 100644 index 0000000..c33f14f --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcStatesHandlerIntegrationTest.php @@ -0,0 +1,38 @@ +handle([ + 'id_country' => (int) \Configuration::get('PS_COUNTRY_DEFAULT'), + ]); + + self::assertTrue($response['success']); + self::assertArrayHasKey('states', $response); + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcTempAddressIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcTempAddressIntegrationTest.php new file mode 100644 index 0000000..9888535 --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcTempAddressIntegrationTest.php @@ -0,0 +1,155 @@ +createCustomer(); + $context->customer = $customer; + + $originalDelivery = $this->createAddress($customer, 'Original delivery', 'FR', '75001', 'Paris'); + $originalInvoice = $this->createAddress($customer, 'Original invoice', 'FR', '69001', 'Lyon'); + + $cart = new \Cart(); + $cart->id_currency = (int) \Configuration::get('PS_CURRENCY_DEFAULT'); + $cart->id_lang = (int) \Configuration::get('PS_LANG_DEFAULT'); + $cart->id_shop_group = 1; + $cart->id_shop = 1; + $cart->id_customer = (int) $customer->id; + $cart->id_address_delivery = (int) $originalDelivery->id; + $cart->id_address_invoice = (int) $originalInvoice->id; + self::assertTrue($cart->add()); + $context->cart = $cart; + + $tempAddress = new OpcTempAddress($context); + $tempDeliveryId = $tempAddress->createFromRequest([ + 'id_country' => (string) \Country::getByIso('FR'), + 'postcode' => '75009', + 'city' => 'Paris', + 'invoice_id_country' => (string) \Country::getByIso('US'), + 'use_same_address' => '0', + ]); + + self::assertGreaterThan(0, $tempDeliveryId); + self::assertSame($tempDeliveryId, (int) $context->cart->id_address_delivery); + self::assertNotSame((int) $originalInvoice->id, (int) $context->cart->id_address_invoice); + self::assertNotSame($tempDeliveryId, (int) $context->cart->id_address_invoice); + + $tempInvoiceId = (int) $context->cart->id_address_invoice; + $tempDeliveryAddress = new \Address($tempDeliveryId); + $tempInvoiceAddress = new \Address($tempInvoiceId); + + self::assertSame((int) \Country::getByIso('FR'), (int) $tempDeliveryAddress->id_country); + self::assertSame((int) \Country::getByIso('US'), (int) $tempInvoiceAddress->id_country); + + $tempAddress->cleanup($tempDeliveryId, (int) $originalDelivery->id); + + $freshCart = new \Cart((int) $cart->id); + self::assertSame((int) $originalDelivery->id, (int) $freshCart->id_address_delivery); + self::assertSame((int) $originalInvoice->id, (int) $freshCart->id_address_invoice); + self::assertSame( + '0', + (string) \Db::getInstance()->getValue('SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'address` WHERE id_address = ' . (int) $tempDeliveryId) + ); + self::assertSame( + '0', + (string) \Db::getInstance()->getValue('SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'address` WHERE id_address = ' . (int) $tempInvoiceId) + ); + } + + public function testItFallsBackToDeliveryCountryForTempInvoiceAddressWhenUseSameAddressIsEnabled(): void + { + \Configuration::updateValue('PS_TAX_ADDRESS_TYPE', 'id_address_invoice'); + + $context = self::getContext(); + $customer = $this->createCustomer(); + $context->customer = $customer; + + $originalDelivery = $this->createAddress($customer, 'Original delivery', 'FR', '75001', 'Paris'); + $originalInvoice = $this->createAddress($customer, 'Original invoice', 'FR', '69001', 'Lyon'); + + $cart = new \Cart(); + $cart->id_currency = (int) \Configuration::get('PS_CURRENCY_DEFAULT'); + $cart->id_lang = (int) \Configuration::get('PS_LANG_DEFAULT'); + $cart->id_shop_group = 1; + $cart->id_shop = 1; + $cart->id_customer = (int) $customer->id; + $cart->id_address_delivery = (int) $originalDelivery->id; + $cart->id_address_invoice = (int) $originalInvoice->id; + self::assertTrue($cart->add()); + $context->cart = $cart; + + $tempAddress = new OpcTempAddress($context); + $tempDeliveryId = $tempAddress->createFromRequest([ + 'id_country' => (string) \Country::getByIso('US'), + 'postcode' => '33133', + 'city' => 'Miami', + 'use_same_address' => '1', + ]); + + self::assertGreaterThan(0, $tempDeliveryId); + $tempInvoiceId = (int) $context->cart->id_address_invoice; + self::assertNotSame((int) $originalInvoice->id, $tempInvoiceId); + self::assertSame((int) \Country::getByIso('US'), (int) (new \Address($tempInvoiceId))->id_country); + + $tempAddress->cleanup($tempDeliveryId, (int) $originalDelivery->id); + } + + private function createCustomer(): \Customer + { + $customer = new \Customer(); + $customer->firstname = 'Integration'; + $customer->lastname = 'Customer'; + $customer->email = sprintf('opc-temp-%s@example.com', uniqid('', true)); + $customer->is_guest = true; + $customer->passwd = \Tools::hash('integration-password'); + self::assertTrue($customer->save()); + + return $customer; + } + + private function createAddress(\Customer $customer, string $alias, string $countryIso, string $postcode, string $city): \Address + { + $address = new \Address(); + $address->id_customer = (int) $customer->id; + $address->alias = $alias; + $address->firstname = $customer->firstname; + $address->lastname = $customer->lastname; + $address->address1 = '1 rue Integration'; + $address->city = $city; + $address->postcode = $postcode; + $address->id_country = (int) \Country::getByIso($countryIso); + self::assertTrue($address->add()); + + return $address; + } +} diff --git a/tests/php/Integration/Checkout/Ajax/fixtures/CheckoutGuestEmailUpdateConcurrentWorker.php b/tests/php/Integration/Checkout/Ajax/fixtures/CheckoutGuestEmailUpdateConcurrentWorker.php index d5d8a00..cccfca0 100644 --- a/tests/php/Integration/Checkout/Ajax/fixtures/CheckoutGuestEmailUpdateConcurrentWorker.php +++ b/tests/php/Integration/Checkout/Ajax/fixtures/CheckoutGuestEmailUpdateConcurrentWorker.php @@ -43,20 +43,13 @@ public function getLocale(): string final class ConcurrentEmailUpdateCheckoutForm extends OnePageCheckoutForm { - /** - * @var array> - */ - protected $errors = []; - public function __construct() { } public function submitGuestInit(array $params = []): bool { - $this->errors[''][] = 'submitGuestInit should not be called during guest email update race.'; - - return false; + return true; } /** @@ -64,7 +57,7 @@ public function submitGuestInit(array $params = []): bool */ public function getErrors(): array { - return $this->errors; + return []; } } diff --git a/tests/php/Integration/Checkout/OnePageCheckoutFormSyncContextIntegrationTest.php b/tests/php/Integration/Checkout/OnePageCheckoutFormSyncContextIntegrationTest.php index 64e72f3..ce5f26a 100644 --- a/tests/php/Integration/Checkout/OnePageCheckoutFormSyncContextIntegrationTest.php +++ b/tests/php/Integration/Checkout/OnePageCheckoutFormSyncContextIntegrationTest.php @@ -54,7 +54,7 @@ public function testItHydratesEmptyContextFromFreshGuestCartOwner(): void self::assertTrue((bool) $context->customer->is_guest); } - public function testItDoesNotHydrateWhenFreshCartOwnerIsRegistered(): void + public function testItHydratesWhenFreshCartOwnerIsRegistered(): void { $registeredOwner = $this->createCustomer($this->uniqueEmail('registered-owner'), false); $cart = $this->createPersistedCart((int) $registeredOwner->id); @@ -66,10 +66,11 @@ public function testItDoesNotHydrateWhenFreshCartOwnerIsRegistered(): void $form = $this->createFormWithoutConstructor($context); $this->invokeSyncContextCustomerFromCart($form); - self::assertSame(0, (int) $context->customer->id); + self::assertSame((int) $registeredOwner->id, (int) $context->customer->id); + self::assertFalse((bool) $context->customer->is_guest); } - public function testItKeepsAlreadyHydratedContextCustomer(): void + public function testItResynchronizesAlreadyHydratedContextCustomerToFreshCartOwner(): void { $guestOwner = $this->createCustomer($this->uniqueEmail('owner-guest'), true); $existingContextCustomer = $this->createCustomer($this->uniqueEmail('existing-customer'), true); @@ -82,7 +83,7 @@ public function testItKeepsAlreadyHydratedContextCustomer(): void $form = $this->createFormWithoutConstructor($context); $this->invokeSyncContextCustomerFromCart($form); - self::assertSame((int) $existingContextCustomer->id, (int) $context->customer->id); + self::assertSame((int) $guestOwner->id, (int) $context->customer->id); } public function testItHydratesFromFreshOwnerWhenContextCartSnapshotOwnerIsZero(): void @@ -103,7 +104,7 @@ public function testItHydratesFromFreshOwnerWhenContextCartSnapshotOwnerIsZero() self::assertTrue((bool) $context->customer->is_guest); } - public function testItDoesNothingWhenContextCustomerIdIsPositiveButStale(): void + public function testItResynchronizesWhenContextCustomerIdIsPositiveButStale(): void { $guestOwner = $this->createCustomer($this->uniqueEmail('stale-context-guest-owner'), true); $cart = $this->createPersistedCart((int) $guestOwner->id); @@ -116,7 +117,7 @@ public function testItDoesNothingWhenContextCustomerIdIsPositiveButStale(): void $form = $this->createFormWithoutConstructor($context); $this->invokeSyncContextCustomerFromCart($form); - self::assertSame(999999, (int) $context->customer->id); + self::assertSame((int) $guestOwner->id, (int) $context->customer->id); } public function testItDoesNothingWhenCartHasNoOwner(): void diff --git a/tests/php/Integration/Form/BackOfficeConfigurationFormIntegrationTest.php b/tests/php/Integration/Form/BackOfficeConfigurationFormIntegrationTest.php index 5d5ff72..c71fd7f 100644 --- a/tests/php/Integration/Form/BackOfficeConfigurationFormIntegrationTest.php +++ b/tests/php/Integration/Form/BackOfficeConfigurationFormIntegrationTest.php @@ -47,6 +47,7 @@ protected function tearDown(): void { $_GET = $this->backupGet; $_POST = $this->backupPost; + \Shop::setContext(\Shop::CONTEXT_ALL); parent::tearDown(); } @@ -143,6 +144,25 @@ public function testItDisplaysConfirmationOnlyOnceAfterRedirect(): void self::assertStringContainsString('twig-after-submit', $secondGetContent); self::assertCount(1, $module->confirmationMessages); } + + public function testItDoesNotPersistSubmittedValueOutsideSingleShopContext(): void + { + $_POST['submitPsOnePageCheckoutConfiguration'] = '1'; + $_POST['PS_ONE_PAGE_CHECKOUT_ENABLED'] = '1'; + \Shop::setContext(\Shop::CONTEXT_ALL); + + $twig = new Environment(new ArrayLoader([ + '@Modules/ps_onepagecheckout/views/templates/admin/checkout_layout_configuration.html.twig' => 'twig-after-submit', + ])); + $module = new IntegrationBackOfficeModuleStub($twig, new ContainerBuilder()); + $form = new IntegrationBackOfficeConfigurationFormSpy($module, 'PS_ONE_PAGE_CHECKOUT_ENABLED'); + + $content = $form->renderBackOfficeConfiguration(); + + self::assertSame('', $content); + self::assertSame([], $form->persistedValues); + self::assertCount(1, $form->redirectedUrls); + } } class IntegrationBackOfficeConfigurationFormSpy extends BackOfficeConfigurationForm diff --git a/tests/php/Integration/Form/Spe10BackOfficeConfigurationFormIntegrationTest.php b/tests/php/Integration/Form/Spe10BackOfficeConfigurationFormIntegrationTest.php new file mode 100644 index 0000000..6009f9e --- /dev/null +++ b/tests/php/Integration/Form/Spe10BackOfficeConfigurationFormIntegrationTest.php @@ -0,0 +1,11 @@ +id = (int) $id; + } +} diff --git a/tests/php/Mocks/bootstrap.php b/tests/php/Mocks/bootstrap.php index 0cddd09..9bf2296 100644 --- a/tests/php/Mocks/bootstrap.php +++ b/tests/php/Mocks/bootstrap.php @@ -2,4 +2,6 @@ require_once __DIR__ . '/Configuration.php'; require_once __DIR__ . '/Hook.php'; +require_once __DIR__ . '/LegacyConfiguration.php'; +require_once __DIR__ . '/LegacyEntityMapper.php'; require_once __DIR__ . '/PrestaShopLogger.php'; diff --git a/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php new file mode 100644 index 0000000..7d471ac --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php @@ -0,0 +1,43 @@ +language = new class extends \Language { + public function __construct() + { + } + }; + $context->language->id = 1; + + $customer = $this->createMock(\Customer::class); + $customer->method('getAddresses')->with(1)->willReturn([ + ['id_address' => 10, 'alias' => 'Home'], + ['id_address' => 12, 'alias' => 'Office'], + ]); + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolve')->willReturn($customer); + + $handler = new OnePageCheckoutAddressesListHandler($context, $resolver); + $response = $handler->handle(['id_address' => 12]); + + self::assertTrue($response['success']); + self::assertCount(2, $response['addresses']); + self::assertSame(12, $response['address']['id_address']); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/OpcCarriersHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcCarriersHandlerTest.php new file mode 100644 index 0000000..c409446 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcCarriersHandlerTest.php @@ -0,0 +1,60 @@ +cart = new class extends \Cart { + public bool $saved = false; + + public function __construct() + { + } + + public function save($nullValues = false, $autoDate = true) + { + $this->saved = true; + + return true; + } + }; + $context->cart->id = 42; + $context->cart->id_address_delivery = 10; + + $translator = $this->createMock(TranslatorInterface::class); + $translator + ->method('trans') + ->willReturn('Invalid delivery address.'); + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver + ->method('resolveId') + ->willReturn(123); + + $handler = new class($context, $translator, null, $resolver) extends OnePageCheckoutCarriersHandler { + protected function isOwnedCheckoutAddress(int $addressId): bool + { + return false; + } + }; + + $response = $handler->handle([ + 'id_address_delivery' => '999', + ]); + + self::assertFalse($response['success']); + self::assertSame(['Invalid delivery address.'], $response['errors']['id_address_delivery']); + self::assertSame(10, (int) $context->cart->id_address_delivery); + self::assertFalse($context->cart->saved); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/OpcGuestInitHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcGuestInitHandlerTest.php index d56548d..3724a3d 100644 --- a/tests/php/Unit/Checkout/Ajax/OpcGuestInitHandlerTest.php +++ b/tests/php/Unit/Checkout/Ajax/OpcGuestInitHandlerTest.php @@ -380,8 +380,16 @@ public function testItCreatesANewGuestWhenRegisteredEmailIsSubmittedOnAnonymousC $handler->setCustomerById(55, $registeredCustomer); $opcForm - ->expects($this->never()) + ->expects($this->once()) ->method('submitGuestInit') + ->willReturn(false) + ; + $opcForm + ->expects($this->once()) + ->method('getErrors') + ->willReturn([ + 'email' => ['Unable to save guest customer'], + ]) ; $response = $handler->handle([ @@ -389,9 +397,8 @@ public function testItCreatesANewGuestWhenRegisteredEmailIsSubmittedOnAnonymousC 'token' => 'expected-token', ]); - self::assertTrue($response['success']); - self::assertFalse($response['customer_created']); - self::assertSame(0, $response['id_customer']); + self::assertFalse($response['success']); + self::assertSame(['Unable to save guest customer'], $response['errors']['email']); } public function testItCreatesANewGuestWhenExistingGuestEmailIsSubmittedOnAnonymousCart(): void @@ -405,8 +412,16 @@ public function testItCreatesANewGuestWhenExistingGuestEmailIsSubmittedOnAnonymo $handler->setCustomerIdByEmail('john@doe.example', 55); $opcForm - ->expects($this->never()) + ->expects($this->once()) ->method('submitGuestInit') + ->willReturn(false) + ; + $opcForm + ->expects($this->once()) + ->method('getErrors') + ->willReturn([ + 'email' => ['Unable to save guest customer'], + ]) ; $response = $handler->handle([ @@ -414,9 +429,8 @@ public function testItCreatesANewGuestWhenExistingGuestEmailIsSubmittedOnAnonymo 'token' => 'expected-token', ]); - self::assertTrue($response['success']); - self::assertFalse($response['customer_created']); - self::assertSame(0, $response['id_customer']); + self::assertFalse($response['success']); + self::assertSame(['Unable to save guest customer'], $response['errors']['email']); } public function testItDoesNotNoopWhenCartCustomerReferenceIsStale(): void @@ -466,8 +480,16 @@ public function testItReturnsErrorWhenCartCustomerReferenceIsStaleDuringExisting $handler->setFreshCartCustomerIdByCartId(1, 999); $opcForm - ->expects($this->never()) + ->expects($this->once()) ->method('submitGuestInit') + ->willReturn(false) + ; + $opcForm + ->expects($this->once()) + ->method('getErrors') + ->willReturn([ + 'email' => ['Unable to save guest customer'], + ]) ; $response = $handler->handle([ @@ -476,12 +498,11 @@ public function testItReturnsErrorWhenCartCustomerReferenceIsStaleDuringExisting ]); self::assertFalse($response['success']); - self::assertArrayHasKey('', $response['errors']); - self::assertNotEmpty($response['errors']['']); + self::assertSame(['Unable to save guest customer'], $response['errors']['email']); self::assertSame(999, (int) $cart->id_customer); self::assertSame(10, (int) \Context::getContext()->customer->id); - self::assertSame('updated@example.com', $guest->email); - self::assertSame('updated@example.com', (string) \Context::getContext()->customer->email); + self::assertSame('guest@example.com', $guest->email); + self::assertSame('guest@example.com', (string) \Context::getContext()->customer->email); } public function testItReturnsErrorWhenCartClaimLosesRaceAndFreshOwnerCannotBeReadInUnitDbMock(): void diff --git a/tests/php/Unit/Checkout/Ajax/OpcPaymentMethodsHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcPaymentMethodsHandlerTest.php new file mode 100644 index 0000000..c6d4d7b --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcPaymentMethodsHandlerTest.php @@ -0,0 +1,101 @@ + 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ]; + $selectionKey = (new PaymentSelectionKeyBuilder())->buildSelectionKey($paymentOption); + + $context = new class extends \Context { + public function __construct() + { + } + }; + $context->cart = new class extends \Cart { + public function __construct() + { + $this->id = 1; + } + + public function getOrderTotal($withTaxes = true, $type = \Cart::BOTH, $products = null, $id_carrier = null, $use_cache = false, bool $keepOrderPrices = false) + { + return 42.0; + } + }; + $context->cookie = new class($selectionKey) { + /** @var array */ + private array $values; + + public function __construct(string $selectionKey) + { + $this->values = [ + 'opc_selected_payment_module' => 'ps_wirepayment', + 'opc_selected_payment_selection_key' => $selectionKey, + ]; + } + + public function __get(string $name) + { + return $this->values[$name] ?? null; + } + + public function __unset(string $name): void + { + unset($this->values[$name]); + } + + public function write(): void + { + } + }; + + $finder = $this->getMockBuilder(\PaymentOptionsFinder::class) + ->disableOriginalConstructor() + ->onlyMethods(['present']) + ->getMock(); + $finder->expects($this->once())->method('present')->with(false)->willReturn([ + 'ps_wirepayment' => [ + 0 => $paymentOption, + ], + ]); + + $handler = new OnePageCheckoutPaymentMethodsHandler($context, $finder); + $response = $handler->handle(); + + self::assertTrue($response['success']); + self::assertSame('ps_wirepayment', $response['selected_payment_module']); + self::assertSame($selectionKey, $response['selected_payment_selection_key']); + self::assertSame('ps_wirepayment', $response['payment_options']['ps_wirepayment'][0]['module_name']); + self::assertNotSame('', $response['payment_options']['ps_wirepayment'][0]['selection_key']); + self::assertFalse($response['is_free']); + } + + public function testItReturnsStructuredErrorWhenCartIsMissing(): void + { + $context = new class extends \Context { + public function __construct() + { + } + }; + $context->cart = null; + + $handler = new OnePageCheckoutPaymentMethodsHandler($context); + $response = $handler->handle(); + + self::assertFalse($response['success']); + self::assertArrayHasKey('errors', $response); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php new file mode 100644 index 0000000..08f6858 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php @@ -0,0 +1,45 @@ +createMock(TranslatorInterface::class); + $context = new class extends \Context { + public function __construct() + { + } + }; + $context->smarty = $this->createMock(\Smarty::class); + $context->language = new class extends \Language { + public function __construct() + { + } + }; + $context->language->id = 1; + $context->country = new class extends \Country { + public function __construct() + { + } + }; + $context->country->id = 8; + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolveId')->willReturn(42); + + $handler = new OnePageCheckoutSaveAddressHandler($context, $translator, $resolver); + $response = $handler->handle([]); + + self::assertFalse($response['success']); + self::assertNotEmpty($response['errors']); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php new file mode 100644 index 0000000..e684878 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php @@ -0,0 +1,51 @@ + */ + public array $values = []; + + public function __set(string $name, $value): void + { + $this->values[$name] = $value; + } + + public function __get(string $name) + { + return $this->values[$name] ?? null; + } + + public function write(): void + { + } + }; + $context = new class extends \Context { + public function __construct() + { + } + }; + $context->cookie = $cookie; + + $handler = new OnePageCheckoutSelectPaymentHandler($context); + $response = $handler->handle([ + 'payment_option' => 'payment-option-1', + 'payment_module' => 'ps_wirepayment', + 'payment_selection_key' => 'ps_wirepayment::selection', + ]); + + self::assertTrue($response['success']); + self::assertSame('payment-option-1', $cookie->values['opc_selected_payment_option']); + self::assertSame('ps_wirepayment', $cookie->values['opc_selected_payment_module']); + self::assertSame('ps_wirepayment::selection', $cookie->values['opc_selected_payment_selection_key']); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/OpcStatesHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcStatesHandlerTest.php new file mode 100644 index 0000000..7d6f6e5 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcStatesHandlerTest.php @@ -0,0 +1,21 @@ +handle([]); + + self::assertTrue($response['success']); + self::assertSame([], $response['states']); + self::assertFalse($response['contains_states']); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/TempAddressCarrierSelectionStorageTest.php b/tests/php/Unit/Checkout/Ajax/TempAddressCarrierSelectionStorageTest.php new file mode 100644 index 0000000..8ab0f64 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/TempAddressCarrierSelectionStorageTest.php @@ -0,0 +1,54 @@ + */ + public array $values = []; + public int $writes = 0; + + public function __get(string $name) + { + return $this->values[$name] ?? null; + } + + public function __set(string $name, string $value): void + { + $this->values[$name] = $value; + } + + public function __unset(string $name): void + { + unset($this->values[$name]); + } + + public function write(): void + { + ++$this->writes; + } + }; + + $context = new \Context(); + $context->cookie = $cookie; + + $storage = new TempAddressCarrierSelectionStorage($context); + + self::assertSame('', $storage->get()); + + $storage->save('2,'); + self::assertSame('2,', $storage->get()); + + $storage->clear(); + self::assertSame('', $storage->get()); + self::assertSame(2, $cookie->writes); + } +} diff --git a/tests/php/Unit/Checkout/CheckoutOnePageStepRenderTest.php b/tests/php/Unit/Checkout/CheckoutOnePageStepRenderTest.php new file mode 100644 index 0000000..4df648a --- /dev/null +++ b/tests/php/Unit/Checkout/CheckoutOnePageStepRenderTest.php @@ -0,0 +1,184 @@ +getMockBuilder(\Smarty::class) + ->disableOriginalConstructor() + ->getMock(); + + $context = new class extends \Context { + public function __construct() + { + } + }; + $context->smarty = $smarty; + $context->cart = new class extends \Cart { + public function __construct() + { + } + + public function isVirtualCart() + { + return false; + } + + public function getOrderTotal( + $withTaxes = true, + $type = \Cart::BOTH, + $products = null, + $id_carrier = null, + $use_cache = false, + bool $keepOrderPrices = false, + ) { + return 10.0; + } + }; + $context->cookie = new class { + public function __get(string $name) + { + return ''; + } + }; + $context->controller = new class { + public function getTemplateVarConfiguration(): array + { + return [ + 'display_taxes_label' => true, + ]; + } + }; + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturn(''); + + $opcForm = $this->getMockBuilder(OnePageCheckoutForm::class) + ->disableOriginalConstructor() + ->onlyMethods(['getTemplateVariables']) + ->getMock(); + $opcForm->expects($this->once()) + ->method('getTemplateVariables') + ->willReturn([ + 'contactFields' => ['email' => ['value' => 'john@example.com']], + 'additionalCustomerFields' => [], + 'useSameAddressField' => ['value' => true], + 'deliveryFields' => ['firstname' => ['name' => 'firstname']], + 'invoiceFields' => ['invoice_firstname' => ['name' => 'invoice_firstname']], + 'invoiceMetaFields' => [], + 'errors' => ['' => []], + 'token' => 'token', + ]); + + $paymentOptionsFinder = $this->getMockBuilder(\PaymentOptionsFinder::class) + ->disableOriginalConstructor() + ->onlyMethods(['present']) + ->getMock(); + $paymentOptionsFinder->method('present')->willReturn([]); + + $conditionsToApproveFinder = $this->getMockBuilder(\ConditionsToApproveFinder::class) + ->disableOriginalConstructor() + ->onlyMethods(['getConditionsToApproveForTemplate']) + ->getMock(); + $conditionsToApproveFinder->method('getConditionsToApproveForTemplate')->willReturn([]); + + $checkoutSession = new class($context->cart) { + private \Cart $cart; + + public function __construct(\Cart $cart) + { + $this->cart = $cart; + } + + public function getCart() + { + return $this->cart; + } + + public function getDeliveryOptions() + { + return []; + } + + public function getSelectedDeliveryOption() + { + return ''; + } + + public function isRecyclable() + { + return false; + } + + public function getMessage() + { + return ''; + } + + public function getGift() + { + return [ + 'isGift' => false, + 'message' => '', + ]; + } + }; + + $step = new class($context, $translator, $opcForm, $paymentOptionsFinder, $conditionsToApproveFinder, new PaymentSelectionKeyBuilder(), $checkoutSession) extends CheckoutOnePageStep { + public array $capturedParams = []; + private object $checkoutSessionStub; + + public function __construct( + \Context $context, + TranslatorInterface $translator, + OnePageCheckoutForm $opcForm, + \PaymentOptionsFinder $paymentOptionsFinder, + \ConditionsToApproveFinder $conditionsToApproveFinder, + PaymentSelectionKeyBuilder $paymentSelectionKeyBuilder, + object $checkoutSessionStub, + ) { + parent::__construct( + $context, + $translator, + $opcForm, + $paymentOptionsFinder, + $conditionsToApproveFinder, + $paymentSelectionKeyBuilder + ); + $this->checkoutSessionStub = $checkoutSessionStub; + } + + public function getCheckoutSession() + { + return $this->checkoutSessionStub; + } + + protected function renderTemplate($template, array $extraParams = [], array $params = []) + { + $this->capturedParams = $params; + + return 'rendered'; + } + }; + + self::assertSame('rendered', $step->render()); + self::assertArrayHasKey('contactFields', $step->capturedParams); + self::assertArrayHasKey('deliveryFields', $step->capturedParams); + self::assertArrayHasKey('invoiceFields', $step->capturedParams); + self::assertArrayHasKey('errors', $step->capturedParams); + self::assertArrayHasKey('configuration', $step->capturedParams); + self::assertArrayHasKey('is_guest_checkout_enabled', $step->capturedParams['configuration']); + self::assertIsBool($step->capturedParams['configuration']['is_guest_checkout_enabled']); + self::assertArrayNotHasKey('opc_form', $step->capturedParams); + } +} diff --git a/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php b/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php new file mode 100644 index 0000000..5d433b7 --- /dev/null +++ b/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php @@ -0,0 +1,129 @@ +mockProcess = $process; + } + + public function getCheckoutProcess(): \CheckoutProcess + { + return $this->mockProcess; + } +} + +class CheckoutOnePageStepRequestPersistenceTest extends TestCase +{ + private \Cart|MockObject $cart; + private \Context|MockObject $context; + private \CheckoutSession|MockObject $session; + private \CheckoutProcess|MockObject $checkoutProcess; + private OnePageCheckoutForm|MockObject $opcForm; + private TestableCheckoutOnePageStep $step; + + protected function setUp(): void + { + $this->cart = $this->createMock(\Cart::class); + + $customer = $this->createMock(\Customer::class); + $customer->method('isLogged')->willReturn(true); + $customer->method('isGuest')->willReturn(false); + + $language = $this->createMock(\Language::class); + $language->id = 1; + + $this->context = $this->createMock(\Context::class); + $this->context->cart = $this->cart; + $this->context->customer = $customer; + $this->context->language = $language; + $this->context->smarty = $this->createMock(\Smarty::class); + + $this->session = $this->createMock(\CheckoutSession::class); + + $this->checkoutProcess = $this->createMock(\CheckoutProcess::class); + $this->checkoutProcess->method('getCheckoutSession')->willReturn($this->session); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + $this->opcForm = $this->createMock(OnePageCheckoutForm::class); + $this->opcForm->method('fillWith')->willReturnSelf(); + + $this->step = new TestableCheckoutOnePageStep( + $this->context, + $translator, + $this->opcForm, + $this->createMock(\PaymentOptionsFinder::class), + $this->createMock(\ConditionsToApproveFinder::class) + ); + $this->step->setMockProcess($this->checkoutProcess); + } + + public function testDeliveryOptionArrayIsPersisted(): void + { + $this->session->expects($this->once()) + ->method('setDeliveryOption') + ->with([5 => '1,']); + + $this->step->handleRequest(['delivery_option' => [5 => '1,']]); + } + + public function testDeliveryOptionStringIsNotPersisted(): void + { + $this->session->expects($this->never()) + ->method('setDeliveryOption'); + + $this->step->handleRequest(['delivery_option' => '1,']); + } + + public function testDeliveryOptionNotPersistedOnSubmit(): void + { + $this->session->expects($this->never()) + ->method('setDeliveryOption'); + + $this->step->handleRequest([ + 'delivery_option' => [5 => '1,'], + 'submitOnePageCheckout' => '1', + ]); + } + + public function testDeliveryOptionNotPersistedOnVirtualCart(): void + { + $cart = $this->createMock(\Cart::class); + $cart->method('isVirtualCart')->willReturn(true); + + $session = $this->createMock(\CheckoutSession::class); + $session->expects($this->never())->method('setDeliveryOption'); + + $checkoutProcess = $this->createMock(\CheckoutProcess::class); + $checkoutProcess->method('getCheckoutSession')->willReturn($session); + + $context = clone $this->context; + $context->cart = $cart; + + $step = new TestableCheckoutOnePageStep( + $context, + $this->createMock(TranslatorInterface::class), + $this->opcForm, + $this->createMock(\PaymentOptionsFinder::class), + $this->createMock(\ConditionsToApproveFinder::class) + ); + $step->setMockProcess($checkoutProcess); + $step->handleRequest(['delivery_option' => [5 => '1,']]); + + self::assertTrue(true); + } +} diff --git a/tests/php/Unit/Checkout/CheckoutOnePageStepSpe7Test.php b/tests/php/Unit/Checkout/CheckoutOnePageStepSpe7Test.php new file mode 100644 index 0000000..848b5be --- /dev/null +++ b/tests/php/Unit/Checkout/CheckoutOnePageStepSpe7Test.php @@ -0,0 +1,39 @@ +newInstanceWithoutConstructor(); + + $context = new class extends \Context { + public function __construct() + { + } + }; + $context->cookie = new class { + public function __get(string $name) + { + return $name === 'opc_selected_payment_module' ? 'ps_wirepayment' : null; + } + }; + + $contextProperty = new \ReflectionProperty(\AbstractCheckoutStep::class, 'context'); + $contextProperty->setAccessible(true); + $contextProperty->setValue($step, $context); + + $method = $reflection->getMethod('getSelectedPaymentModule'); + $method->setAccessible(true); + + self::assertSame('ps_wirepayment', $method->invoke($step)); + } +} diff --git a/tests/php/Unit/Checkout/CheckoutOnePageStepTest.php b/tests/php/Unit/Checkout/CheckoutOnePageStepTest.php deleted file mode 100644 index 88c5940..0000000 --- a/tests/php/Unit/Checkout/CheckoutOnePageStepTest.php +++ /dev/null @@ -1,25 +0,0 @@ -newInstanceWithoutConstructor(); - - self::assertSame('checkout-one-page-step', $step->getIdentifier()); - } -} diff --git a/tests/php/Unit/Checkout/PaymentSelectionKeyBuilderTest.php b/tests/php/Unit/Checkout/PaymentSelectionKeyBuilderTest.php new file mode 100644 index 0000000..f19e5a3 --- /dev/null +++ b/tests/php/Unit/Checkout/PaymentSelectionKeyBuilderTest.php @@ -0,0 +1,56 @@ + 'payment-option-1', + 'module_name' => 'ps_wirepayment', + 'action' => '/module/ps_wirepayment/validation', + 'call_to_action_text' => 'Wire payment', + 'inputs' => [ + ['name' => 'token', 'type' => 'hidden'], + ], + ]; + + $firstKey = $builder->buildSelectionKey($baseOption); + $secondKey = $builder->buildSelectionKey(array_merge($baseOption, ['id' => 'payment-option-999'])); + + self::assertSame($firstKey, $secondKey); + } + + public function testItChangesSelectionKeyWhenBusinessSignatureChanges(): void + { + $builder = new PaymentSelectionKeyBuilder(); + + $firstKey = $builder->buildSelectionKey([ + 'module_name' => 'ps_wirepayment', + 'action' => '/module/ps_wirepayment/validation', + 'call_to_action_text' => 'Wire payment', + 'inputs' => [ + ['name' => 'token', 'type' => 'hidden'], + ], + ]); + + $secondKey = $builder->buildSelectionKey([ + 'module_name' => 'ps_wirepayment', + 'action' => '/module/ps_wirepayment/alternate', + 'call_to_action_text' => 'Wire payment', + 'inputs' => [ + ['name' => 'token', 'type' => 'hidden'], + ], + ]); + + self::assertNotSame($firstKey, $secondKey); + } +} diff --git a/tests/php/Unit/Controller/AddressFormControllerTest.php b/tests/php/Unit/Controller/AddressFormControllerTest.php index ae673c6..d2cc90b 100644 --- a/tests/php/Unit/Controller/AddressFormControllerTest.php +++ b/tests/php/Unit/Controller/AddressFormControllerTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutAddressFormHandler; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutFormFactory; +use PrestaShop\PrestaShop\Adapter\Presenter\Object\ObjectPresenter; class AddressFormControllerTest extends TestCase { @@ -34,6 +35,7 @@ public function testHandleAddressFormRefreshReturnsRenderedPartialWhenEnabled(): { $controller = new TestAddressFormController(); $controller->module = $this->createEnabledModule(); + $controller->setTestContext($this->createControllerContext()); $handler = $this->getMockBuilder(OnePageCheckoutAddressFormHandler::class) ->disableOriginalConstructor() @@ -54,7 +56,7 @@ public function testHandleAddressFormRefreshReturnsRenderedPartialWhenEnabled(): $response = $controller->callHandleAddressFormRefresh(); - self::assertSame('rendered:checkout/_partials/one-page-checkout-form', $response['address_form']); + self::assertSame('rendered:checkout/_partials/one-page-checkout/addresses-section', $response['addresses_section']); } public function testHandleAddressFormRefreshReturnsTechnicalErrorOnRuntimeException(): void @@ -88,6 +90,83 @@ private function createDisabledModule(): \Ps_Onepagecheckout { return new DisabledPsOnepagecheckoutModuleForAddressForm(); } + + private function createControllerContext(): \Context + { + $smarty = $this->getMockBuilder(\Smarty::class) + ->disableOriginalConstructor() + ->onlyMethods(['assign', 'getTemplateVars']) + ->getMock(); + $smarty + ->expects($this->once()) + ->method('assign') + ->with('customer', $this->callback(static function (array $customer): bool { + return array_key_exists('is_logged', $customer) + && array_key_exists('is_guest', $customer) + && array_key_exists('firstname', $customer) + && array_key_exists('lastname', $customer) + && array_key_exists('gender', $customer) + && array_key_exists('risk', $customer) + && array_key_exists('addresses', $customer) + && $customer['is_logged'] === false + && $customer['is_guest'] === true + && $customer['firstname'] === 'Alice' + && $customer['lastname'] === 'Doe' + && is_array($customer['addresses']); + })) + ; + $smarty + ->method('getTemplateVars') + ->with('customer') + ->willReturn([]) + ; + + $context = new class extends \Context { + public function __construct() + { + } + }; + $context->smarty = $smarty; + $context->customer = new class extends \Customer { + public function __construct() + { + } + + public function getSimpleAddresses($idLang = null) + { + return [ + [ + 'id' => 0, + 'alias' => 'Home', + ], + ]; + } + + public function isGuest() + { + return true; + } + + public function isLogged($withGuest = false) + { + return false; + } + }; + $context->customer->id = 42; + $context->customer->firstname = 'Alice'; + $context->customer->lastname = 'Doe'; + $context->customer->id_gender = 0; + $context->customer->id_risk = 0; + $context->customer->is_guest = true; + $context->language = new class extends \Language { + public function __construct() + { + } + }; + $context->language->id = 1; + + return $context; + } } class TestAddressFormController extends \Ps_OnepagecheckoutAddressFormModuleFrontController @@ -105,6 +184,12 @@ public function callHandleAddressFormRefresh(): array return $this->handleAddressFormRefresh(); } + public function setTestContext(\Context $context): void + { + $this->context = $context; + $this->objectPresenter = new ObjectPresenter(); + } + protected function createAddressFormHandler(OnePageCheckoutFormFactory $opcFormFactory): OnePageCheckoutAddressFormHandler { if ($this->throwOnCreateHandler) { diff --git a/tests/php/Unit/Controller/AddressesListControllerTest.php b/tests/php/Unit/Controller/AddressesListControllerTest.php new file mode 100644 index 0000000..ed4035d --- /dev/null +++ b/tests/php/Unit/Controller/AddressesListControllerTest.php @@ -0,0 +1,45 @@ +handleAddressesList(); + } + + protected function buildTechnicalErrorResponse(): array + { + return ['success' => false, 'error' => 'technical-error']; + } + }; + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + self::assertSame('technical-error', $controller->callHandleAddressesList()['error']); + } +} diff --git a/tests/php/Unit/Controller/AdminPsOnePageCheckoutControllerTest.php b/tests/php/Unit/Controller/AdminPsOnePageCheckoutControllerTest.php index 733f42c..f0d49d1 100644 --- a/tests/php/Unit/Controller/AdminPsOnePageCheckoutControllerTest.php +++ b/tests/php/Unit/Controller/AdminPsOnePageCheckoutControllerTest.php @@ -37,10 +37,29 @@ public function testItReturnsEmptyStringWhenModuleIsNotPsOnepagecheckout(): void self::assertSame('', $controller->callGetBackOfficeConfigurationContent()); } + + public function testViewAccessRequiresLegacyViewAndModuleConfigurePermission(): void + { + $controller = new TestAdminPsOnePageCheckoutController(); + $controller->legacyViewAccess = true; + $controller->moduleConfigurePermission = true; + + self::assertTrue($controller->viewAccess()); + + $controller->moduleConfigurePermission = false; + self::assertFalse($controller->viewAccess()); + + $controller->legacyViewAccess = false; + $controller->moduleConfigurePermission = true; + self::assertFalse($controller->viewAccess()); + } } class TestAdminPsOnePageCheckoutController extends \AdminPsOnePageCheckoutController { + public bool $legacyViewAccess = true; + public bool $moduleConfigurePermission = true; + public function __construct() { } @@ -49,4 +68,14 @@ public function callGetBackOfficeConfigurationContent(): string { return $this->getBackOfficeConfigurationContent(); } + + protected function hasLegacyViewAccess(bool $disable = false): bool + { + return $this->legacyViewAccess; + } + + protected function hasModuleConfigurePermission(): bool + { + return $this->moduleConfigurePermission; + } } diff --git a/tests/php/Unit/Controller/CarriersControllerTest.php b/tests/php/Unit/Controller/CarriersControllerTest.php new file mode 100644 index 0000000..5b0964f --- /dev/null +++ b/tests/php/Unit/Controller/CarriersControllerTest.php @@ -0,0 +1,45 @@ +handleCarriers(); + } + + protected function buildTechnicalErrorResponse(): array + { + return ['success' => false, 'error' => 'technical-error']; + } + }; + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + self::assertSame('technical-error', $controller->callHandleCarriers()['error']); + } +} diff --git a/tests/php/Unit/Controller/PaymentMethodsControllerTest.php b/tests/php/Unit/Controller/PaymentMethodsControllerTest.php new file mode 100644 index 0000000..766ee4c --- /dev/null +++ b/tests/php/Unit/Controller/PaymentMethodsControllerTest.php @@ -0,0 +1,50 @@ +context = $context; + } + + public function __construct() + { + } + + public function callHandlePaymentMethods(): array + { + return $this->handlePaymentMethods(); + } + + protected function buildTechnicalErrorResponse(): array + { + return ['success' => false, 'error' => 'technical-error']; + } + }; + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + self::assertSame('technical-error', $controller->callHandlePaymentMethods()['error']); + } +} diff --git a/tests/php/Unit/Controller/SaveAddressControllerTest.php b/tests/php/Unit/Controller/SaveAddressControllerTest.php new file mode 100644 index 0000000..f492eb3 --- /dev/null +++ b/tests/php/Unit/Controller/SaveAddressControllerTest.php @@ -0,0 +1,45 @@ +handleSaveAddress(); + } + + protected function buildTechnicalErrorResponse(): array + { + return ['success' => false, 'error' => 'technical-error']; + } + }; + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + self::assertSame('technical-error', $controller->callHandleSaveAddress()['error']); + } +} diff --git a/tests/php/Unit/Controller/SelectCarrierControllerTest.php b/tests/php/Unit/Controller/SelectCarrierControllerTest.php new file mode 100644 index 0000000..176ea2f --- /dev/null +++ b/tests/php/Unit/Controller/SelectCarrierControllerTest.php @@ -0,0 +1,45 @@ +handleSelectCarrier(); + } + + protected function buildTechnicalErrorResponse(): array + { + return ['success' => false, 'error' => 'technical-error']; + } + }; + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + self::assertSame('technical-error', $controller->callHandleSelectCarrier()['error']); + } +} diff --git a/tests/php/Unit/Controller/SelectPaymentControllerTest.php b/tests/php/Unit/Controller/SelectPaymentControllerTest.php new file mode 100644 index 0000000..598656b --- /dev/null +++ b/tests/php/Unit/Controller/SelectPaymentControllerTest.php @@ -0,0 +1,45 @@ +handleSelectPayment(); + } + + protected function buildTechnicalErrorResponse(): array + { + return ['success' => false, 'error' => 'technical-error']; + } + }; + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + self::assertSame('technical-error', $controller->callHandleSelectPayment()['error']); + } +} diff --git a/tests/php/Unit/Controller/StatesControllerTest.php b/tests/php/Unit/Controller/StatesControllerTest.php new file mode 100644 index 0000000..e723b28 --- /dev/null +++ b/tests/php/Unit/Controller/StatesControllerTest.php @@ -0,0 +1,45 @@ +handleStates(); + } + + protected function buildTechnicalErrorResponse(): array + { + return ['success' => false, 'error' => 'technical-error']; + } + }; + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + self::assertSame('technical-error', $controller->callHandleStates()['error']); + } +} diff --git a/tests/php/Unit/Form/BackOfficeConfigurationFormTest.php b/tests/php/Unit/Form/BackOfficeConfigurationFormTest.php index 822841e..6d6759c 100644 --- a/tests/php/Unit/Form/BackOfficeConfigurationFormTest.php +++ b/tests/php/Unit/Form/BackOfficeConfigurationFormTest.php @@ -14,6 +14,34 @@ class BackOfficeConfigurationFormTest extends TestCase { + /** + * @var array + */ + private array $backupPost = []; + + protected function setUp(): void + { + parent::setUp(); + + $this->backupPost = $_POST; + + $context = \Context::getContext(); + $context->link = new class { + public function getAdminLink(string $controller, bool $withToken = true, array $params = [], array $extraParams = []): string + { + return '/admin/index.php?controller=' . $controller; + } + }; + } + + protected function tearDown(): void + { + $_POST = $this->backupPost; + \Shop::setContext(\Shop::CONTEXT_ALL); + + parent::tearDown(); + } + public function testItPersistsConfigurationValueWhenEnabled(): void { $form = new SpyBackOfficeConfigurationForm($this->createMock(\Module::class), 'PS_ONE_PAGE_CHECKOUT_ENABLED'); @@ -42,6 +70,21 @@ public function testItLoadsCurrentConfigurationValue(): void self::assertSame(1, $value); self::assertSame(1, $form->readConfigurationCalls); } + + public function testItDoesNotPersistSubmittedValueOutsideSingleShopContext(): void + { + $_POST['submitPsOnePageCheckoutConfiguration'] = '1'; + $_POST['PS_ONE_PAGE_CHECKOUT_ENABLED'] = '1'; + \Shop::setContext(\Shop::CONTEXT_ALL); + + $form = new SpyBackOfficeConfigurationForm($this->createMock(\Module::class), 'PS_ONE_PAGE_CHECKOUT_ENABLED'); + + $content = $form->renderBackOfficeConfiguration(); + + self::assertSame('', $content); + self::assertSame([], $form->updatedConfigurationValues); + self::assertSame(1, $form->redirectCalls); + } } class SpyBackOfficeConfigurationForm extends BackOfficeConfigurationForm @@ -52,6 +95,7 @@ class SpyBackOfficeConfigurationForm extends BackOfficeConfigurationForm public array $updatedConfigurationValues = []; public int $readConfigurationCalls = 0; + public int $redirectCalls = 0; private int $nextReadValue = 0; @@ -81,4 +125,9 @@ protected function readConfigurationValue(): int return $this->nextReadValue; } + + protected function redirectToConfigurationForm(): void + { + ++$this->redirectCalls; + } } diff --git a/tests/php/Unit/Form/OnePageCheckoutFormSpe7FinalSubmitTest.php b/tests/php/Unit/Form/OnePageCheckoutFormSpe7FinalSubmitTest.php new file mode 100644 index 0000000..aaa7d80 --- /dev/null +++ b/tests/php/Unit/Form/OnePageCheckoutFormSpe7FinalSubmitTest.php @@ -0,0 +1,11 @@ +formatter = $this->getMockBuilder(OnePageCheckoutFormatter::class) ->disableOriginalConstructor() - ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry']) + ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry', 'getFieldGroup']) ->getMock() ; $this->formatter @@ -64,6 +64,10 @@ public function __construct() ->method('setInvoiceCountry') ->willReturnSelf() ; + $this->formatter + ->method('getFieldGroup') + ->willReturnCallback([$this, 'getFieldGroupForTest']) + ; $this->customerPersister = $this->getMockBuilder(\CustomerPersister::class) ->disableOriginalConstructor() @@ -224,6 +228,7 @@ public function testGuestInitDoesNotCallModuleCustomerValidation(): void 'email' => 'guest-no-module-validation@example.com', 'psgdpr_privacy' => '1', 'compliance_terms' => '1', + 'communication_channel' => 'email', ]))); self::assertFalse($form->wasModuleValidationCalled()); self::assertEmpty($form->getField('compliance_note')->getErrors()); @@ -263,12 +268,11 @@ public function testItDoesNotCreateGuestCustomerWhenRequiredRadioConsentIsMissin $form = $this->createForm(); $this->customerPersister - ->expects($this->once()) + ->expects($this->never()) ->method('save') - ->willReturn(true) ; - self::assertTrue($form->submitGuestInit($this->withDefaultCountry([ + self::assertFalse($form->submitGuestInit($this->withDefaultCountry([ 'email' => 'guest-radio-missing@example.com', 'psgdpr_privacy' => '1', 'compliance_terms' => '1', @@ -277,7 +281,7 @@ public function testItDoesNotCreateGuestCustomerWhenRequiredRadioConsentIsMissin $errors = $form->getErrors(); self::assertArrayHasKey('communication_channel', $errors); - self::assertEmpty($errors['communication_channel']); + self::assertNotEmpty($errors['communication_channel']); } public function testGuestInitIgnoresRequiredAddressConsentFields(): void @@ -295,6 +299,7 @@ public function testGuestInitIgnoresRequiredAddressConsentFields(): void 'psgdpr_privacy' => '1', 'compliance_terms' => '1', 'compliance_note' => 'Ready', + 'communication_channel' => 'email', 'marketing_preferences' => '0', ]))); } @@ -314,6 +319,7 @@ public function testGuestInitIgnoresFinalSubmitVeto(): void 'psgdpr_privacy' => '1', 'compliance_terms' => '1', 'compliance_note' => 'Ready', + 'communication_channel' => 'email', ]))); } @@ -367,6 +373,29 @@ public function testItKeepsRealNamesForGuestWhenFillingFromCustomer(): void public function testItSeparatesTemplateVariablesByBusinessOrigin(): void { + $customerProbeText = (new \FormField()) + ->setName('opcinvariantprobe_customer_text') + ->setType('text'); + $customerProbeSelect = (new \FormField()) + ->setName('opcinvariantprobe_customer_select') + ->setType('select'); + + $customerProbeTextarea = (new \FormField()) + ->setName('opcinvariantprobe_customer_textarea') + ->setType('textarea'); + + $customerProbeCheckbox = (new \FormField()) + ->setName('opcinvariantprobe_customer_checkbox') + ->setType('checkbox'); + + $customerProbeRadio = (new \FormField()) + ->setName('opcinvariantprobe_customer_radio') + ->setType('radio-buttons'); + + $addressProbeCheckbox = (new \FormField()) + ->setName('opcinvariantprobe_address_checkbox') + ->setType('checkbox'); + $form = $this->createForm(); $form->setFormFieldsForTest([ 'email' => (new \FormField()) @@ -375,27 +404,15 @@ public function testItSeparatesTemplateVariablesByBusinessOrigin(): void 'optin' => (new \FormField()) ->setName('optin') ->setType('checkbox'), - 'customer_probe_text' => (new \FormField()) - ->setName('opcinvariantprobe_customer_text') - ->setType('text'), - 'customer_probe_select' => (new \FormField()) - ->setName('opcinvariantprobe_customer_select') - ->setType('select'), - 'customer_probe_textarea' => (new \FormField()) - ->setName('opcinvariantprobe_customer_textarea') - ->setType('textarea'), - 'customer_probe_checkbox' => (new \FormField()) - ->setName('opcinvariantprobe_customer_checkbox') - ->setType('checkbox'), - 'customer_probe_radio' => (new \FormField()) - ->setName('opcinvariantprobe_customer_radio') - ->setType('radio-buttons'), + 'customer_probe_text' => $customerProbeText, + 'customer_probe_select' => $customerProbeSelect, + 'customer_probe_textarea' => $customerProbeTextarea, + 'customer_probe_checkbox' => $customerProbeCheckbox, + 'customer_probe_radio' => $customerProbeRadio, 'firstname' => (new \FormField()) ->setName('firstname') ->setType('text'), - 'opcinvariantprobe_address_checkbox' => (new \FormField()) - ->setName('opcinvariantprobe_address_checkbox') - ->setType('checkbox'), + 'opcinvariantprobe_address_checkbox' => $addressProbeCheckbox, 'invoice_address1' => (new \FormField()) ->setName('invoice_address1') ->setType('text'), @@ -426,17 +443,20 @@ public function testItSeparatesTemplateVariablesByBusinessOrigin(): void ], array_keys($templateVariables['formFields']) ); - self::assertArrayNotHasKey('contactFields', $templateVariables); - self::assertArrayNotHasKey('additionalCustomerFields', $templateVariables); - self::assertArrayNotHasKey('useSameAddressField', $templateVariables); - self::assertArrayNotHasKey('deliveryFields', $templateVariables); - self::assertArrayNotHasKey('invoiceFields', $templateVariables); - self::assertArrayNotHasKey('invoiceMetaFields', $templateVariables); + self::assertArrayHasKey('contactFields', $templateVariables); + self::assertArrayHasKey('additionalCustomerFields', $templateVariables); + self::assertArrayHasKey('useSameAddressField', $templateVariables); + self::assertArrayHasKey('deliveryFields', $templateVariables); + self::assertArrayHasKey('invoiceFields', $templateVariables); + self::assertArrayHasKey('invoiceMetaFields', $templateVariables); + self::assertArrayHasKey('token', $templateVariables); self::assertSame('email', $templateVariables['formFields']['email']['name']); - self::assertSame('opcinvariantprobe_customer_text', $templateVariables['formFields']['customer_probe_text']['name']); + self::assertSame('email', $templateVariables['contactFields']['email']['name']); + self::assertSame('opcinvariantprobe_customer_text', $templateVariables['additionalCustomerFields']['customer_probe_text']['name']); self::assertSame('use_same_address', $templateVariables['formFields']['use_same_address']['name']); - self::assertSame('invoice_address1', $templateVariables['formFields']['invoice_address1']['name']); - self::assertSame('id_address_invoice', $templateVariables['formFields']['id_address_invoice']['name']); + self::assertSame('firstname', $templateVariables['deliveryFields']['firstname']['name']); + self::assertSame('invoice_address1', $templateVariables['invoiceFields']['invoice_address1']['name']); + self::assertSame('id_address_invoice', $templateVariables['invoiceMetaFields']['id_address_invoice']['name']); } public function testSubmitPersistsDeliveryAndInvoiceAddressesWhenUseSameAddressIsDisabled(): void @@ -445,7 +465,7 @@ public function testSubmitPersistsDeliveryAndInvoiceAddressesWhenUseSameAddressI $form->forceValidateResult(true); $this->context->cart = new LightweightCart(); - $this->context->cart->id = 0; + $this->context->cart->id = -1; $this->customerPersister ->expects($this->once()) @@ -513,7 +533,7 @@ public function testSubmitPersistsOnlyDeliveryAddressWhenUseSameAddressIsEnabled $form->forceValidateResult(true); $this->context->cart = new LightweightCart(); - $this->context->cart->id = 0; + $this->context->cart->id = -1; $this->customerPersister ->expects($this->once()) @@ -558,6 +578,130 @@ public function testSubmitPersistsOnlyDeliveryAddressWhenUseSameAddressIsEnabled ], $result); } + public function testSubmitUsesConnectedCustomerEmailWhenCheckoutPostOmitsIt(): void + { + $form = $this->createSubmitForm(); + + $this->context->customer = new LightweightCustomer(); + $this->context->customer->id = 77; + $this->context->customer->is_guest = 0; + $this->context->customer->email = 'registered@example.com'; + $this->context->cart = new LightweightCart(); + $this->context->cart->id = -1; + $this->context->cart->id_customer = 77; + + $this->customerPersister + ->expects($this->never()) + ->method('save') + ; + + $this->addressPersister + ->expects($this->once()) + ->method('save') + ->willReturnCallback(static function (\Address $address): bool { + $address->id = 404; + + return true; + }) + ; + + $form->fillWith($this->withDefaultCountry([ + 'firstname' => 'Spec', + 'lastname' => 'FortyTwo', + 'address1' => '4 Registered street', + 'city' => 'Nantes', + 'postcode' => '44000', + 'psgdpr_privacy' => '1', + 'compliance_terms' => '1', + 'communication_channel' => 'email', + 'use_same_address' => '1', + ])); + + $result = $form->submit(); + + self::assertSame([ + 'id_address_delivery' => 404, + 'id_address_invoice' => 404, + ], $result); + self::assertSame('registered@example.com', (string) $form->getValue('email')); + } + + public function testFillWithKeepsSelectedDeliveryAndInvoiceCountriesWithoutManualOverride(): void + { + $language = new class extends \Language { + public function __construct() + { + } + }; + $language->id = 1; + + $formatter = $this->getMockBuilder(OnePageCheckoutFormatter::class) + ->disableOriginalConstructor() + ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry']) + ->getMock() + ; + + $formatter + ->method('getFormat') + ->willReturn([ + 'id_country' => (new \FormField()) + ->setName('id_country') + ->setType('select') + ->setRequired(true) + ->setAvailableValues([ + ['id' => 8, 'label' => 'France'], + ['id' => 21, 'label' => 'Belgium'], + ]), + 'invoice_id_country' => (new \FormField()) + ->setName('invoice_id_country') + ->setType('select') + ->setRequired(false) + ->setAvailableValues([ + ['id' => 8, 'label' => 'France'], + ['id' => 21, 'label' => 'Belgium'], + ]), + ]) + ; + + $defaultCountry = new class extends \Country { + public function __construct() + { + } + }; + $defaultCountry->id = self::DEFAULT_COUNTRY_ID; + + $formatter + ->method('getCountry') + ->willReturn($defaultCountry) + ; + $formatter + ->method('setCountry') + ->willReturnSelf() + ; + $formatter + ->method('setInvoiceCountry') + ->willReturnSelf() + ; + + $form = new TestableOnePageCheckoutForm( + $this->createMock(\Smarty::class), + $this->context, + $language, + $this->translator, + $formatter, + $this->customerPersister, + $this->addressPersister + ); + + $form->fillWith([ + 'id_country' => '21', + 'invoice_id_country' => '21', + ]); + + self::assertSame('21', (string) $form->getValue('id_country')); + self::assertSame('21', (string) $form->getValue('invoice_id_country')); + } + /** * @return \FormField[] */ @@ -731,7 +875,7 @@ public function __construct() $submitFormatter = $this->getMockBuilder(OnePageCheckoutFormatter::class) ->disableOriginalConstructor() - ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry']) + ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry', 'getFieldGroup']) ->getMock() ; $submitFormatter @@ -758,6 +902,10 @@ public function __construct() ->method('setInvoiceCountry') ->willReturnSelf() ; + $submitFormatter + ->method('getFieldGroup') + ->willReturnCallback([$this, 'getFieldGroupForTest']) + ; return new TestableOnePageCheckoutForm( $this->createMock(\Smarty::class), @@ -770,6 +918,13 @@ public function __construct() ); } + public function getFieldGroupForTest(string $key): ?string + { + return in_array($key, ['compliance_terms', 'compliance_note', 'communication_channel', 'newsletter_optin'], true) + ? OnePageCheckoutFormatter::FIELD_GROUP_CUSTOMER + : null; + } + /** * @param array $params * diff --git a/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php b/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php new file mode 100644 index 0000000..bf0b557 --- /dev/null +++ b/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php @@ -0,0 +1,27 @@ +javascriptDefinitions[0]); self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['guestInit']); self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['addressForm']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['addressesList']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['states']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['saveAddress']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['deleteAddress']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['carriers']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['selectCarrier']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['paymentMethods']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['selectPayment']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['i18n']['deleteAddressConfirmTitle']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['i18n']['deleteAddressConfirmMessage']); } public function testHookActionFrontControllerSetMediaAssignsFlagAndSkipsAssetsWhenDisabled(): void @@ -271,6 +281,15 @@ public function registerHook($hookName, $shopList = null): bool return $this->registerHookResult; } + public function trans( + $id, + array $parameters = [], + $domain = null, + $locale = null, + ): string { + return (string) $id; + } + protected function createCheckoutProcessBuilder(): OnePageCheckoutProcessBuilder { if ($this->checkoutProcessBuilder instanceof OnePageCheckoutProcessBuilder) { diff --git a/tests/php/bootstrap-autoload.php b/tests/php/bootstrap-autoload.php index 89857de..de03902 100644 --- a/tests/php/bootstrap-autoload.php +++ b/tests/php/bootstrap-autoload.php @@ -39,6 +39,30 @@ $loader->addClassMap([ 'Ps_Onepagecheckout' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/ps_onepagecheckout.php', 'AdminPsOnePageCheckoutController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/admin/AdminPsOnePageCheckoutController.php', - 'Ps_OnepagecheckoutGuestInitModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/GuestInit.php', - 'Ps_OnepagecheckoutAddressFormModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/AddressForm.php', + 'Ps_OnepagecheckoutGuestInitModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/guestinit.php', + 'Ps_OnepagecheckoutAddressFormModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/addressform.php', + 'Ps_OnepagecheckoutAddressesListModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/addresseslist.php', + 'Ps_OnepagecheckoutStatesModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/states.php', + 'Ps_OnepagecheckoutSaveAddressModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/saveaddress.php', + 'Ps_OnepagecheckoutDeleteAddressModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/deleteaddress.php', + 'Ps_OnepagecheckoutCarriersModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/carriers.php', + 'Ps_OnepagecheckoutSelectCarrierModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/selectcarrier.php', + 'Ps_OnepagecheckoutPaymentMethodsModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/paymentmethods.php', + 'Ps_OnepagecheckoutSelectPaymentModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/selectpayment.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\CheckoutAjaxResponse' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Shared/CheckoutAjaxResponse.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\CartPresenterHelper' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Shared/CartPresenterHelper.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\CheckoutSessionFactory' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Shared/CheckoutSessionFactory.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\CheckoutCustomerContextResolver' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Customer/CheckoutCustomerContextResolver.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutGuestInitHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutAddressFormHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutAddressesListHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutSaveAddressHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutDeleteAddressHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutStatesHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Address/OnePageCheckoutStatesHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OpcTempAddress' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Address/OpcTempAddress.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutCarriersHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutSelectCarrierHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\TempAddressCarrierSelectionStorage' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Carrier/TempAddressCarrierSelectionStorage.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutPaymentMethodsHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Payment/OnePageCheckoutPaymentMethodsHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\OnePageCheckoutSelectPaymentHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php', ]); diff --git a/tests/php/bootstrap-unit.php b/tests/php/bootstrap-unit.php index 65467ae..318c0bb 100644 --- a/tests/php/bootstrap-unit.php +++ b/tests/php/bootstrap-unit.php @@ -1,5 +1,18 @@ bind('\\PrestaShop\\PrestaShop\\Adapter\\EntityMapper', new LegacyEntityMapper(), true); +$legacyTestContainer->bind('\\PrestaShop\\PrestaShop\\Core\\ConfigurationInterface', new LegacyConfiguration(), true); +$legacyTestContainer->bind('\\PrestaShop\\PrestaShop\\Adapter\\AddressFactory', new AddressFactory(), true); + +ServiceLocator::setServiceContainerInstance($legacyTestContainer); diff --git a/views/js/events.js b/views/js/events.js new file mode 100644 index 0000000..dc23457 --- /dev/null +++ b/views/js/events.js @@ -0,0 +1,19 @@ +const OPC_EVENTS = { + opcCarrierSelected: 'opcCarrierSelected', + opcCarriersUpdated: 'opcCarriersUpdated', + opcCarriersFailed: 'opcCarriersFailed', + opcCarriersLoading: 'opcCarriersLoading', + opcPaymentMethodsLoading: 'opcPaymentMethodsLoading', + opcPaymentMethodsUpdated: 'opcPaymentMethodsUpdated', + opcPaymentMethodsFailed: 'opcPaymentMethodsFailed', + opcPaymentMethodSelected: 'opcPaymentMethodSelected', + opcGuestInitSuccess: 'opcGuestInitSuccess', + opcFinalSubmitStarted: 'opcFinalSubmitStarted', + opcDeliveryAddressUpdated: 'opcDeliveryAddressUpdated', + opcBillingAddressUpdated: 'opcBillingAddressUpdated', + opcCartSummaryBeforeUpdate: 'opcCartSummaryBeforeUpdate', + opcCartSummaryUpdated: 'opcCartSummaryUpdated', + updatedOpcAddressForm: 'updatedOpcAddressForm', +}; + +export default OPC_EVENTS; diff --git a/views/js/opc-address-modal.js b/views/js/opc-address-modal.js new file mode 100644 index 0000000..70e28c3 --- /dev/null +++ b/views/js/opc-address-modal.js @@ -0,0 +1,795 @@ +import OPC_EVENTS from './events'; +import OPC_SELECTORS from './selectors'; +import {getConfiguredOpcUrl, getOpcRuntimeConfiguration} from './runtime/opc-runtime'; + +/** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + */ +(function psOpcAddressModalRuntime() { +const $ = window.$ || window.jQuery; +const prestashop = window.prestashop || {}; + +if (!$) { + return; +} + +const MODAL_SELECTOR = OPC_SELECTORS.modals.address; +const OPEN_SELECTOR = '[data-opc-action="open-address-modal"], [data-bs-target="#modal-delivery"], [data-bs-target="#modal-invoice"]'; +const SAVE_SELECTOR = '#submit-address-modal, .js-opc-save-address'; +const COUNTRY_SELECTOR = '[name="id_country"], [name$="id_country"]'; +const STATE_SELECTOR = '[name="id_state"], [name$="id_state"]'; +const MODAL_SCOPES = MODAL_SELECTOR.split(',').map((selector) => selector.trim()); +const SAVE_TARGETS = SAVE_SELECTOR.split(',').map((selector) => selector.trim()); +const COUNTRY_TARGETS = COUNTRY_SELECTOR.split(',').map((selector) => selector.trim()); +const MODAL_SAVE_SELECTOR = MODAL_SCOPES.flatMap((modalSelector) => { + return SAVE_TARGETS.map((targetSelector) => `${modalSelector} ${targetSelector}`); +}).join(', '); +const MODAL_COUNTRY_SELECTOR = MODAL_SCOPES.flatMap((modalSelector) => { + return COUNTRY_TARGETS.map((targetSelector) => `${modalSelector} ${targetSelector}`); +}).join(', '); +const MODAL_FIELD_SELECTOR = MODAL_SCOPES.flatMap((modalSelector) => { + return ['input', 'select', 'textarea'].map((fieldSelector) => `${modalSelector} ${fieldSelector}`); +}).join(', '); +const URL_KEYS = { + addressesList: 'addressesList', + states: 'states', + saveAddress: 'saveAddress', + deleteAddress: 'deleteAddress', + addressForm: 'addressForm', +}; +const OPC_ADDRESSES_SECTION_SELECTOR = OPC_SELECTORS.opc.addressesSection; +const DELIVERY_SECTION_SELECTOR = OPC_SELECTORS.opc.deliverySection; +const DELIVERY_FIELDS_SELECTOR = OPC_SELECTORS.opc.deliveryFields; +const BILLING_SECTION_SELECTOR = OPC_SELECTORS.opc.billingSection; +const BILLING_FIELDS_SELECTOR = OPC_SELECTORS.opc.billingFields; +const DISABLED_BY_SAME_ADDRESS_ATTRIBUTE = 'data-opc-disabled-by-same-address'; + +const ADDRESS_FIELDS = [ + 'id_address', + 'alias', + 'firstname', + 'lastname', + 'company', + 'vat_number', + 'address1', + 'address2', + 'city', + 'postcode', + 'id_state', + 'id_country', + 'phone', +]; +const FIELD_ERROR_CLASS = 'js-opc-field-error'; +const GLOBAL_ERROR_CLASS = 'js-opc-address-modal-error'; +const DELETE_CONFIRM_MODAL_ID = 'opc-delete-address-confirm-modal'; + +function isNonSubmittableField($field) { + return $field.is(':button, [type="button"], [type="submit"], [type="reset"], [type="image"], [type="file"]'); +} + +function setModalFieldsDisabled($modal, disabled) { + $modal.find('input, select, textarea').each((_, field) => { + const $field = $(field); + + if ($field.is('[type="hidden"]') || isNonSubmittableField($field)) { + return; + } + + $field.prop('disabled', disabled); + }); +} + +function disableClosedModalFields() { + $(MODAL_SELECTOR).each((_, modal) => { + const $modal = $(modal); + + if (!$modal.hasClass('show')) { + setModalFieldsDisabled($modal, true); + } + }); +} + +function retriggerCheckoutValidation() { + const form = document.querySelector(OPC_SELECTORS.opc.checkout); + + if (!form) { + return; + } + + form.dispatchEvent(new Event('change', {bubbles: true})); +} + +function getOpcRuntimeI18n(key, fallback = '') { + const runtimeConfiguration = getOpcRuntimeConfiguration(); + + if (runtimeConfiguration && runtimeConfiguration.i18n && runtimeConfiguration.i18n[key]) { + return String(runtimeConfiguration.i18n[key]); + } + + return String(fallback); +} + +function getModalField($modal, fieldName) { + const $exactField = $modal.find(`[name="${fieldName}"]`).first(); + if ($exactField.length) { + return $exactField; + } + + return $modal.find(`[name$="${fieldName}"]`).first(); +} + +function getAddressSection($addressForm, sectionSelector, fieldsSelector) { + const $fields = $addressForm.find(fieldsSelector).first(); + if ($fields.length) { + return $fields; + } + + return $addressForm.find(sectionSelector).first(); +} + +function getAddressSectionFieldValue($addressForm, sectionSelector, fieldsSelector, fieldName) { + const $section = getAddressSection($addressForm, sectionSelector, fieldsSelector); + if (!$section.length) { + return ''; + } + + const $field = $section.find(`[name="${fieldName}"]`).first(); + if (!$field.length) { + return ''; + } + + return String($field.val() || ''); +} + +function setAddressSectionFieldValue($addressForm, sectionSelector, fieldsSelector, fieldName, value) { + if (typeof value === 'undefined' || value === null || String(value) === '') { + return; + } + + const $section = getAddressSection($addressForm, sectionSelector, fieldsSelector); + if (!$section.length) { + return; + } + + const $field = $section.find(`[name="${fieldName}"]`).first(); + if (!$field.length) { + return; + } + + $field.val(String(value)); +} + +function syncBillingSectionConstraints($addressForm, useSameAddress) { + const $billingSection = $addressForm.find(BILLING_SECTION_SELECTOR).first(); + + if (!$billingSection.length) { + return; + } + + $billingSection.toggle(!useSameAddress); + + $billingSection.find('input, select, textarea').each((_, field) => { + const $field = $(field); + + if (useSameAddress) { + if (!$field.prop('disabled')) { + $field.attr(DISABLED_BY_SAME_ADDRESS_ATTRIBUTE, '1'); + $field.prop('disabled', true); + } + + return; + } + + if ($field.attr(DISABLED_BY_SAME_ADDRESS_ATTRIBUTE) === '1') { + $field.prop('disabled', false); + $field.removeAttr(DISABLED_BY_SAME_ADDRESS_ATTRIBUTE); + } + }); +} + +function resetModalFields($modal) { + ADDRESS_FIELDS.forEach((fieldName) => { + if (fieldName === 'id_address') { + getModalField($modal, fieldName).val(''); + + return; + } + + const $field = getModalField($modal, fieldName); + if (!$field.length) { + return; + } + + if ($field.is('select')) { + $field.val(''); + + return; + } + + $field.val(''); + }); +} + +function clearValidationErrors($modal) { + $modal.find(`.${FIELD_ERROR_CLASS}`).remove(); + $modal.find(`.${GLOBAL_ERROR_CLASS}`).remove(); + $modal.find('.is-invalid').removeClass('is-invalid'); +} + +function isVisibleModalField(field) { + if (!(field instanceof HTMLElement)) { + return false; + } + + const computedStyle = window.getComputedStyle(field); + + return computedStyle.display !== 'none' + && computedStyle.visibility !== 'hidden' + && field.getClientRects().length > 0; +} + +function isModalFieldValid(field) { + if ( + !(field instanceof HTMLInputElement) + && !(field instanceof HTMLSelectElement) + && !(field instanceof HTMLTextAreaElement) + ) { + return true; + } + + if (field.disabled || !isVisibleModalField(field)) { + return true; + } + + return typeof field.checkValidity !== 'function' || field.checkValidity(); +} + +function updateModalSaveState($modal) { + const $saveButtons = $modal.find(SAVE_SELECTOR); + + if (!$saveButtons.length) { + return; + } + + const modalElement = $modal.get(0); + const isOpen = modalElement instanceof HTMLElement && $modal.hasClass('show'); + if (!isOpen) { + $saveButtons.prop('disabled', true); + + return; + } + + const isValid = $modal + .find('input, select, textarea') + .toArray() + .every((field) => isModalFieldValid(field)); + + $saveButtons.prop('disabled', !isValid); +} + +function updateModalTitle($modal, type) { + const $title = $modal.find('.modal-header h2').first(); + + if (!$title.length) { + return; + } + + const nextTitle = type === 'edit' + ? String($modal.attr('data-title-edit') || '') + : String($modal.attr('data-title-new') || ''); + + if (nextTitle !== '') { + $title.text(nextTitle); + } +} + +function populateForm($modal, address) { + if (!address || typeof address !== 'object') { + return; + } + + ADDRESS_FIELDS.forEach((fieldName) => { + const value = typeof address[fieldName] === 'undefined' ? '' : address[fieldName]; + const $field = getModalField($modal, fieldName); + + if (!$field.length) { + return; + } + + $field.val(value); + }); +} + +function getAddressFromTrigger($trigger) { + const address = {}; + + ADDRESS_FIELDS.forEach((fieldName) => { + const attributeValue = $trigger.attr(`data-${fieldName}`); + + if (typeof attributeValue !== 'undefined') { + address[fieldName] = attributeValue; + } + }); + + return address; +} + +function getModalType($trigger) { + return String($trigger.attr('data-type') || 'create'); +} + +function readStateUiTargets($modal) { + return { + $wrapper: $modal.find('.state-field-wrapper, #state-field-wrapper').first(), + $select: $modal.find(STATE_SELECTOR).first(), + $row: $modal.find('.address-country-row, #address-country-row').first(), + }; +} + +function updateStateFieldUi($modal, response, selectedStateId) { + const {$wrapper, $select, $row} = readStateUiTargets($modal); + + if (!$wrapper.length || !$select.length) { + return; + } + + const states = response && Array.isArray(response.states) ? response.states : []; + const hasStates = Boolean(response && response.hasStates) || states.length > 0; + + if (!hasStates) { + $wrapper.hide(); + $select.prop('required', false).val(''); + if ($row.length) { + $row.removeClass('form-fields-row--3').addClass('form-fields-row--2'); + } + + return; + } + + const placeholder = String($select.attr('data-select-placeholder') || ''); + $select.empty(); + $select.append($('