diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 624b0a4..a64f07f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -62,9 +62,9 @@ jobs: matrix: prestashop: # Keep the checkout ref and Docker image tag aligned when adding more supported versions. - - label: latest - image_tag: latest - core_ref: '' + - label: 9.1.0 + image_tag: 9.1.0 + core_ref: 9.1.0 steps: - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -74,15 +74,7 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Checkout PrestaShop core (default branch) - if: ${{ matrix.prestashop.core_ref == '' }} - uses: actions/checkout@v5 - with: - repository: PrestaShop/PrestaShop - path: prestashop-core - - name: Checkout PrestaShop core (${{ matrix.prestashop.core_ref }}) - if: ${{ matrix.prestashop.core_ref != '' }} uses: actions/checkout@v5 with: repository: PrestaShop/PrestaShop @@ -106,9 +98,9 @@ jobs: matrix: prestashop: # Keep the checkout ref and Docker image tag aligned when adding more supported versions. - - label: latest - image_tag: latest - core_ref: '' + - label: 9.1.0 + image_tag: 9.1.0 + core_ref: 9.1.0 steps: - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -118,15 +110,7 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Checkout PrestaShop core (default branch) - if: ${{ matrix.prestashop.core_ref == '' }} - uses: actions/checkout@v5 - with: - repository: PrestaShop/PrestaShop - path: prestashop-core - - name: Checkout PrestaShop core (${{ matrix.prestashop.core_ref }}) - if: ${{ matrix.prestashop.core_ref != '' }} uses: actions/checkout@v5 with: repository: PrestaShop/PrestaShop 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..18abc5e --- /dev/null +++ b/controllers/front/addresseslist.php @@ -0,0 +1,66 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + try { + $handler = new OnePageCheckoutAddressesListHandler( + $this->context, + new CheckoutCustomerContextResolver($this->context) + ); + $response = $handler->handle(); + if (empty($response['success'])) { + return $response; + } + + return [ + 'success' => true, + 'address_count' => (int) ($response['address_count'] ?? 0), + 'delivery_html' => $this->render( + 'checkout/_partials/one-page-checkout/address-list', + [ + 'customer' => $response['customer'] ?? [], + 'prefix' => '', + 'selected_address' => (int) ($response['selected_delivery_address'] ?? 0), + ] + ), + 'billing_html' => $this->render( + 'checkout/_partials/one-page-checkout/address-list', + [ + 'customer' => $response['customer'] ?? [], + 'prefix' => 'invoice_', + 'selected_address' => (int) ($response['selected_invoice_address'] ?? 0), + ] + ), + ]; + } catch (Throwable $exception) { + PrestaShopLogger::addLog( + sprintf('ps_onepagecheckout addressesList runtime exception: %s', $exception->getMessage()), + 3, + null, + 'Module', + (int) $this->module->id, + true + ); + + return $this->buildTechnicalErrorResponse(); + } + } +} diff --git a/controllers/front/AddressForm.php b/controllers/front/addressform.php similarity index 54% rename from controllers/front/AddressForm.php rename to controllers/front/addressform.php index 950bb09..aae215d 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(); } @@ -35,8 +27,8 @@ protected function handleAddressFormRefresh(): array $templateVariables = $handler->getTemplateVariables(Tools::getAllValues()); return [ - 'address_form' => $this->render( - 'checkout/_partials/one-page-checkout-form', + 'addresses_section' => $this->render( + 'checkout/_partials/one-page-checkout/addresses-section', $templateVariables ), ]; @@ -63,34 +55,10 @@ protected function getOpcFormFactory(): OnePageCheckoutFormFactory protected function createAddressFormHandler(OnePageCheckoutFormFactory $opcFormFactory): OnePageCheckoutAddressFormHandler { - return new OnePageCheckoutAddressFormHandler($opcFormFactory->create()); - } - - /** - * @return array - */ - protected function buildTechnicalErrorResponse(): array - { - return [ - 'success' => false, - 'errors' => [ - '' => [ - $this->trans('One-page checkout is currently unavailable.', [], 'Shop.Notifications.Error'), - ], - ], - ]; - } - - /** - * @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)); + return new OnePageCheckoutAddressFormHandler( + $opcFormFactory->create(), + $this->context, + new CheckoutCustomerContextResolver($this->context) + ); } } diff --git a/controllers/front/carriers.php b/controllers/front/carriers.php new file mode 100644 index 0000000..c5123fa --- /dev/null +++ b/controllers/front/carriers.php @@ -0,0 +1,47 @@ + + */ + 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; + } +} diff --git a/controllers/front/carttotals.php b/controllers/front/carttotals.php new file mode 100644 index 0000000..b763f04 --- /dev/null +++ b/controllers/front/carttotals.php @@ -0,0 +1,30 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $cartPresenterHelper = new CartPresenterHelper($this->context); + $cartPreview = $cartPresenterHelper->presentCart(); + + return [ + 'success' => true, + 'totals' => $cartPreview['totals'], + ]; + } +} diff --git a/controllers/front/deleteaddress.php b/controllers/front/deleteaddress.php new file mode 100644 index 0000000..af7b70f --- /dev/null +++ b/controllers/front/deleteaddress.php @@ -0,0 +1,31 @@ + + */ + 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()); + } +} diff --git a/controllers/front/GuestInit.php b/controllers/front/guestinit.php similarity index 63% rename from controllers/front/GuestInit.php rename to controllers/front/guestinit.php index 00035f7..7101770 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(); } @@ -69,32 +60,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/opcsubmit.php b/controllers/front/opcsubmit.php new file mode 100644 index 0000000..149f60e --- /dev/null +++ b/controllers/front/opcsubmit.php @@ -0,0 +1,91 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + header('HTTP/1.1 405 Method Not Allowed'); + header('Allow: POST'); + + return [ + 'success' => false, + 'reload' => true, + 'checkout_url' => $this->getCheckoutUrl(), + ]; + } + + try { + return $this->createSubmitHandler()->handle(Tools::getAllValues()); + } catch (Throwable $exception) { + PrestaShopLogger::addLog( + sprintf('ps_onepagecheckout opcSubmit runtime exception: %s', $exception->getMessage()), + 3, + null, + 'Module', + (int) $this->module->id, + true + ); + + return $this->buildTechnicalErrorResponse(); + } + } + + protected function createSubmitHandler(): OnePageCheckoutSubmitHandler + { + $module = $this->module; + if (!$module instanceof Ps_Onepagecheckout) { + throw new LogicException('ps_onepagecheckout module is not available.'); + } + + $translator = $module->getTranslator(); + $checkoutSessionFactory = new CheckoutSessionFactory($this->context, $translator); + $formFactory = new OnePageCheckoutFormFactory($this->context, $module); + + return new OnePageCheckoutSubmitHandler( + $this->context, + $checkoutSessionFactory, + new OnePageCheckoutSubmitProcessor( + $this->context, + $translator, + $formFactory->create(), + new PaymentOptionsFinder(), + new ConditionsToApproveFinder($this->context, $translator) + ), + new OnePageCheckoutSubmitValidationStateStorage($this->context) + ); + } + + /** + * @return array + */ + protected function getTechnicalErrorResponseExtra(): array + { + return [ + 'reload' => true, + 'checkout_url' => $this->getCheckoutUrl(), + ]; + } + + private function getCheckoutUrl(): string + { + return isset($this->context->link) + ? (string) $this->context->link->getPageLink('order') + : ''; + } +} diff --git a/controllers/front/paymentmethods.php b/controllers/front/paymentmethods.php new file mode 100644 index 0000000..4cb466e --- /dev/null +++ b/controllers/front/paymentmethods.php @@ -0,0 +1,53 @@ + + */ + 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(); + } + } +} diff --git a/controllers/front/saveaddress.php b/controllers/front/saveaddress.php new file mode 100644 index 0000000..065269e --- /dev/null +++ b/controllers/front/saveaddress.php @@ -0,0 +1,31 @@ + + */ + 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()); + } +} diff --git a/controllers/front/selectcarrier.php b/controllers/front/selectcarrier.php new file mode 100644 index 0000000..cc46882 --- /dev/null +++ b/controllers/front/selectcarrier.php @@ -0,0 +1,38 @@ + + */ + 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; + } +} diff --git a/controllers/front/selectpayment.php b/controllers/front/selectpayment.php new file mode 100644 index 0000000..1b6373b --- /dev/null +++ b/controllers/front/selectpayment.php @@ -0,0 +1,26 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutSelectPaymentHandler($this->context); + + return $handler->handle(Tools::getAllValues()); + } +} diff --git a/controllers/front/states.php b/controllers/front/states.php new file mode 100644 index 0000000..21ac8ce --- /dev/null +++ b/controllers/front/states.php @@ -0,0 +1,26 @@ + + */ + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + $handler = new OnePageCheckoutStatesHandler(); + + return $handler->handle(Tools::getAllValues()); + } +} 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..3069d97 100644 --- a/ps_onepagecheckout.php +++ b/ps_onepagecheckout.php @@ -56,7 +56,19 @@ 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', + 'opcsubmit', + ]; } public function install() @@ -129,7 +141,7 @@ public function hookActionFrontControllerSetMedia(): void 'urls' => [ 'guestInit' => $this->context->link->getModuleLink( $this->name, - 'GuestInit', + 'guestinit', ['ajax' => 1, 'action' => 'opcGuestInit'], null, null, @@ -138,13 +150,103 @@ 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 + ), + 'opcSubmit' => $this->context->link->getModuleLink( + $this->name, + 'opcsubmit', + ['ajax' => 1, 'action' => 'opcSubmit'], + null, + null, + null, + true + ), + 'cartTotals' => $this->context->link->getModuleLink( + $this->name, + 'carttotals', + ['ajax' => 1, 'action' => 'opcCartTotals'], + null, + null, + null, + true + ), ], ]; @@ -202,6 +304,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..d79edfc --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php @@ -0,0 +1,149 @@ +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 + { + $deliveryAddressId = (int) ($requestParameters['id_address_delivery'] ?? 0); + $ownedDeliveryAddress = null; + $customer = $this->customerResolver->resolve(); + $useSameAddress = array_key_exists('use_same_address', $requestParameters) + && (string) $requestParameters['use_same_address'] !== '0'; + + if ($customer instanceof \Customer) { + $this->opcForm->fillFromCustomer($customer); + } + + if ($deliveryAddressId > 0) { + $ownedDeliveryAddress = $this->loadOwnedAddress($deliveryAddressId); + if ($ownedDeliveryAddress instanceof \Address) { + $this->opcForm->fillFromAddress($ownedDeliveryAddress); + } + } + + $formParams = []; + + foreach (['id_country', 'invoice_id_country', 'use_same_address', 'id_address_delivery', 'id_address_invoice'] as $name) { + if (!isset($requestParameters[$name])) { + continue; + } + + if ($name === 'invoice_id_country' && $useSameAddress) { + continue; + } + + if (in_array($name, ['id_address_delivery', 'id_address_invoice'], true) && (int) $requestParameters[$name] <= 0) { + continue; + } + + if ($name === 'id_address_delivery' && !$ownedDeliveryAddress instanceof \Address) { + continue; + } + + if ($name === 'id_address_invoice' && !$this->isOwnedAddressId((int) $requestParameters[$name])) { + continue; + } + + $formParams[$name] = $requestParameters[$name]; + } + + if ( + $this->shouldFallbackToDefaultDeliveryCountry($customer, $requestParameters, $deliveryAddressId) + && !isset($formParams['id_country']) + ) { + $formParams['id_country'] = (string) \Configuration::get('PS_COUNTRY_DEFAULT'); + } + + if (!empty($formParams)) { + $this->opcForm->fillWith($formParams); + } + + return $this->buildAddressesSectionTemplateVariables(); + } + + /** + * @param array $requestParameters + */ + private function shouldFallbackToDefaultDeliveryCountry( + ?\Customer $customer, + array $requestParameters, + int $deliveryAddressId, + ): bool { + if (!$customer instanceof \Customer) { + return false; + } + + if ($deliveryAddressId > 0 || isset($requestParameters['id_country'])) { + return false; + } + + return count($customer->getSimpleAddresses((int) $this->context->language->id)) === 0; + } + + 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; + } + + /** + * The refreshed addresses partial needs the same top-level checkout variables + * as the initial step render, not just the form payload. + * + * @return array + */ + private function buildAddressesSectionTemplateVariables(): array + { + return $this->opcForm->getTemplateVariables() + [ + 'is_virtual_cart' => $this->context->cart->isVirtualCart(), + 'cart' => [ + 'id_address_delivery' => (int) ($this->context->cart->id_address_delivery ?? 0), + 'id_address_invoice' => (int) ($this->context->cart->id_address_invoice ?? 0), + ], + ]; + } +} diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php new file mode 100644 index 0000000..1d45ea1 --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php @@ -0,0 +1,44 @@ +context = $context; + $this->customerResolver = $customerResolver; + $this->customerTemplateBuilder = $customerTemplateBuilder ?? new CheckoutCustomerTemplateBuilder( + $context, + $customerResolver + ); + } + + /** + * @return array + */ + public function handle(): array + { + $customer = $this->customerResolver->resolve(); + if (!$customer instanceof \Customer) { + return CheckoutAjaxResponse::error('Unable to resolve checkout customer.'); + } + + $customerTemplate = $this->customerTemplateBuilder->build(); + + return [ + 'success' => true, + 'customer' => $customerTemplate, + 'address_count' => count($customerTemplate['addresses'] ?? []), + 'selected_delivery_address' => (int) ($this->context->cart->id_address_delivery ?? 0), + 'selected_invoice_address' => (int) ($this->context->cart->id_address_invoice ?? 0), + ]; + } +} 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..bad8047 --- /dev/null +++ b/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php @@ -0,0 +1,136 @@ +context = $context; + $this->translator = $translator; + $this->customerResolver = $customerResolver; + } + + /** + * @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['id_address'] ?? 0); + $address = $addressId > 0 ? 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.'); + } + + if (\Validate::isLoadedObject($this->context->cart)) { + if ($addressType === 'invoice') { + $this->context->cart->id_address_invoice = (int) $address->id; + } else { + $this->context->cart->id_address_delivery = (int) $address->id; + + if ((string) ($requestParameters['use_same_address'] ?? '1') !== '0') { + $this->context->cart->id_address_invoice = (int) $address->id; + } + } + + $this->context->cart->update(); + } + + return [ + 'success' => true, + ]; + } + + 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..a017dbe --- /dev/null +++ b/src/Checkout/Ajax/Customer/CheckoutCustomerContextResolver.php @@ -0,0 +1,50 @@ +context = $context; + } + + public function resolve(): ?\Customer + { + if (\Validate::isLoadedObject($this->context->customer) && (int) $this->context->customer->id > 0) { + return $this->context->customer; + } + + return $this->resolvePersistedCartOwner(); + } + + 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/Customer/CheckoutCustomerTemplateBuilder.php b/src/Checkout/Ajax/Customer/CheckoutCustomerTemplateBuilder.php new file mode 100644 index 0000000..92106bb --- /dev/null +++ b/src/Checkout/Ajax/Customer/CheckoutCustomerTemplateBuilder.php @@ -0,0 +1,74 @@ +context = $context; + $this->customerResolver = $customerResolver ?? new CheckoutCustomerContextResolver($context); + } + + /** + * @return array + */ + public function build(): array + { + $templateCustomer = $this->resolveTemplateCustomer(); + $customer = $this->customerResolver->resolve(); + + if (!$customer instanceof \Customer) { + return array_merge($templateCustomer, [ + 'is_logged' => false, + 'addresses' => [], + ]); + } + + return array_merge($templateCustomer, [ + 'id' => (int) $customer->id, + 'firstname' => (string) $customer->firstname, + 'lastname' => (string) $customer->lastname, + 'email' => (string) $customer->email, + 'is_guest' => (bool) $customer->isGuest(), + 'is_logged' => (bool) $customer->isLogged(), + 'addresses' => array_map( + [$this, 'normalizeAddress'], + (array) $customer->getSimpleAddresses((int) ($this->context->language->id ?? 0)) + ), + ]); + } + + /** + * @param array $address + * + * @return array + */ + private function normalizeAddress(array $address): array + { + if (!array_key_exists('id', $address) && array_key_exists('id_address', $address)) { + $address['id'] = $address['id_address']; + } + + return $address; + } + + /** + * @return array + */ + private function resolveTemplateCustomer(): array + { + if (!isset($this->context->smarty)) { + return []; + } + + $templateCustomer = $this->context->smarty->getTemplateVars('customer'); + + return is_array($templateCustomer) ? $templateCustomer : []; + } +} 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..5437f75 --- /dev/null +++ b/src/Checkout/Ajax/Payment/OnePageCheckoutPaymentMethodsHandler.php @@ -0,0 +1,261 @@ +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; + $resolvedCountry = null; + $taxAddressType = (string) \Configuration::get('PS_TAX_ADDRESS_TYPE'); + $addressIds = $this->resolveCountryAddressCandidates($taxAddressType, $requestParameters); + + foreach ($addressIds as $addressId) { + $resolvedCountry = $this->loadCountryFromAddressId($addressId); + if ($resolvedCountry instanceof \Country) { + break; + } + } + + if (!$resolvedCountry instanceof \Country) { + if ($taxAddressType === 'id_address_invoice') { + $resolvedCountryId = (int) ($requestParameters['invoice_id_country'] ?? 0) ?: (int) ($requestParameters['id_country'] ?? 0); + } else { + $resolvedCountryId = (int) ($requestParameters['id_country'] ?? 0) ?: (int) ($requestParameters['delivery_id_country'] ?? 0); + } + + if ($resolvedCountryId > 0) { + $candidateCountry = new \Country($resolvedCountryId); + if (\Validate::isLoadedObject($candidateCountry)) { + $resolvedCountry = $candidateCountry; + } + } + } + + $countryId = $resolvedCountry instanceof \Country + ? (int) $resolvedCountry->id + : (\Validate::isLoadedObject($this->context->country) ? (int) $this->context->country->id : 0); + + try { + if ($resolvedCountry instanceof \Country) { + $this->context->country = $resolvedCountry; + } + + $isFree = 0.0 == (float) $this->context->cart->getOrderTotal(true, \Cart::BOTH); + $paymentOptions = $this->paymentOptionsFinder->present($isFree); + $paymentOptions = $this->filterByCountry($paymentOptions, $countryId); + $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 $requestParameters + * + * @return int[] + */ + private function resolveCountryAddressCandidates(string $taxAddressType, array $requestParameters): array + { + $requestAddressKey = $taxAddressType === 'id_address_invoice' + ? 'id_address_invoice' + : 'id_address_delivery'; + $requestAddressId = (int) ($requestParameters[$requestAddressKey] ?? 0); + $cartAddressId = (int) ($this->context->cart->{$taxAddressType} ?? 0); + + $addressIds = []; + if ($requestAddressId > 0) { + $addressIds[] = $requestAddressId; + } + + if ($cartAddressId > 0 && $cartAddressId !== $requestAddressId) { + $addressIds[] = $cartAddressId; + } + + return $addressIds; + } + + private function loadCountryFromAddressId(int $addressId): ?\Country + { + if ($addressId <= 0) { + return null; + } + + $address = new \Address($addressId); + if (!\Validate::isLoadedObject($address) || (int) $address->id_country <= 0) { + return null; + } + + $candidateCountry = new \Country((int) $address->id_country); + if (!\Validate::isLoadedObject($candidateCountry)) { + return null; + } + + return $candidateCountry; + } + + /** + * @param array $paymentOptions + * + * @return array + */ + private function filterByCountry(array $paymentOptions, int $countryId): array + { + if ($paymentOptions === [] || $countryId <= 0) { + 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'] === $countryId) { + $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 + ); + } + + 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..482363d --- /dev/null +++ b/src/Checkout/Ajax/Shared/CartPresenterHelper.php @@ -0,0 +1,33 @@ +context = $context; + } + + public function presentCart(): CartLazyArray + { + $this->context->cart->resetProductRelatedStaticCache(); + \Cache::clean('presentedCart_*'); + + $cartLazyArray = (new CartPresenter())->present($this->context->cart, true); + + // CartLazyArray computes values on first access and caches them. + // Force tax-sensitive properties now so callers that restore a temporary + // delivery address in a finally block get correct tax-inclusive values. + $cartLazyArray->offsetGet('products'); + $cartLazyArray->offsetGet('subtotals'); + $cartLazyArray->offsetGet('totals'); + + return $cartLazyArray; + } +} 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/Ajax/Submit/OnePageCheckoutSubmitHandler.php b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitHandler.php new file mode 100644 index 0000000..50bf820 --- /dev/null +++ b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitHandler.php @@ -0,0 +1,79 @@ +context = $context; + $this->checkoutSessionFactory = $checkoutSessionFactory; + $this->submitProcessor = $submitProcessor; + $this->submitValidationStateStorage = $submitValidationStateStorage; + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters): array + { + $checkoutSession = $this->checkoutSessionFactory->create(); + $this->persistSelectedPaymentModule($requestParameters); + $processingResult = $this->submitProcessor->process($checkoutSession, $requestParameters); + if (($processingResult['success'] ?? false) === true) { + $this->submitValidationStateStorage->clear(); + + return [ + 'success' => true, + 'reload' => false, + 'checkout_url' => $checkoutSession->getCheckoutURL(), + ]; + } + + $this->submitValidationStateStorage->save([ + 'validation_errors' => isset($processingResult['validation_errors']) && is_array($processingResult['validation_errors']) + ? $processingResult['validation_errors'] + : [], + 'form_errors' => isset($processingResult['form_errors']) && is_array($processingResult['form_errors']) + ? $processingResult['form_errors'] + : [], + 'submitted_values' => isset($processingResult['submitted_values']) && is_array($processingResult['submitted_values']) + ? $processingResult['submitted_values'] + : [], + ]); + + return [ + 'success' => false, + 'reload' => true, + 'checkout_url' => $checkoutSession->getCheckoutURL(), + ]; + } + + /** + * @param array $requestParameters + */ + private function persistSelectedPaymentModule(array $requestParameters): void + { + $paymentMethod = isset($requestParameters['paymentMethod']) ? trim((string) $requestParameters['paymentMethod']) : ''; + + if ($paymentMethod === '' || !isset($this->context->cookie)) { + return; + } + + $this->context->cookie->__set('opc_selected_payment_module', $paymentMethod); + $this->context->cookie->write(); + } +} diff --git a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php new file mode 100644 index 0000000..97ff9e8 --- /dev/null +++ b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php @@ -0,0 +1,424 @@ + + */ + private array $validationErrors = []; + + public function __construct( + \Context $context, + TranslatorInterface $translator, + OnePageCheckoutForm $opcForm, + \PaymentOptionsFinder $paymentOptionsFinder, + \ConditionsToApproveFinder $conditionsToApproveFinder, + ?PaymentSelectionKeyBuilder $paymentSelectionKeyBuilder = null, + ) { + $this->context = $context; + $this->translator = $translator; + $this->opcForm = $opcForm; + $this->paymentOptionsFinder = $paymentOptionsFinder; + $this->conditionsToApproveFinder = $conditionsToApproveFinder; + $this->paymentSelectionKeyBuilder = $paymentSelectionKeyBuilder ?? new PaymentSelectionKeyBuilder(); + } + + /** + * @param \CheckoutSession $checkoutSession + * @param array $requestParameters + * + * @return array + */ + public function process(\CheckoutSession $checkoutSession, array $requestParameters): array + { + $this->validationErrors = []; + $normalizedRequestParameters = $this->normalizeSubmittedRequestParameters($requestParameters); + + $this->hydrateOpcFromSubmittedAddresses($normalizedRequestParameters); + + $validationResult = $this->validateAllSections($checkoutSession, $normalizedRequestParameters); + if (!$this->isAllSectionsValid($validationResult)) { + return $this->buildFailureResult($normalizedRequestParameters); + } + + if (!$this->saveAllSections($checkoutSession, $normalizedRequestParameters)) { + return $this->buildFailureResult($normalizedRequestParameters); + } + + return [ + 'success' => true, + 'validation_errors' => [], + 'form_errors' => [], + 'submitted_values' => [], + ]; + } + + /** + * Browser form submits omit unchecked checkboxes, but OPC always needs an explicit + * same/different-address flag during the final submit flow. + * + * @param array $requestParameters + * + * @return array + */ + private function normalizeSubmittedRequestParameters(array $requestParameters): array + { + if (!array_key_exists('use_same_address', $requestParameters)) { + $requestParameters['use_same_address'] = $this->context->cart->isVirtualCart() ? '1' : '0'; + } + + return $requestParameters; + } + + /** + * @param array $requestParameters + */ + private function hydrateOpcFromSubmittedAddresses(array $requestParameters): void + { + $deliveryAddressId = (int) ($requestParameters['id_address_delivery'] ?? 0); + $invoiceAddressId = (int) ($requestParameters['id_address_invoice'] ?? 0); + $customerId = (int) ($this->context->customer->id ?? 0); + + $this->fillOpcFormFromResolvedAddresses($deliveryAddressId, $invoiceAddressId, $customerId); + } + + /** + * @param array $requestParameters + * + * @return array + */ + private function validateAllSections(\CheckoutSession $checkoutSession, array $requestParameters): array + { + return [ + 'identity' => $this->validateIdentitySection($requestParameters), + 'address' => $this->validateAddressSection($requestParameters), + 'shipping' => $this->validateShippingSection($checkoutSession, $requestParameters), + 'payment' => $this->validatePaymentSection($checkoutSession, $requestParameters), + 'conditions' => $this->validateConditionsSection($requestParameters), + ]; + } + + /** + * @param array $requestParameters + */ + private function validateIdentitySection(array $requestParameters): bool + { + $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)) { + return true; + } + + $this->validationErrors['identity'] = [ + 'email' => $this->translator->trans( + 'Invalid email format.', + [], + 'Shop.Notifications.Error' + ), + ]; + + return false; + } + + /** + * @param array $requestParameters + */ + private function validateAddressSection(array $requestParameters): bool + { + $this->opcForm->fillWith($requestParameters); + + if ($this->opcForm->validate()) { + return true; + } + + $this->validationErrors['address'] = $this->opcForm->getErrors(); + + return false; + } + + /** + * @param array $requestParameters + */ + private function validateShippingSection(\CheckoutSession $checkoutSession, array $requestParameters): bool + { + if ($this->context->cart->isVirtualCart()) { + return true; + } + + $deliveryOptions = $checkoutSession->getDeliveryOptions(); + $deliveryOptionKey = isset($requestParameters['delivery_option']) && is_string($requestParameters['delivery_option']) + ? $requestParameters['delivery_option'] + : (string) $checkoutSession->getSelectedDeliveryOption(); + + if ($deliveryOptionKey === '' || empty($deliveryOptions[$deliveryOptionKey])) { + $this->validationErrors['shipping'] = [ + 'delivery_option' => $this->translator->trans( + 'Please select a shipping method.', + [], + 'Shop.Notifications.Error' + ), + ]; + + return false; + } + + $currentDeliveryOption = $deliveryOptions[$deliveryOptionKey]; + $isComplete = true; + + if (!empty($currentDeliveryOption['is_module']) && !empty($currentDeliveryOption['external_module_name'])) { + \Hook::exec( + 'actionValidateStepComplete', + [ + 'step_name' => 'delivery', + 'request_params' => $requestParameters, + 'completed' => &$isComplete, + ], + \Module::getModuleIdByName($currentDeliveryOption['external_module_name']) + ); + } + + return $isComplete; + } + + /** + * @param array $requestParameters + */ + private function validatePaymentSection(\CheckoutSession $checkoutSession, array $requestParameters): bool + { + $isFree = 0 == (float) $checkoutSession->getCart()->getOrderTotal(true, \Cart::BOTH); + $paymentOptions = $this->paymentSelectionKeyBuilder->enrichPaymentOptions( + (array) $this->paymentOptionsFinder->present($isFree) + ); + $selectedPaymentModule = trim((string) ($requestParameters['paymentMethod'] ?? $this->getSelectedPaymentModule())); + + if ($isFree || empty($paymentOptions) || $selectedPaymentModule !== '') { + return true; + } + + $this->validationErrors['payment'] = [ + 'paymentMethod' => $this->translator->trans( + 'Please select a payment method.', + [], + 'Shop.Notifications.Error' + ), + ]; + + return false; + } + + /** + * @param array $requestParameters + */ + private function validateConditionsSection(array $requestParameters): bool + { + $conditionsToApprove = (array) $this->conditionsToApproveFinder->getConditionsToApproveForTemplate(); + $submittedConditions = $requestParameters['conditions_to_approve'] ?? []; + + if (!is_array($submittedConditions)) { + $submittedConditions = []; + } + + foreach (array_keys($conditionsToApprove) as $conditionIdentifier) { + if (!empty($submittedConditions[$conditionIdentifier])) { + continue; + } + + $this->validationErrors['conditions'] = [ + 'conditions_to_approve' => $this->translator->trans( + 'Please accept the terms of service.', + [], + 'Shop.Notifications.Error' + ), + ]; + + return false; + } + + return true; + } + + /** + * @param array $validationResult + */ + private function isAllSectionsValid(array $validationResult): bool + { + return $validationResult['identity'] + && $validationResult['address'] + && $validationResult['shipping'] + && $validationResult['payment'] + && $validationResult['conditions']; + } + + /** + * @param array $requestParameters + */ + private function saveAllSections(\CheckoutSession $checkoutSession, array $requestParameters): bool + { + $customer = $this->context->customer; + $isGuestFlow = !$customer->isLogged() || $customer->isGuest(); + if ($isGuestFlow) { + $hookResult = array_reduce( + \Hook::exec('actionSubmitAccountBefore', [], null, true), + static function ($carry, $item) { + return $carry && $item; + }, + true + ); + if (!$hookResult) { + return false; + } + } + + $addressIds = $this->opcForm->fillWith($requestParameters)->submit(); + if (!$addressIds) { + $this->validationErrors['address'] = $this->opcForm->getErrors(); + + return false; + } + + $checkoutSession->setIdAddressDelivery($addressIds['id_address_delivery']); + $checkoutSession->setIdAddressInvoice($addressIds['id_address_invoice']); + + if ( + !$this->context->cart->isVirtualCart() + && isset($requestParameters['delivery_option']) + && is_string($requestParameters['delivery_option']) + && $requestParameters['delivery_option'] !== '' + ) { + $checkoutSession->setDeliveryOption([ + (int) $addressIds['id_address_delivery'] => $requestParameters['delivery_option'], + ]); + } + + if (isset($requestParameters['delivery_message'])) { + $checkoutSession->setMessage($requestParameters['delivery_message']); + } + + if ((bool) \Configuration::get('PS_RECYCLABLE_PACK')) { + $checkoutSession->setRecyclable($requestParameters['recyclable'] ?? false); + } + + if ((bool) \Configuration::get('PS_GIFT_WRAPPING')) { + $useGift = $requestParameters['gift'] ?? false; + $checkoutSession->setGift( + $useGift, + $useGift ? ($requestParameters['gift_message'] ?? '') : '' + ); + } + + $customer = $checkoutSession->getCustomer(); + if ($customer->isGuest() || empty($customer->firstname) || empty($customer->lastname)) { + $address = new \Address($addressIds['id_address_delivery'], (int) $this->context->language->id); + if ($address->id && (!empty($address->firstname) || !empty($address->lastname))) { + $customer->firstname = $address->firstname; + $customer->lastname = $address->lastname; + $customer->save(); + $this->context->updateCustomer($customer); + } + } + + \Hook::exec('actionCarrierProcess', ['cart' => $checkoutSession->getCart()]); + + return true; + } + + /** + * @param array $submittedValues + * + * @return array + */ + private function buildFailureResult(array $submittedValues): array + { + return [ + 'success' => false, + 'validation_errors' => $this->validationErrors, + 'form_errors' => $this->opcForm->getErrors(), + 'submitted_values' => $this->filterPersistedSubmittedValues($submittedValues), + ]; + } + + /** + * @param array $submittedValues + * + * @return array + */ + private function filterPersistedSubmittedValues(array $submittedValues): array + { + unset( + $submittedValues['ajax'], + $submittedValues['action'], + $submittedValues['static_token'], + $submittedValues['token'], + $submittedValues['paymentMethod'] + ); + + return array_filter( + $submittedValues, + static fn ($value): bool => is_scalar($value) || is_array($value) || $value === null + ); + } + + private function getSelectedPaymentModule(): string + { + if (!isset($this->context->cookie)) { + return ''; + } + + return (string) ($this->context->cookie->__get('opc_selected_payment_module') ?: ''); + } + + private function fillOpcFormFromResolvedAddresses(int $deliveryAddressId, int $invoiceAddressId, ?int $customerId = null): void + { + if ($deliveryAddressId <= 0 && $invoiceAddressId <= 0) { + return; + } + + $languageId = (int) $this->context->language->id; + $deliveryAddress = $this->resolveAddressForHydration($deliveryAddressId, $languageId, $customerId); + $invoiceAddress = null; + + if ($invoiceAddressId > 0 && $invoiceAddressId !== $deliveryAddressId) { + $invoiceAddress = $this->resolveAddressForHydration($invoiceAddressId, $languageId, $customerId); + } + + if ($deliveryAddress || $invoiceAddress) { + $this->opcForm->fillFromAddresses($deliveryAddress, $invoiceAddress); + } + } + + private function resolveAddressForHydration(int $addressId, int $languageId, ?int $customerId = null): ?\Address + { + if ( + $addressId <= 0 + || ($customerId !== null && ($customerId <= 0 || !\Customer::customerHasAddress($customerId, $addressId))) + ) { + return null; + } + + $address = new \Address($addressId, $languageId); + + return \Validate::isLoadedObject($address) ? $address : null; + } +} diff --git a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php new file mode 100644 index 0000000..e50527a --- /dev/null +++ b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php @@ -0,0 +1,81 @@ +context = $context; + } + + /** + * @param array $state + */ + public function save(array $state): void + { + if (!$this->hasCookie()) { + return; + } + + $encodedState = json_encode($state); + if ($encodedState === false) { + return; + } + + $this->context->cookie->__set(self::COOKIE_KEY, $encodedState); + $this->writeCookie(); + } + + /** + * @return array + */ + public function consume(): array + { + if (!$this->hasCookie()) { + return []; + } + + $rawState = (string) ($this->context->cookie->__get(self::COOKIE_KEY) ?: ''); + $this->clear(); + + if ($rawState === '') { + return []; + } + + $decodedState = json_decode($rawState, true); + + return is_array($decodedState) ? $decodedState : []; + } + + public function clear(): void + { + if (!$this->hasCookie()) { + return; + } + + if (method_exists($this->context->cookie, '__unset')) { + $this->context->cookie->__unset(self::COOKIE_KEY); + } else { + $this->context->cookie->__set(self::COOKIE_KEY, ''); + } + + $this->writeCookie(); + } + + private function hasCookie(): bool + { + return isset($this->context->cookie); + } + + private function writeCookie(): void + { + if (method_exists($this->context->cookie, 'write')) { + $this->context->cookie->write(); + } + } +} diff --git a/src/Checkout/CheckoutOnePageStep.php b/src/Checkout/CheckoutOnePageStep.php index 37d31f4..3bbe6ce 100644 --- a/src/Checkout/CheckoutOnePageStep.php +++ b/src/Checkout/CheckoutOnePageStep.php @@ -27,10 +27,9 @@ namespace PrestaShop\Module\PsOnePageCheckout\Checkout; -use Address; +use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\Submit\OnePageCheckoutSubmitValidationStateStorage; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutForm; use Symfony\Contracts\Translation\TranslatorInterface; -use Validate; class CheckoutOnePageStep extends \AbstractCheckoutStep { @@ -46,6 +45,8 @@ class CheckoutOnePageStep extends \AbstractCheckoutStep */ public $paymentOptionsFinder; + private PaymentSelectionKeyBuilder $paymentSelectionKeyBuilder; + /** * @var \ConditionsToApproveFinder */ @@ -59,6 +60,8 @@ class CheckoutOnePageStep extends \AbstractCheckoutStep private $displayTaxesLabel = false; private $validationErrors = []; + private bool $clearPersistedValidationErrorsOnNextSave = false; + private OnePageCheckoutSubmitValidationStateStorage $submitValidationStateStorage; /** * @param \Context $context @@ -66,6 +69,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 +77,15 @@ public function __construct( OnePageCheckoutForm $opcForm, \PaymentOptionsFinder $paymentOptionsFinder, \ConditionsToApproveFinder $conditionsToApproveFinder, + ?PaymentSelectionKeyBuilder $paymentSelectionKeyBuilder = null, + ?OnePageCheckoutSubmitValidationStateStorage $submitValidationStateStorage = null, ) { parent::__construct($context, $translator); $this->opcForm = $opcForm; $this->paymentOptionsFinder = $paymentOptionsFinder; $this->conditionsToApproveFinder = $conditionsToApproveFinder; + $this->paymentSelectionKeyBuilder = $paymentSelectionKeyBuilder ?? new PaymentSelectionKeyBuilder(); + $this->submitValidationStateStorage = $submitValidationStateStorage ?? new OnePageCheckoutSubmitValidationStateStorage($context); } // Delivery options setters (like CheckoutDeliveryStep) @@ -146,14 +154,15 @@ public function handleRequest(array $requestParameters = []) // Step is always reachable (single step) $this->setReachable(true); - // Pre-fill form from session if not submitting - if (!isset($requestParameters['submitOnePageCheckout'])) { - $this->hydrateOpcFromSession(); - } + $this->hydrateOpcFromSession(); + $this->restoreLastFailedSubmitState(); - // Handle submission - if (isset($requestParameters['submitOnePageCheckout'])) { - $this->handleOnePageCheckoutSubmit($requestParameters); + if ( + !$this->context->cart->isVirtualCart() + && isset($requestParameters['delivery_option']) + && is_array($requestParameters['delivery_option']) + ) { + $this->getCheckoutSession()->setDeliveryOption($requestParameters['delivery_option']); } $this->setTitle( @@ -174,169 +183,65 @@ private function hydrateOpcFromSession(): void $this->opcForm->fillFromCustomer($customer); } - $languageId = $this->context->language->id; - - $idAddressDelivery = $session->getIdAddressDelivery(); - $idAddressInvoice = $session->getIdAddressInvoice(); - - $deliveryAddress = $idAddressDelivery ? new \Address($idAddressDelivery, $languageId) : null; - - $invoiceAddress = null; - if ($idAddressInvoice && $idAddressInvoice != $idAddressDelivery) { - $invoiceAddress = new \Address($idAddressInvoice, $languageId); - } - - if ($deliveryAddress || $invoiceAddress) { - $this->opcForm->fillFromAddresses($deliveryAddress, $invoiceAddress); - } - } - - private function handleOnePageCheckoutSubmit(array $requestParameters): void - { - $validationResult = $this->validateAllSections($requestParameters); - if (!$this->isAllSectionsValid($validationResult)) { - $this->getCheckoutProcess()->setHasErrors(true); - $this->setCurrent(true); - - return; - } - if (!$this->saveAllSections($requestParameters)) { - $this->getCheckoutProcess()->setHasErrors(true); - - return; - } - $this->setComplete(true); + $this->fillOpcFormFromResolvedAddresses( + (int) $session->getIdAddressDelivery(), + (int) $session->getIdAddressInvoice() + ); } /** - * Validate all sections (5 validations) - * - * @param array $requestParameters + * Get validation errors * - * @return array ['identity' => bool, 'address' => bool, 'shipping' => bool, 'payment' => bool, 'conditions' => bool] + * @return array */ - private function validateAllSections(array $requestParameters) + public function getValidationErrors() { - $this->validationErrors = []; - $result = [ - 'identity' => true, - 'address' => true, - 'shipping' => true, - 'payment' => true, - 'conditions' => true, - ]; - - // 1. Identity validation: email only - if (empty($requestParameters['email']) || !\Validate::isEmail($requestParameters['email'])) { - $result['identity'] = false; - $this->validationErrors['identity'] = [ - 'email' => $this->getTranslator()->trans( - 'Invalid email format.', - [], - 'Shop.Notifications.Error' - ), - ]; - } - - // 2. Address validation: validate form - $this->opcForm->fillWith($requestParameters); - if (!$this->opcForm->validate()) { - $result['address'] = false; - $this->validationErrors['address'] = $this->opcForm->getErrors(); - } - - return $result; + return $this->validationErrors; } /** - * Check if all sections are valid - * - * @param array $validationResult - * - * @return bool + * @return array */ - private function isAllSectionsValid(array $validationResult) + public function getDataToPersist() { - return $validationResult['identity'] - && $validationResult['address'] - && $validationResult['shipping'] - && $validationResult['payment'] - && $validationResult['conditions']; + // This step still lives inside the Core CheckoutProcess injected by the module hook, + // so it must keep the standard AbstractCheckoutStep persistence contract. + return [ + 'validation_errors' => $this->clearPersistedValidationErrorsOnNextSave ? [] : $this->validationErrors, + ]; } /** - * Save all sections (5 saves) - * - * @param array $requestParameters + * Keep step identifier stable to avoid persistence regressions in checkout_session_data. * - * @return bool + * @return string */ - private function saveAllSections(array $requestParameters) + public function getIdentifier() { - // 1. Identity + Address: save via form (creates/updates customer guest and addresses) - $customer = $this->context->customer; - $isGuestFlow = !$customer->isLogged() || $customer->isGuest(); - if ($isGuestFlow) { - $hookResult = array_reduce( - \Hook::exec('actionSubmitAccountBefore', [], null, true), - function ($carry, $item) { - return $carry && $item; - }, - true - ); - if (!$hookResult) { - return false; - } - } - - $addressIds = $this->opcForm->fillWith($requestParameters)->submit(); - if (!$addressIds) { - return false; - } - - // Set addresses in session - $this->getCheckoutSession()->setIdAddressDelivery($addressIds['id_address_delivery']); - $this->getCheckoutSession()->setIdAddressInvoice($addressIds['id_address_invoice']); - - // Sync customer name from delivery address if needed - $customer = $this->getCheckoutSession()->getCustomer(); - if ($customer && ($customer->isGuest() || empty($customer->firstname) || empty($customer->lastname))) { - $address = new \Address($addressIds['id_address_delivery'], $this->context->language->id); - if ($address->id && (!empty($address->firstname) || !empty($address->lastname))) { - $customer->firstname = $address->firstname; - $customer->lastname = $address->lastname; - $customer->save(); - $this->context->updateCustomer($customer); - } - } - - return true; + return 'checkout-one-page-step'; } /** - * Get validation errors + * @param array $data * - * @return array + * @return $this */ - public function getValidationErrors() + public function restorePersistedData(array $data) { - return $this->validationErrors; - } + $this->validationErrors = isset($data['validation_errors']) && is_array($data['validation_errors']) + ? $data['validation_errors'] + : []; + $this->clearPersistedValidationErrorsOnNextSave = !empty($this->validationErrors); - /** - * Keep step identifier stable to avoid persistence regressions in checkout_session_data. - * - * @return string - */ - public function getIdentifier() - { - return 'checkout-one-page-step'; + return $this; } 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,15 +257,18 @@ 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, + 'is_free' => $isFree, + 'selected_payment_module' => $this->getSelectedPaymentModule(), + 'selected_payment_selection_key' => $this->getSelectedPaymentSelectionKey(), 'conditions_to_approve' => $conditionsToApprove, 'validation_errors' => $this->validationErrors, + 'validation_error_messages' => $this->getValidationErrorMessages(), 'recyclable' => $this->getCheckoutSession()->isRecyclable(), 'recyclablePackAllowed' => $this->isRecyclablePackAllowed(), 'delivery_message' => $this->getCheckoutSession()->getMessage(), @@ -370,8 +278,122 @@ 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 restoreLastFailedSubmitState(): void + { + $submitState = $this->submitValidationStateStorage->consume(); + if ($submitState === []) { + return; + } + + $submittedValues = isset($submitState['submitted_values']) && is_array($submitState['submitted_values']) + ? $submitState['submitted_values'] + : []; + $formErrors = isset($submitState['form_errors']) && is_array($submitState['form_errors']) + ? $submitState['form_errors'] + : []; + + if ($submittedValues !== [] || $formErrors !== []) { + $this->opcForm->restoreSubmissionState($submittedValues, $formErrors); + } + + $this->validationErrors = isset($submitState['validation_errors']) && is_array($submitState['validation_errors']) + ? $submitState['validation_errors'] + : []; + + if ($this->validationErrors !== [] || $formErrors !== []) { + $this->clearPersistedValidationErrorsOnNextSave = true; + } + } + + /** + * @return array + */ + private function getValidationErrorMessages(): array + { + $messages = []; + + array_walk_recursive($this->validationErrors, static function ($value) use (&$messages): void { + if (!is_string($value) || $value === '') { + return; + } + + $messages[] = $value; + }); + + return array_values(array_unique($messages)); + } + + private function fillOpcFormFromResolvedAddresses(int $deliveryAddressId, int $invoiceAddressId, ?int $customerId = null): void + { + if ($deliveryAddressId <= 0 && $invoiceAddressId <= 0) { + return; + } + + $languageId = (int) $this->context->language->id; + $deliveryAddress = $this->resolveAddressForHydration($deliveryAddressId, $languageId, $customerId); + $invoiceAddress = null; + + if ($invoiceAddressId > 0 && $invoiceAddressId !== $deliveryAddressId) { + $invoiceAddress = $this->resolveAddressForHydration($invoiceAddressId, $languageId, $customerId); + } + + if ($deliveryAddress || $invoiceAddress) { + $this->opcForm->fillFromAddresses($deliveryAddress, $invoiceAddress); + } + } + + private function resolveAddressForHydration(int $addressId, int $languageId, ?int $customerId = null): ?\Address + { + if ( + $addressId <= 0 + || ($customerId !== null && ($customerId <= 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/AddressFormValidationTrait.php b/src/Form/AddressFormValidationTrait.php index 1c7b45d..905ceff 100644 --- a/src/Form/AddressFormValidationTrait.php +++ b/src/Form/AddressFormValidationTrait.php @@ -46,26 +46,22 @@ protected function validateAddressPostcode( } if ($invoicePostcode && $invoicePostcode->isRequired()) { - $invoiceCountry = $country; + $invoiceValidationCountry = $country; if ($invoiceCountryField && $invoiceCountryField->getValue()) { - $invoiceCountry = new \Country( + $invoiceValidationCountry = new \Country( (int) $invoiceCountryField->getValue(), $this->language->id ); } - if ($invoiceCountry) { - if ($invoiceCountry->need_zip_code && !$invoiceCountry->checkZipCode($invoicePostcode->getValue())) { - $invoicePostcode->addError($this->translator->trans( - 'Invalid postcode - should look like "%zipcode%"', - ['%zipcode%' => $invoiceCountry->zip_code_format], - 'Shop.Forms.Errors' - )); - $is_valid = false; - } - } elseif ($country && $country->checkZipCode($invoicePostcode->getValue()) === false) { + + if ( + $invoiceValidationCountry + && $invoiceValidationCountry->need_zip_code + && !$invoiceValidationCountry->checkZipCode($invoicePostcode->getValue()) + ) { $invoicePostcode->addError($this->translator->trans( 'Invalid postcode - should look like "%zipcode%"', - ['%zipcode%' => $country->zip_code_format], + ['%zipcode%' => $invoiceValidationCountry->zip_code_format], 'Shop.Forms.Errors' )); $is_valid = false; 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..69803dc --- /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('', false) + ) + ); + + $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..bf7c290 100644 --- a/src/Form/OnePageCheckoutForm.php +++ b/src/Form/OnePageCheckoutForm.php @@ -29,9 +29,10 @@ use Address; use Cart; -use Context; use Country; use Customer; +use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerContextResolver; +use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerTemplateBuilder; use PrestaShop\PrestaShop\Core\Util\InternationalizedDomainNameConverter; use Symfony\Contracts\Translation\TranslatorInterface; use Validate; @@ -40,7 +41,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 +115,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) @@ -139,7 +144,7 @@ public function fillFromAddress(\Address $address) { $this->address = $address; $params = get_object_vars($address); - $params['id_address'] = $address->id; + $params['id_address_delivery'] = $address->id; return $this->fillWith($params); } @@ -161,7 +166,7 @@ public function fillFromAddresses(?\Address $deliveryAddress = null, ?\Address $ $params[$key] = $value; } } - $params['id_address'] = $deliveryAddress->id; + $params['id_address_delivery'] = $deliveryAddress->id; } $deliveryAddressId = $deliveryAddress ? $deliveryAddress->id : 0; @@ -180,6 +185,31 @@ public function fillFromAddresses(?\Address $deliveryAddress = null, ?\Address $ return $this->fillWith($params); } + /** + * Restore the last failed submit state after the AJAX submit redirects back to /order. + * + * @param array $params + * @param array $errors + */ + public function restoreSubmissionState(array $params, array $errors): self + { + $this->fillWith($params); + $this->errors = ['' => is_array($errors[''] ?? null) ? $errors[''] : []]; + + foreach ($errors as $fieldName => $fieldErrors) { + if ($fieldName === '' || !is_array($fieldErrors)) { + continue; + } + + $field = $this->getField((string) $fieldName); + if ($field) { + $field->setErrors($fieldErrors); + } + } + + return $this; + } + public function validate() { // When use_same_address, invoice fields are optional (skipped) @@ -210,6 +240,10 @@ public function validate() $is_valid = $this->validateAddressFormHook(); } + if ($is_valid) { + $this->validateCustomerFieldsByModules(); + } + return $is_valid && parent::validate(); } @@ -224,13 +258,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,38 +279,63 @@ 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->id_customer = $customer->id; - if (empty($deliveryAddress->alias)) { - $deliveryAddress->alias = $this->translator->trans('My Address', [], 'Shop.Theme.Checkout'); - } - \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$deliveryAddress]); - if (!$this->addressPersister->save($deliveryAddress, $token)) { - return false; + $deliveryAddress = $this->resolveSelectedCustomerAddress( + $this->resolveSelectedDeliveryAddressId(), + (int) $customer->id + ); + + if ($deliveryAddress instanceof \Address) { + $idAddressDelivery = (int) $deliveryAddress->id; + } else { + $deliveryAddress = $this->buildAddressFromGroup( + $fieldsByGroup['deliveryFields'], + null + ); + $deliveryAddress->id_customer = $customer->id; + if (empty($deliveryAddress->alias)) { + $deliveryAddress->alias = $this->translator->trans('My Address', [], 'Shop.Theme.Checkout'); + } + \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$deliveryAddress]); + if (!$this->addressPersister->save($deliveryAddress, $token)) { + return false; + } + + $idAddressDelivery = (int) $deliveryAddress->id; } - $idAddressDelivery = $deliveryAddress->id; $idAddressInvoice = $idAddressDelivery; // Create/update invoice address if different if (!$useSameAddress) { $idAddressInvoice = (int) \Tools::getValue('id_address_invoice'); - $invoiceAddress = $this->buildAddressFromFields('invoice_', $idAddressInvoice ?: null); - $invoiceAddress->id_customer = $customer->id; - $invoiceAddress->alias = $invoiceAddress->alias ?: $this->translator->trans( - 'Invoice address', - [], - 'Shop.Theme.Checkout' - ); - \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$invoiceAddress]); - if (!$this->addressPersister->save($invoiceAddress, $token)) { - return false; + $invoiceAddress = $this->resolveSelectedCustomerAddress($idAddressInvoice, (int) $customer->id); + + if ($invoiceAddress instanceof \Address) { + $idAddressInvoice = (int) $invoiceAddress->id; + } else { + $invoiceAddress = $this->buildAddressFromGroup( + $fieldsByGroup['invoiceFields'], + null, + 'invoice_' + ); + $invoiceAddress->id_customer = $customer->id; + $invoiceAddress->alias = $invoiceAddress->alias ?: $this->translator->trans( + 'Invoice address', + [], + 'Shop.Theme.Checkout' + ); + \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$invoiceAddress]); + if (!$this->addressPersister->save($invoiceAddress, $token)) { + return false; + } + + $idAddressInvoice = (int) $invoiceAddress->id; } - $idAddressInvoice = $invoiceAddress->id; } return [ @@ -288,38 +345,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 + { + $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 ((int) $this->context->customer->id > 0 || !\Validate::isLoadedObject($this->context->cart)) { + 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 +434,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 +453,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 +569,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 +593,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 +605,9 @@ public function getCustomer() */ public function getAddress() { - return $this->buildAddressFromFields('', \Tools::getValue('id_address')); + $fieldsByGroup = $this->mapFieldsByGroup(); + + return $this->buildAddressFromGroup($fieldsByGroup['deliveryFields'], $this->resolveSelectedDeliveryAddressId()); } /** @@ -496,28 +617,269 @@ 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(), + 'customer' => $this->buildCheckoutCustomerTemplateVariables(), '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 buildCheckoutCustomerTemplateVariables(): array + { + return (new CheckoutCustomerTemplateBuilder( + $this->context, + new CheckoutCustomerContextResolver($this->context) + ))->build(); + } + + private function resolveSelectedDeliveryAddressId(): ?int + { + $selectedDeliveryAddressId = (int) \Tools::getValue('id_address_delivery'); + + return $selectedDeliveryAddressId > 0 ? $selectedDeliveryAddressId : null; + } + + private function resolveSelectedCustomerAddress(?int $addressId, int $customerId): ?\Address + { + if ($addressId === null || $addressId <= 0 || $customerId <= 0) { + return null; + } + + $address = new \Address($addressId, (int) $this->language->id); + + if (!\Validate::isLoadedObject($address) || (int) $address->id_customer !== $customerId) { + return null; + } + + return $address; + } + + /** + * @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..38c33b2 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,24 +159,31 @@ public function getFormat() ) ->setValue(true); + // Hidden field to preserve delivery address ID when editing + $format['id_address_delivery'] = (new \FormField()) + ->setName('id_address_delivery') + ->setType('hidden'); + // Hidden field to preserve invoice address ID when editing $format['id_address_invoice'] = (new \FormField()) ->setName('id_address_invoice') ->setType('hidden'); // Delivery address fields - $format = array_merge($format, $this->getAddressFieldsFormat('', true)); + $format = array_merge($format, $this->getAddressFieldsFormat('', false)); // Invoice address fields (prefixed with invoice_) if ($this->invoiceCountry !== null) { $deliveryCountry = $this->getCountry(); $this->setCountry($this->invoiceCountry); - $format = array_merge($format, $this->getAddressFieldsFormat('invoice_', true)); + $format = array_merge($format, $this->getAddressFieldsFormat('invoice_', false)); $this->setCountry($deliveryCountry); } else { - $format = array_merge($format, $this->getAddressFieldsFormat('invoice_', true)); + $format = array_merge($format, $this->getAddressFieldsFormat('invoice_', false)); } + $format = $this->sortAddressFields($format); + // Add constraints and max length $format = $this->addConstraints( $this->addMaxLength($format) @@ -184,8 +201,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 +223,44 @@ protected function getDefinitionKey($name) { return strpos($name, 'invoice_') === 0 ? substr($name, 8) : $name; } + + /** + * @param array $fields + * + * @return array + */ + protected function sortAddressFields(array $fields): array + { + $customOrder = [ + 'id_country' => 1, + 'alias' => 2, + 'firstname' => 3, + 'lastname' => 4, + 'company' => 5, + 'vat_number' => 6, + 'address1' => 7, + 'address2' => 8, + 'city' => 9, + 'postcode' => 10, + 'id_state' => 11, + 'phone' => 12, + ]; + + uksort($fields, static function (string $keyA, string $keyB) use ($customOrder): int { + $baseA = str_replace('invoice_', '', $keyA); + $baseB = str_replace('invoice_', '', $keyB); + + $positionA = $customOrder[$baseA] ?? 999; + $positionB = $customOrder[$baseB] ?? 999; + + return $positionA <=> $positionB; + }); + + return $fields; + } + + public function getFieldGroup(string $key): ?string + { + return $this->fieldGroups[$key] ?? null; + } } diff --git a/tests/php/Fixtures/CheckoutTestFixtures.php b/tests/php/Fixtures/CheckoutTestFixtures.php new file mode 100644 index 0000000..60011e6 --- /dev/null +++ b/tests/php/Fixtures/CheckoutTestFixtures.php @@ -0,0 +1,232 @@ + $value) { + $context->{$property} = $value; + } + + return $context; + } + + public static function language(int $id = 1): \Language + { + $language = new LanguageStub(); + $language->id = $id; + + return $language; + } + + /** + * @param array $overrides + */ + public static function country(array $overrides = []): \Country + { + $country = new CountryStub(); + + foreach ($overrides as $property => $value) { + $country->{$property} = $value; + } + + return $country; + } + + /** + * @param array> $simpleAddresses + * @param array $overrides + */ + public static function customer( + array $simpleAddresses = [], + bool $logged = false, + bool $guest = false, + array $overrides = [], + ): \Customer { + $customer = new CustomerStub($simpleAddresses, $logged, $guest); + + foreach ($overrides as $property => $value) { + $customer->{$property} = $value; + } + + return $customer; + } + + /** + * @param array $overrides + */ + public static function cart(array $overrides = []): \Cart + { + $cart = new CartStub(); + + foreach ($overrides as $property => $value) { + $cart->{$property} = $value; + } + + return $cart; + } + + public static function pricedCart(float $total, array $overrides = []): \Cart + { + $cart = new PricedCartStub($total); + + foreach ($overrides as $property => $value) { + $cart->{$property} = $value; + } + + return $cart; + } + + public static function virtualCart(bool $isVirtual, array $overrides = []): \Cart + { + $cart = new VirtualCartStub($isVirtual); + + foreach ($overrides as $property => $value) { + $cart->{$property} = $value; + } + + return $cart; + } + + public static function smarty(): \Smarty + { + return new SmartyStub(); + } + + public static function shop(int $id = 1): object + { + return new ShopStub($id); + } + + public static function translator(?callable $translator = null): object + { + return new TranslatorStub($translator); + } +} + +final class ContextStub extends \Context +{ + public function __construct() + { + } +} + +final class LanguageStub extends \Language +{ + public function __construct() + { + } +} + +final class CountryStub extends \Country +{ + public function __construct() + { + } +} + +class CartStub extends \Cart +{ + public function __construct() + { + } +} + +final class PricedCartStub extends CartStub +{ + public function __construct(private readonly float $total) + { + } + + public function getOrderTotal($withTaxes = true, $type = \Cart::BOTH, $products = null, $id_carrier = null, $use_cache = false, bool $keepOrderPrices = false) + { + return $this->total; + } +} + +final class VirtualCartStub extends CartStub +{ + public function __construct(private readonly bool $virtual) + { + } + + public function isVirtualCart() + { + return $this->virtual; + } +} + +final class CustomerStub extends \Customer +{ + /** + * @param array> $simpleAddresses + */ + public function __construct( + private readonly array $simpleAddresses = [], + private readonly bool $logged = false, + private readonly bool $guest = false, + ) { + } + + public function getSimpleAddresses($idLang = null): array + { + return $this->simpleAddresses; + } + + public function getAddresses($idLang = null): array + { + return $this->simpleAddresses; + } + + public function isGuest(): bool + { + return $this->guest; + } + + public function isLogged($withGuest = false): bool + { + return $this->logged || ($withGuest && $this->guest); + } +} + +final class SmartyStub extends \Smarty +{ + public function __construct() + { + } +} + +final class ShopStub +{ + public function __construct(public int $id) + { + } +} + +final class TranslatorStub +{ + /** + * @var callable|null + */ + private $translatorCallback; + + public function __construct(?callable $translator = null) + { + $this->translatorCallback = $translator; + } + + public function trans(string $message, array $parameters = [], string $domain = ''): string + { + if ($this->translatorCallback !== null) { + return (string) call_user_func($this->translatorCallback, $message, $parameters, $domain); + } + + return strtr($message, $parameters); + } +} diff --git a/tests/php/Fixtures/LegacyFormStubs.php b/tests/php/Fixtures/LegacyFormStubs.php new file mode 100644 index 0000000..65431bf --- /dev/null +++ b/tests/php/Fixtures/LegacyFormStubs.php @@ -0,0 +1,385 @@ +name = (string) $name; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setType($type): self + { + $this->type = (string) $type; + + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setRequired($required): self + { + $this->required = (bool) $required; + + return $this; + } + + public function isRequired(): bool + { + return $this->required; + } + + public function setLabel($label): self + { + $this->label = (string) $label; + + return $this; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setValue($value): self + { + $this->value = $value; + + return $this; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function addAvailableValue($value, $label = null): self + { + $this->availableValues[$value] = $label ?? $value; + + return $this; + } + + public function setAvailableValues($availableValues): self + { + $this->availableValues = (array) $availableValues; + + return $this; + } + + public function getAvailableValues(): array + { + return $this->availableValues; + } + + public function setMinLength($minLength): self + { + $this->minLength = (int) $minLength; + + return $this; + } + + public function getMinLength(): ?int + { + return $this->minLength; + } + + public function setMaxLength($maxLength): self + { + $this->maxLength = (int) $maxLength; + + return $this; + } + + public function getMaxLength(): ?int + { + return $this->maxLength; + } + + public function addConstraint($constraint): self + { + $this->constraints[] = $constraint; + + return $this; + } + + public function getConstraints(): array + { + return $this->constraints; + } + + public function addError($error): self + { + $this->errors[] = $error; + + return $this; + } + + public function getErrors(): array + { + return $this->errors; + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'required' => $this->required, + 'label' => $this->label, + 'value' => $this->value, + 'availableValues' => $this->availableValues, + 'minLength' => $this->minLength, + 'maxLength' => $this->maxLength, + 'constraints' => $this->constraints, + 'errors' => $this->errors, + ]; + } + } +} + +if (!class_exists('Country', false)) { + class Country + { + public static array $registry = []; + public const GEOLOC_ALLOWED = 1; + public int $id = 0; + public bool $need_zip_code = false; + public string $zip_code_format = ''; + public bool $need_identification_number = false; + public bool $contains_states = false; + public array $validZipCodes = []; + + public function __construct(int $id = 0, int $idLang = 0) + { + if ($id > 0 && isset(self::$registry[$id])) { + $data = self::$registry[$id]; + $this->id = $id; + $this->need_zip_code = (bool) ($data['need_zip_code'] ?? false); + $this->zip_code_format = (string) ($data['zip_code_format'] ?? ''); + $this->need_identification_number = (bool) ($data['need_identification_number'] ?? false); + $this->contains_states = (bool) ($data['contains_states'] ?? false); + $this->validZipCodes = (array) ($data['validZipCodes'] ?? []); + } + } + + public function checkZipCode($postcode): bool + { + return in_array((string) $postcode, $this->validZipCodes, true); + } + + public static function getCountries($idLang = 0, $active = false): array + { + return array_map(static function (array $country, int $countryId): array { + return [ + 'id_country' => $countryId, + 'name' => (string) ($country['name'] ?? ''), + ]; + }, self::$registry, array_keys(self::$registry)); + } + } +} + +if (!class_exists('Address', false)) { + class Address + { + public static array $definition = ['fields' => []]; + public $id = 0; + public $id_customer = 0; + public $id_country = 0; + public $id_state = 0; + public $alias = ''; + public $firstname = ''; + public $lastname = ''; + public $address1 = ''; + public $address2 = ''; + public $postcode = ''; + public $city = ''; + public $phone = ''; + public $dni = ''; + public $company = ''; + + public function __construct($id = null, $idLang = null) + { + if ($id !== null) { + $this->id = (int) $id; + } + } + } +} + +if (!class_exists('Customer', false)) { + class Customer + { + public static array $requiredFields = []; + public $id = 0; + public $is_guest = 0; + public $email = ''; + public $firstname = ''; + public $lastname = ''; + public $id_country = 0; + public $id_gender = 0; + public $id_risk = 0; + public $passwd = ''; + + public function __construct($id = null) + { + if ($id !== null) { + $this->id = (int) $id; + } + } + + public function isFieldRequired($fieldName): bool + { + return in_array($fieldName, self::$requiredFields, true); + } + + public function isGuest(): bool + { + return false; + } + + public function isLogged($withGuest = false): bool + { + return false; + } + + public function getSimpleAddresses($idLang = null): array + { + return []; + } + + public function getAddresses($idLang = null): array + { + return []; + } + } +} + +if (!class_exists('Context', false)) { + class Context + { + public static ?self $instance = null; + public mixed $customer = null; + public mixed $cart = null; + public mixed $cookie = null; + public mixed $language = null; + public mixed $country = null; + public mixed $shop = null; + public mixed $smarty = null; + public mixed $link = null; + public mixed $controller = null; + + public static function getContext(): self + { + if (self::$instance === null) { + self::$instance = new self(); + self::$instance->customer = new class { + public function isLogged($withGuest = false): bool + { + return false; + } + }; + } + + return self::$instance; + } + } +} + +if (!class_exists('Configuration', false)) { + class Configuration + { + public static array $values = []; + + public static function get($key) + { + return self::$values[$key] ?? false; + } + + public static function resetStaticCache(): void + { + } + } +} + +if (!class_exists('Carrier', false)) { + class Carrier + { + public static function getDeliveredCountries($idLang = 0, $active = false, $groupByZone = false): array + { + return Country::getCountries($idLang, $active); + } + } +} + +if (!class_exists('AddressFormat', false)) { + class AddressFormat + { + public static array $orderedFields = []; + public static array $requiredFields = []; + + public static function getOrderedAddressFields($idCountry = 0, $splitAll = false, $cleaned = false): array + { + return self::$orderedFields; + } + + public static function getFieldsRequired(): array + { + return self::$requiredFields; + } + } +} + +if (!class_exists('State', false)) { + class State + { + public static array $statesByCountry = []; + + public static function getStatesByIdCountry($idCountry, $active = false, $orderBy = null, $sort = 'ASC'): array + { + return self::$statesByCountry[(int) $idCountry] ?? []; + } + } +} + +if (!class_exists('Hook', false)) { + class Hook + { + public static array $responses = []; + + public static function exec($hookName, $hookArgs = [], $idModule = null, $arrayReturn = false) + { + return self::$responses[$hookName] ?? ($arrayReturn ? [] : null); + } + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php index 8a3d47a..2e1e372 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php +++ b/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php @@ -32,13 +32,14 @@ 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
']; $handler = new OnePageCheckoutAddressFormHandler($formSpy); $response = $handler->getTemplateVariables([ - 'id_address' => (string) $address->id, + 'id_address_delivery' => (string) $address->id, 'id_country' => '8', 'invoice_id_country' => '8', 'use_same_address' => '1', @@ -47,13 +48,15 @@ public function testItLoadsAddressThenAppliesWhitelistedPayload(): void self::assertSame([(int) $address->id], $formSpy->loadedAddressIds); self::assertSame([ + ['customer_id' => (int) $customer->id], [ 'id_country' => '8', - 'invoice_id_country' => '8', 'use_same_address' => '1', + 'id_address_delivery' => (string) $address->id, ], ], $formSpy->fillWithPayloads); - self::assertSame(['address_form' => '
ok
'], $response); + self::assertAddressSectionContext($response); + self::assertSame('
ok
', $response['address_form']); } public function testItDoesNotLoadAddressWhenIdIsNotPositive(): void @@ -63,7 +66,7 @@ public function testItDoesNotLoadAddressWhenIdIsNotPositive(): void $handler = new OnePageCheckoutAddressFormHandler($formSpy); $response = $handler->getTemplateVariables([ - 'id_address' => '0', + 'id_address_delivery' => '0', 'id_country' => '8', ]); @@ -71,7 +74,8 @@ public function testItDoesNotLoadAddressWhenIdIsNotPositive(): void self::assertSame([ ['id_country' => '8'], ], $formSpy->fillWithPayloads); - self::assertSame(['address_form' => '
country-only
'], $response); + self::assertAddressSectionContext($response); + self::assertSame('
country-only
', $response['address_form']); } public function testItReturnsTemplateVariablesWithoutFormFillWhenPayloadIsIrrelevant(): void @@ -86,7 +90,109 @@ public function testItReturnsTemplateVariablesWithoutFormFillWhenPayloadIsIrrele self::assertSame([], $formSpy->loadedAddressIds); self::assertSame([], $formSpy->fillWithPayloads); - self::assertSame(['address_form' => '
initial
'], $response); + self::assertAddressSectionContext($response); + self::assertSame('
initial
', $response['address_form']); + } + + 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([ + ['customer_id' => (int) $customer->id], + [ + 'invoice_id_country' => '8', + 'id_address_invoice' => (string) $invoiceAddress->id, + ], + ], $formSpy->fillWithPayloads); + self::assertAddressSectionContext($response); + self::assertSame('
invoice
', $response['address_form']); + } + + 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_delivery' => (string) $foreignAddress->id, + 'id_country' => '8', + ]); + + self::assertSame([], $formSpy->loadedAddressIds); + self::assertSame([ + ['customer_id' => (int) $owner->id], + ['id_country' => '8'], + ], $formSpy->fillWithPayloads); + self::assertAddressSectionContext($response); + self::assertSame('
safe
', $response['address_form']); + } + + public function testItFallsBackToDefaultCountryWhenCustomerHasNoSavedAddress(): void + { + $customer = $this->createCustomer($this->uniqueEmail('opc-empty-addresses')); + self::getContext()->customer = $customer; + + $formSpy = new IntegrationOpcAddressFormSpy(); + $formSpy->templateVars = ['address_form' => '
default-country
']; + $handler = new OnePageCheckoutAddressFormHandler($formSpy); + + $response = $handler->getTemplateVariables([ + 'use_same_address' => '1', + ]); + + self::assertSame([ + ['customer_id' => (int) $customer->id], + [ + 'use_same_address' => '1', + 'id_country' => (string) \Configuration::get('PS_COUNTRY_DEFAULT'), + ], + ], $formSpy->fillWithPayloads); + self::assertAddressSectionContext($response); + self::assertSame('
default-country
', $response['address_form']); + } + + public function testItIgnoresInvoiceCountryWhenUseSameAddressIsEnabled(): void + { + $customer = $this->createCustomer($this->uniqueEmail('opc-same-address-country')); + self::getContext()->customer = $customer; + + $formSpy = new IntegrationOpcAddressFormSpy(); + $formSpy->templateVars = ['address_form' => '
same-address-country
']; + $handler = new OnePageCheckoutAddressFormHandler($formSpy); + + $response = $handler->getTemplateVariables([ + 'id_country' => '21', + 'invoice_id_country' => '8', + 'use_same_address' => '1', + ]); + + self::assertSame([ + ['customer_id' => (int) $customer->id], + [ + 'id_country' => '21', + 'use_same_address' => '1', + ], + ], $formSpy->fillWithPayloads); + self::assertAddressSectionContext($response); + self::assertSame('
same-address-country
', $response['address_form']); } private static function resetTables(): void @@ -132,6 +238,18 @@ private function uniqueEmail(string $prefix): string { return sprintf('%s_%s@example.com', $prefix, uniqid('', true)); } + + /** + * @param array $response + */ + private static function assertAddressSectionContext(array $response): void + { + self::assertArrayHasKey('is_virtual_cart', $response); + self::assertArrayHasKey('cart', $response); + self::assertIsArray($response['cart']); + self::assertArrayHasKey('id_address_delivery', $response['cart']); + self::assertArrayHasKey('id_address_invoice', $response['cart']); + } } class IntegrationOpcAddressFormSpy extends OnePageCheckoutForm @@ -162,6 +280,15 @@ public function fillFromAddress(\Address $address) return $this; } + public function fillFromCustomer(\Customer $customer) + { + $this->fillWithPayloads[] = [ + 'customer_id' => (int) $customer->id, + ]; + + return $this; + } + public function fillWith(array $params = []) { $this->fillWithPayloads[] = $params; diff --git a/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php new file mode 100644 index 0000000..77928bc --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php @@ -0,0 +1,81 @@ +createCustomer(); + $firstAddress = $this->createAddressForCustomer((int) $customer->id, 'Home'); + $secondAddress = $this->createAddressForCustomer((int) $customer->id, 'Office'); + + $context = self::getContext(); + $context->customer = $customer; + $context->language = new \Language((int) \Configuration::get('PS_LANG_DEFAULT')); + $context->cart = new \Cart(); + $context->cart->id_address_delivery = (int) $secondAddress->id; + $context->cart->id_address_invoice = (int) $firstAddress->id; + + $handler = new OnePageCheckoutAddressesListHandler($context, new CheckoutCustomerContextResolver($context)); + $response = $handler->handle(); + + self::assertTrue($response['success']); + self::assertSame(2, $response['address_count']); + self::assertSame((int) $secondAddress->id, (int) $response['selected_delivery_address']); + self::assertSame((int) $firstAddress->id, (int) $response['selected_invoice_address']); + self::assertCount(2, $response['customer']['addresses']); + } + + 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..f0c5372 --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcPaymentMethodsHandlerIntegrationTest.php @@ -0,0 +1,450 @@ + '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 testItFiltersPaymentOptionsByPersistedDeliveryAddressCountry(): void + { + $customer = $this->createCustomer('opc-payment-country'); + $usAddress = $this->createAddressForCustomer((int) $customer->id, 'US'); + + $context = self::getContext(); + $context->cart = new class((int) $usAddress->id) extends \Cart { + public function __construct(int $deliveryAddressId) + { + $this->id = 1; + $this->id_address_delivery = $deliveryAddressId; + } + + 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' => [[ + 'module_name' => 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ]], + 'ps_checkpayment' => [[ + '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(); + + 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 testItPrefersExplicitDeliveryAddressSelectionWhenTaxesUseDeliveryAddress(): void + { + \Configuration::updateValue('PS_TAX_ADDRESS_TYPE', 'id_address_delivery'); + + $customer = $this->createCustomer('opc-payment-explicit-delivery'); + $usAddress = $this->createAddressForCustomer((int) $customer->id, 'US'); + + $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' => [[ + 'module_name' => 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ]], + 'ps_checkpayment' => [[ + '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_address_delivery' => (string) $usAddress->id, + 'invoice_id_country' => (string) \Country::getByIso('FR'), + ]); + + 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, + ])); + } + } + + private function createCustomer(string $emailPrefix): \Customer + { + $customer = new \Customer(); + $customer->firstname = 'Payment'; + $customer->lastname = 'Customer'; + $customer->email = sprintf('%s_%s@example.com', $emailPrefix, uniqid('', true)); + $customer->is_guest = false; + $customer->passwd = \Tools::hash('payment-test-password'); + + self::assertTrue($customer->save()); + + return $customer; + } + + private function createAddressForCustomer(int $customerId, string $countryIso): \Address + { + $address = new \Address(); + $address->id_customer = $customerId; + $address->id_country = (int) \Country::getByIso($countryIso); + $address->firstname = 'Payment'; + $address->lastname = 'Customer'; + $address->address1 = '1 rue Payment'; + $address->alias = 'payment_' . uniqid('', true); + $address->postcode = $countryIso === 'US' ? '10001' : '75001'; + $address->city = $countryIso === 'US' ? 'New York' : 'Paris'; + + self::assertTrue($address->save()); + + return $address; + } +} diff --git a/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php new file mode 100644 index 0000000..7b4c60a --- /dev/null +++ b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php @@ -0,0 +1,318 @@ +createCustomer(); + $cart = $this->createCartForCustomer($customer); + $context = $this->createCheckoutContext($customer, $cart); + $translator = $this->createTranslator(); + $countryId = (int) \Configuration::get('PS_COUNTRY_DEFAULT'); + $states = \State::getStatesByIdCountry($countryId); + $defaultStateId = !empty($states) ? (int) $states[0]['id_state'] : 0; + + $handler = $this->createHandler($context, $translator); + $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::assertSame(['success' => true], $response, var_export($response, true)); + + $freshCart = new \Cart((int) $cart->id); + self::assertGreaterThan(0, (int) $freshCart->id_address_delivery); + self::assertSame((int) $freshCart->id_address_delivery, (int) $freshCart->id_address_invoice); + + $checkoutSession = $this->createCheckoutSession($context, $translator); + self::assertSame((int) $freshCart->id_address_delivery, (int) $checkoutSession->getIdAddressDelivery()); + self::assertSame((int) $freshCart->id_address_invoice, (int) $checkoutSession->getIdAddressInvoice()); + } + + public function testItCreatesDeliveryAddressWithoutAliasAndUsesTheFallbackAlias(): void + { + $customer = $this->createCustomer(); + $cart = $this->createCartForCustomer($customer); + $context = $this->createCheckoutContext($customer, $cart); + $translator = $this->createTranslator(); + $countryId = (int) \Configuration::get('PS_COUNTRY_DEFAULT'); + $defaultStateId = $this->getFirstStateIdForCountry($countryId); + + $handler = $this->createHandler($context, $translator); + $response = $handler->handle([ + 'address_type' => 'delivery', + 'firstname' => 'Integration', + 'lastname' => 'Customer', + 'address1' => '3 rue Sans Alias', + 'city' => 'Paris', + 'postcode' => '75001', + 'id_country' => (string) $countryId, + 'id_state' => $defaultStateId > 0 ? (string) $defaultStateId : '', + 'alias' => '', + 'use_same_address' => '1', + ]); + + self::assertSame(['success' => true], $response, var_export($response, true)); + + $savedAddress = new \Address((int) $context->cart->id_address_delivery); + self::assertSame('My Address', $savedAddress->alias); + } + + public function testItCreatesDeliveryAddressForCountryWithStatesWhenRequestChangesCountry(): void + { + $customer = $this->createCustomer(); + $cart = $this->createCartForCustomer($customer); + $context = $this->createCheckoutContext($customer, $cart); + $translator = $this->createTranslator(); + + $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 = $this->createHandler($context, $translator); + $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::assertSame(['success' => true], $response, var_export($response, true)); + + $savedAddress = new \Address((int) $context->cart->id_address_delivery); + self::assertSame($unitedStatesId, (int) $savedAddress->id_country); + self::assertSame((int) $illinoisState['id_state'], (int) $savedAddress->id_state); + } + + public function testItCreatesInvoiceAddressWithoutChangingTheDeliveryAddress(): void + { + $customer = $this->createCustomer(); + $deliveryAddress = $this->createAddress($customer, [ + 'alias' => 'Existing delivery', + 'firstname' => 'Integration', + 'lastname' => 'Customer', + 'address1' => '1 rue Livraison', + 'city' => 'Paris', + 'postcode' => '75001', + 'id_country' => (int) \Configuration::get('PS_COUNTRY_DEFAULT'), + ]); + $cart = $this->createCartForCustomer($customer, [ + 'id_address_delivery' => (int) $deliveryAddress->id, + 'id_address_invoice' => (int) $deliveryAddress->id, + ]); + $context = $this->createCheckoutContext($customer, $cart); + $translator = $this->createTranslator(); + $countryId = (int) \Configuration::get('PS_COUNTRY_DEFAULT'); + $defaultStateId = $this->getFirstStateIdForCountry($countryId); + + $handler = $this->createHandler($context, $translator); + $response = $handler->handle([ + 'address_type' => 'invoice', + 'id_address' => '0', + 'invoice_firstname' => 'Invoice', + 'invoice_lastname' => 'Customer', + 'invoice_address1' => '2 rue Facture', + 'invoice_city' => 'Lyon', + 'invoice_postcode' => '69001', + 'invoice_id_country' => (string) $countryId, + 'invoice_id_state' => $defaultStateId > 0 ? (string) $defaultStateId : '', + 'invoice_alias' => 'Invoice', + ]); + + self::assertSame(['success' => true], $response, var_export($response, true)); + + $freshCart = new \Cart((int) $cart->id); + self::assertSame((int) $deliveryAddress->id, (int) $freshCart->id_address_delivery); + self::assertNotSame((int) $deliveryAddress->id, (int) $freshCart->id_address_invoice); + } + + public function testItUpdatesAnOwnedAddress(): void + { + $customer = $this->createCustomer(); + $address = $this->createAddress($customer, [ + 'alias' => 'Home', + 'firstname' => 'Integration', + 'lastname' => 'Customer', + 'address1' => '1 rue Initiale', + 'city' => 'Paris', + 'postcode' => '75001', + 'id_country' => (int) \Configuration::get('PS_COUNTRY_DEFAULT'), + ]); + $cart = $this->createCartForCustomer($customer, [ + 'id_address_delivery' => (int) $address->id, + 'id_address_invoice' => (int) $address->id, + ]); + $context = $this->createCheckoutContext($customer, $cart); + $translator = $this->createTranslator(); + $countryId = (int) \Configuration::get('PS_COUNTRY_DEFAULT'); + $defaultStateId = $this->getFirstStateIdForCountry($countryId); + + $handler = $this->createHandler($context, $translator); + $response = $handler->handle([ + 'address_type' => 'delivery', + 'id_address' => (string) $address->id, + 'firstname' => 'Integration', + 'lastname' => 'Customer', + 'address1' => '99 rue Modifiee', + 'city' => 'Marseille', + 'postcode' => '13001', + 'id_country' => (string) $countryId, + 'id_state' => $defaultStateId > 0 ? (string) $defaultStateId : '', + 'alias' => 'Updated', + 'use_same_address' => '1', + ]); + + self::assertSame(['success' => true], $response, var_export($response, true)); + + $freshAddress = new \Address((int) $address->id); + self::assertSame('99 rue Modifiee', $freshAddress->address1); + self::assertSame('Marseille', $freshAddress->city); + self::assertSame('Updated', $freshAddress->alias); + } + + /** + * @param array $overrides + */ + private function createAddress(\Customer $customer, array $overrides): \Address + { + $address = new \Address(); + $address->id_customer = (int) $customer->id; + + foreach ($overrides as $property => $value) { + $address->{$property} = $value; + } + + self::assertTrue($address->save()); + + return $address; + } + + /** + * @param array $overrides + */ + private function createCartForCustomer(\Customer $customer, array $overrides = []): \Cart + { + $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; + + foreach ($overrides as $property => $value) { + $cart->{$property} = $value; + } + + self::assertTrue($cart->add()); + + return $cart; + } + + private function createCheckoutContext(\Customer $customer, \Cart $cart): \Context + { + $context = self::getContext(); + $context->customer = $customer; + $context->cart = $cart; + $context->language = $this->createLanguage(); + + return $context; + } + + private function createLanguage(): \Language + { + return new \Language((int) \Configuration::get('PS_LANG_DEFAULT')); + } + + private function getFirstStateIdForCountry(int $countryId): int + { + $states = \State::getStatesByIdCountry($countryId); + + return !empty($states) ? (int) $states[0]['id_state'] : 0; + } + + private function createTranslator(): TranslatorInterface + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return $translator; + } + + private function createHandler(\Context $context, TranslatorInterface $translator): OnePageCheckoutSaveAddressHandler + { + return new OnePageCheckoutSaveAddressHandler($context, $translator, new CheckoutCustomerContextResolver($context)); + } + + 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/CheckoutCustomerTemplateBuilderTest.php b/tests/php/Unit/Checkout/Ajax/CheckoutCustomerTemplateBuilderTest.php new file mode 100644 index 0000000..dcaccf8 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/CheckoutCustomerTemplateBuilderTest.php @@ -0,0 +1,174 @@ +getMockBuilder(\Smarty::class) + ->disableOriginalConstructor() + ->onlyMethods(['getTemplateVars']) + ->getMock(); + $smarty->method('getTemplateVars')->with('customer')->willReturn([ + 'firstname' => 'Template', + ]); + + $customer = CheckoutTestFixtures::customer( + [ + [ + 'id' => 10, + 'alias' => 'Home', + ], + ], + true, + false, + [ + 'id' => 42, + 'firstname' => 'Alice', + 'lastname' => 'Doe', + 'email' => 'alice@example.com', + ] + ); + $context = CheckoutTestFixtures::context([ + 'language' => CheckoutTestFixtures::language(1), + 'smarty' => $smarty, + 'customer' => $customer, + ]); + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolve')->willReturn($customer); + + $builder = $this->createBuilder($context, $resolver); + + $result = $builder->build(); + + self::assertSame('Alice', $result['firstname']); + self::assertSame(42, $result['id']); + self::assertFalse($result['is_guest']); + self::assertTrue($result['is_logged']); + self::assertSame( + [ + [ + 'id' => 10, + 'alias' => 'Home', + ], + ], + $result['addresses'] + ); + } + + public function testItFallsBackToTheContextCustomerWhenResolverDoesNotReturnACustomer(): void + { + $context = CheckoutTestFixtures::context([ + 'language' => CheckoutTestFixtures::language(1), + 'customer' => CheckoutTestFixtures::customer([], false, false), + ]); + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolve')->willReturn(null); + + $builder = $this->createBuilder($context, $resolver); + + self::assertSame( + [ + 'is_logged' => false, + 'addresses' => [], + ], + $builder->build() + ); + } + + public function testItUsesTheResolvedCustomerPayload(): void + { + $resolvedCustomer = CheckoutTestFixtures::customer( + [['id' => 22, 'alias' => 'Resolved home']], + true, + false, + [ + 'id' => 42, + 'firstname' => 'John', + 'lastname' => 'Doe', + 'email' => 'john@example.com', + ] + ); + $context = CheckoutTestFixtures::context([ + 'language' => CheckoutTestFixtures::language(1), + 'customer' => CheckoutTestFixtures::customer([], false, false), + ]); + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolve')->willReturn($resolvedCustomer); + + $builder = $this->createBuilder($context, $resolver); + $result = $builder->build(); + + self::assertSame(42, $result['id']); + self::assertSame('John', $result['firstname']); + self::assertSame('Doe', $result['lastname']); + self::assertSame('john@example.com', $result['email']); + self::assertTrue($result['is_logged']); + self::assertFalse($result['is_guest']); + self::assertSame( + [ + [ + 'id' => 22, + 'alias' => 'Resolved home', + ], + ], + $result['addresses'] + ); + } + + public function testItNormalizesIdFromLegacyIdAddressWhenNeeded(): void + { + $customer = CheckoutTestFixtures::customer( + [ + [ + 'id_address' => 15, + 'alias' => 'Legacy address', + ], + ], + false, + true, + ['id' => 99] + ); + $context = CheckoutTestFixtures::context([ + 'language' => CheckoutTestFixtures::language(1), + 'customer' => $customer, + ]); + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolve')->willReturn($customer); + + $builder = $this->createBuilder($context, $resolver); + + $result = $builder->build(); + + self::assertFalse($result['is_logged']); + self::assertSame( + [ + [ + 'id_address' => 15, + 'alias' => 'Legacy address', + 'id' => 15, + ], + ], + $result['addresses'] + ); + } + + private function createBuilder( + \Context $context, + CheckoutCustomerContextResolver $resolver, + ): CheckoutCustomerTemplateBuilder { + return new CheckoutCustomerTemplateBuilder($context, $resolver); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php index 4c14ac7..e083297 100644 --- a/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php +++ b/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php @@ -11,8 +11,10 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerContextResolver; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutAddressFormHandler; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutForm; +use Tests\Fixtures\CheckoutTestFixtures; class OpcAddressFormHandlerTest extends TestCase { @@ -22,23 +24,25 @@ protected function setUp(): void { $this->opcForm = $this->getMockBuilder(OnePageCheckoutForm::class) ->disableOriginalConstructor() - ->onlyMethods(['fillFromAddress', 'fillWith', 'getTemplateVariables']) + ->onlyMethods(['fillFromCustomer', 'fillFromAddress', 'fillWith', 'getTemplateVariables']) ->getMock() ; $context = \Context::getContext(); - $context->language = new class extends \Language { - public function __construct() - { - } - }; - $context->language->id = 1; + $context->language = CheckoutTestFixtures::language(); + $context->cart = CheckoutTestFixtures::cart(); + $context->customer = CheckoutTestFixtures::customer(); } public function testItBuildsTemplateVariablesFromWhitelistedPayload(): void { - $handler = new OnePageCheckoutAddressFormHandler($this->opcForm); + $resolver = $this->createResolverReturning(null); + $handler = new OnePageCheckoutAddressFormHandler($this->opcForm, \Context::getContext(), $resolver); + $this->opcForm + ->expects($this->never()) + ->method('fillFromCustomer') + ; $this->opcForm ->expects($this->never()) ->method('fillFromAddress') @@ -48,7 +52,6 @@ public function testItBuildsTemplateVariablesFromWhitelistedPayload(): void ->method('fillWith') ->with([ 'id_country' => '8', - 'invoice_id_country' => '8', 'use_same_address' => '1', ]) ; @@ -65,13 +68,19 @@ public function testItBuildsTemplateVariablesFromWhitelistedPayload(): void 'ignored_key' => 'ignored', ]); - self::assertSame(['address_form' => '
ok
'], $response); + self::assertAddressSectionContext($response); + self::assertSame('
ok
', $response['address_form']); } - public function testItIgnoresNonPositiveIdAddressFromPayload(): void + public function testItIgnoresNonPositiveDeliveryAddressIdFromPayload(): void { - $handler = new OnePageCheckoutAddressFormHandler($this->opcForm); + $resolver = $this->createResolverReturning(null); + $handler = new OnePageCheckoutAddressFormHandler($this->opcForm, \Context::getContext(), $resolver); + $this->opcForm + ->expects($this->never()) + ->method('fillFromCustomer') + ; $this->opcForm ->expects($this->never()) ->method('fillFromAddress') @@ -90,17 +99,23 @@ public function testItIgnoresNonPositiveIdAddressFromPayload(): void ; $response = $handler->getTemplateVariables([ - 'id_address' => '0', + 'id_address_delivery' => '0', 'id_country' => '8', ]); - self::assertSame(['address_form' => '
country-only
'], $response); + self::assertAddressSectionContext($response); + self::assertSame('
country-only
', $response['address_form']); } public function testItDoesNotFillAddressOrFormWhenPayloadHasNoExpectedKeys(): void { - $handler = new OnePageCheckoutAddressFormHandler($this->opcForm); + $resolver = $this->createResolverReturning(null); + $handler = new OnePageCheckoutAddressFormHandler($this->opcForm, \Context::getContext(), $resolver); + $this->opcForm + ->expects($this->never()) + ->method('fillFromCustomer') + ; $this->opcForm ->expects($this->never()) ->method('fillFromAddress') @@ -119,6 +134,67 @@ public function testItDoesNotFillAddressOrFormWhenPayloadHasNoExpectedKeys(): vo 'foo' => 'bar', ]); - self::assertSame(['address_form' => '
initial
'], $response); + self::assertAddressSectionContext($response); + self::assertSame('
initial
', $response['address_form']); + } + + public function testItIgnoresInvoiceCountryWhenUseSameAddressIsEnabled(): void + { + $resolver = $this->createResolverReturning(null); + $handler = new OnePageCheckoutAddressFormHandler($this->opcForm, \Context::getContext(), $resolver); + + $this->opcForm + ->expects($this->never()) + ->method('fillFromCustomer') + ; + $this->opcForm + ->expects($this->never()) + ->method('fillFromAddress') + ; + $this->opcForm + ->expects($this->once()) + ->method('fillWith') + ->with([ + 'id_country' => '21', + 'use_same_address' => '1', + ]) + ; + $this->opcForm + ->expects($this->once()) + ->method('getTemplateVariables') + ->willReturn(['address_form' => '
same-address
']) + ; + + $response = $handler->getTemplateVariables([ + 'id_country' => '21', + 'invoice_id_country' => '8', + 'use_same_address' => '1', + ]); + + self::assertAddressSectionContext($response); + self::assertSame('
same-address
', $response['address_form']); + } + + private function createResolverReturning(?\Customer $customer): CheckoutCustomerContextResolver + { + $resolver = $this->getMockBuilder(CheckoutCustomerContextResolver::class) + ->disableOriginalConstructor() + ->onlyMethods(['resolve']) + ->getMock(); + $resolver->method('resolve')->willReturn($customer); + + return $resolver; + } + + /** + * @param array $response + */ + private static function assertAddressSectionContext(array $response): void + { + self::assertArrayHasKey('is_virtual_cart', $response); + self::assertArrayHasKey('cart', $response); + self::assertIsArray($response['cart']); + self::assertArrayHasKey('id_address_delivery', $response['cart']); + self::assertArrayHasKey('id_address_invoice', $response['cart']); } } diff --git a/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php new file mode 100644 index 0000000..185f3ef --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php @@ -0,0 +1,41 @@ + CheckoutTestFixtures::language(1), + 'cart' => CheckoutTestFixtures::cart([ + 'id_address_delivery' => 12, + 'id_address_invoice' => 10, + ]), + ]); + + $customer = CheckoutTestFixtures::customer([ + ['id_address' => 10, 'alias' => 'Home'], + ['id_address' => 12, 'alias' => 'Office'], + ], true); + + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolve')->willReturn($customer); + + $handler = new OnePageCheckoutAddressesListHandler($context, $resolver); + $response = $handler->handle(); + + self::assertTrue($response['success']); + self::assertSame(2, $response['address_count']); + self::assertSame(12, $response['selected_delivery_address']); + self::assertSame(10, $response['selected_invoice_address']); + self::assertCount(2, $response['customer']['addresses']); + } +} 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..e7cf037 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 @@ -838,6 +859,20 @@ public function __construct() { } + public function isGuest(): bool + { + return (bool) $this->is_guest; + } + + public function isLogged($withGuest = false): bool + { + if ((int) $this->id <= 0) { + return false; + } + + return !$this->isGuest() || $withGuest; + } + /** * @param bool $nullValues * diff --git a/tests/php/Unit/Checkout/Ajax/OpcPaymentMethodsHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcPaymentMethodsHandlerTest.php new file mode 100644 index 0000000..313cc4a --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcPaymentMethodsHandlerTest.php @@ -0,0 +1,89 @@ + 'ps_wirepayment', + 'call_to_action_text' => 'Wire payment', + 'action' => '/module/ps_wirepayment/validation', + ]; + $selectionKey = (new PaymentSelectionKeyBuilder())->buildSelectionKey($paymentOption); + + $context = CheckoutTestFixtures::context([ + 'cart' => CheckoutTestFixtures::pricedCart(42.0, ['id' => 1]), + 'country' => CheckoutTestFixtures::country(), + 'shop' => CheckoutTestFixtures::shop(1), + ]); + $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 = CheckoutTestFixtures::context([ + 'cart' => null, + 'country' => CheckoutTestFixtures::country(), + ]); + + $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..e0d5958 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php @@ -0,0 +1,42 @@ +createMock(TranslatorInterface::class); + $resolver = $this->createMock(CheckoutCustomerContextResolver::class); + $resolver->method('resolveId')->willReturn(0); + + $handler = $this->createHandler(CheckoutTestFixtures::context(), $translator, $resolver); + $response = $handler->handle([]); + + self::assertSame( + [ + 'success' => false, + 'errors' => [ + '' => ['Unable to resolve checkout customer.'], + ], + ], + $response + ); + } + + private function createHandler( + \Context $context, + TranslatorInterface $translator, + CheckoutCustomerContextResolver $resolver, + ): OnePageCheckoutSaveAddressHandler { + return new OnePageCheckoutSaveAddressHandler($context, $translator, $resolver); + } +} 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/OpcSubmitHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcSubmitHandlerTest.php new file mode 100644 index 0000000..ee7725f --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcSubmitHandlerTest.php @@ -0,0 +1,127 @@ +context = $this->createMock(\Context::class); + $this->context->cookie = new class { + public array $values = []; + public bool $written = false; + + public function __set(string $key, string $value): void + { + $this->values[$key] = $value; + } + + public function __get(string $key): string + { + return $this->values[$key] ?? ''; + } + + public function write(): void + { + $this->written = true; + } + }; + $this->checkoutSessionFactory = $this->createMock(CheckoutSessionFactory::class); + $this->submitProcessor = $this->createMock(OnePageCheckoutSubmitProcessor::class); + $this->submitValidationStateStorage = $this->createMock(OnePageCheckoutSubmitValidationStateStorage::class); + $this->checkoutSession = $this->createMock(\CheckoutSession::class); + $this->checkoutSession->method('getCheckoutURL')->willReturn('/commande'); + + $this->checkoutSessionFactory->method('create')->willReturn($this->checkoutSession); + + $this->handler = new OnePageCheckoutSubmitHandler( + $this->context, + $this->checkoutSessionFactory, + $this->submitProcessor, + $this->submitValidationStateStorage + ); + } + + public function testHandlePassesRequestParametersAsIs(): void + { + $this->submitProcessor + ->expects($this->once()) + ->method('process') + ->with($this->checkoutSession, $this->callback(static function (array $requestParameters): bool { + return !isset($requestParameters['submitOnePageCheckout']) + && $requestParameters['email'] === 'customer@example.com'; + })) + ->willReturn([ + 'success' => true, + 'validation_errors' => [], + 'form_errors' => [], + 'submitted_values' => [], + ]); + $this->submitValidationStateStorage->expects($this->once())->method('clear'); + + $response = $this->handler->handle([ + 'email' => 'customer@example.com', + 'paymentMethod' => 'ps_checkpayment', + ]); + + self::assertTrue($response['success']); + self::assertFalse($response['reload']); + self::assertSame('/commande', $response['checkout_url']); + self::assertSame('ps_checkpayment', $this->context->cookie->__get('opc_selected_payment_module')); + self::assertTrue($this->context->cookie->written); + } + + public function testHandlePersistsFailedSubmitStateAndReturnsReload(): void + { + $this->submitProcessor->expects($this->once()) + ->method('process') + ->willReturn([ + 'success' => false, + 'validation_errors' => [ + 'payment' => ['paymentMethod' => 'Please select a payment method.'], + ], + 'form_errors' => [ + 'firstname' => ['Required'], + ], + 'submitted_values' => [ + 'firstname' => 'Ada', + ], + ]); + $this->submitValidationStateStorage->expects($this->once()) + ->method('save') + ->with([ + 'validation_errors' => [ + 'payment' => ['paymentMethod' => 'Please select a payment method.'], + ], + 'form_errors' => [ + 'firstname' => ['Required'], + ], + 'submitted_values' => [ + 'firstname' => 'Ada', + ], + ]); + + $response = $this->handler->handle([ + ]); + + self::assertFalse($response['success']); + self::assertTrue($response['reload']); + self::assertSame('/commande', $response['checkout_url']); + } +} diff --git a/tests/php/Unit/Checkout/Ajax/OpcSubmitProcessorTest.php b/tests/php/Unit/Checkout/Ajax/OpcSubmitProcessorTest.php new file mode 100644 index 0000000..b4b65b2 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcSubmitProcessorTest.php @@ -0,0 +1,268 @@ +cart = $this->createMock(\Cart::class); + $this->cart->method('isVirtualCart')->willReturn(false); + $this->cart->method('getOrderTotal')->willReturn(10.0); + + $customer = $this->createMock(\Customer::class); + $customer->method('isLogged')->willReturn(true); + $customer->method('isGuest')->willReturn(false); + $customer->id = 42; + $customer->email = 'existing@example.com'; + + $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->cookie = new class { + public array $values = []; + + public function __set(string $key, string $value): void + { + $this->values[$key] = $value; + } + + public function __get(string $key): string + { + return $this->values[$key] ?? ''; + } + }; + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + $this->checkoutSession = $this->createMock(\CheckoutSession::class); + $this->checkoutSession->method('getCart')->willReturn($this->cart); + $this->checkoutSession->method('getCustomer')->willReturn($customer); + $this->checkoutSession->method('getSelectedDeliveryOption')->willReturn(''); + + $this->opcForm = $this->createMock(OnePageCheckoutForm::class); + $this->opcForm->method('fillWith')->willReturnSelf(); + $this->paymentOptionsFinder = $this->createMock(\PaymentOptionsFinder::class); + $this->conditionsToApproveFinder = $this->createMock(\ConditionsToApproveFinder::class); + + $this->processor = new OnePageCheckoutSubmitProcessor( + $this->context, + $translator, + $this->opcForm, + $this->paymentOptionsFinder, + $this->conditionsToApproveFinder + ); + } + + public function testProcessNormalizesMissingUseSameAddressFlag(): void + { + $this->checkoutSession->method('getDeliveryOptions')->willReturn([]); + $this->paymentOptionsFinder->method('present')->willReturn([]); + $this->conditionsToApproveFinder->method('getConditionsToApproveForTemplate')->willReturn([]); + $this->opcForm->expects($this->once()) + ->method('fillWith') + ->with($this->callback(static function (array $parameters): bool { + return isset($parameters['use_same_address']) && $parameters['use_same_address'] === '0'; + })) + ->willReturnSelf(); + $this->opcForm->method('validate')->willReturn(false); + $this->opcForm->method('getErrors')->willReturn(['firstname' => ['required']]); + + $response = $this->processor->process($this->checkoutSession, [ + 'email' => 'connected@example.com', + ]); + + self::assertFalse($response['success']); + self::assertSame('0', $response['submitted_values']['use_same_address']); + } + + public function testProcessNormalizesMissingUseSameAddressFlagForVirtualCart(): void + { + $virtualCart = $this->createMock(\Cart::class); + $virtualCart->method('isVirtualCart')->willReturn(true); + $virtualCart->method('getOrderTotal')->willReturn(10.0); + + $virtualContext = clone $this->context; + $virtualContext->cart = $virtualCart; + + $virtualCheckoutSession = $this->createMock(\CheckoutSession::class); + $virtualCheckoutSession->method('getCart')->willReturn($virtualCart); + $virtualCheckoutSession->method('getCustomer')->willReturn($this->context->customer); + $virtualCheckoutSession->method('getSelectedDeliveryOption')->willReturn(''); + $virtualCheckoutSession->method('getDeliveryOptions')->willReturn([]); + + $virtualOpcForm = $this->createMock(OnePageCheckoutForm::class); + $virtualOpcForm->expects($this->once()) + ->method('fillWith') + ->with($this->callback(static function (array $parameters): bool { + return isset($parameters['use_same_address']) && $parameters['use_same_address'] === '1'; + })) + ->willReturnSelf(); + $virtualOpcForm->method('validate')->willReturn(false); + $virtualOpcForm->method('getErrors')->willReturn(['firstname' => ['required']]); + + $processor = new OnePageCheckoutSubmitProcessor( + $virtualContext, + $this->createMock(TranslatorInterface::class), + $virtualOpcForm, + $this->paymentOptionsFinder, + $this->conditionsToApproveFinder + ); + + $response = $processor->process($virtualCheckoutSession, [ + 'email' => 'connected@example.com', + ]); + + self::assertFalse($response['success']); + self::assertSame('1', $response['submitted_values']['use_same_address']); + } + + public function testProcessRequiresDeliveryOptionForPhysicalCart(): void + { + $this->paymentOptionsFinder->method('present')->willReturn([]); + $this->conditionsToApproveFinder->method('getConditionsToApproveForTemplate')->willReturn([]); + $this->checkoutSession->method('getDeliveryOptions')->willReturn([]); + $this->opcForm->method('validate')->willReturn(true); + $this->opcForm->method('getErrors')->willReturn([]); + + $response = $this->processor->process($this->checkoutSession, [ + 'email' => 'connected@example.com', + ]); + + self::assertFalse($response['success']); + self::assertArrayHasKey('shipping', $response['validation_errors']); + } + + public function testProcessRequiresPaymentSelectionWhenPaymentOptionsExist(): void + { + $this->checkoutSession->method('getDeliveryOptions')->willReturn([ + '1,' => [ + 'is_module' => false, + ], + ]); + $this->paymentOptionsFinder->method('present')->willReturn([ + 'ps_checkpayment' => [[ + 'module_name' => 'ps_checkpayment', + 'id' => 'payment-option-1', + ]], + ]); + $this->conditionsToApproveFinder->method('getConditionsToApproveForTemplate')->willReturn([]); + $this->opcForm->method('validate')->willReturn(true); + $this->opcForm->method('getErrors')->willReturn([]); + + $response = $this->processor->process($this->checkoutSession, [ + 'email' => 'connected@example.com', + 'delivery_option' => '1,', + ]); + + self::assertFalse($response['success']); + self::assertArrayHasKey('payment', $response['validation_errors']); + } + + public function testProcessRequiresConditionsToApprove(): void + { + $this->paymentOptionsFinder->method('present')->willReturn([]); + $this->checkoutSession->method('getDeliveryOptions')->willReturn([ + '1,' => [ + 'is_module' => false, + ], + ]); + $this->conditionsToApproveFinder->method('getConditionsToApproveForTemplate')->willReturn([ + 'terms-and-conditions' => 'Terms', + ]); + $this->opcForm->method('validate')->willReturn(true); + $this->opcForm->method('getErrors')->willReturn([]); + + $response = $this->processor->process($this->checkoutSession, [ + 'email' => 'connected@example.com', + 'delivery_option' => '1,', + 'paymentMethod' => 'ps_checkpayment', + ]); + + self::assertFalse($response['success']); + self::assertArrayHasKey('conditions', $response['validation_errors']); + } + + public function testProcessPersistsResolvedDeliveryOptionAndAddressesOnSuccess(): void + { + $this->paymentOptionsFinder->method('present')->willReturn([]); + $this->conditionsToApproveFinder->method('getConditionsToApproveForTemplate')->willReturn([]); + $this->checkoutSession->method('getDeliveryOptions')->willReturn([ + '1,' => [ + 'is_module' => false, + ], + ]); + $this->opcForm->method('validate')->willReturn(true); + $this->opcForm->expects($this->exactly(2))->method('fillWith')->willReturnSelf(); + $this->opcForm->method('submit')->willReturn([ + 'id_address_delivery' => 10, + 'id_address_invoice' => 10, + ]); + $this->checkoutSession->expects($this->once()) + ->method('setDeliveryOption') + ->with([10 => '1,']); + $this->checkoutSession->expects($this->once())->method('setIdAddressDelivery')->with(10); + $this->checkoutSession->expects($this->once())->method('setIdAddressInvoice')->with(10); + + $response = $this->processor->process($this->checkoutSession, [ + 'email' => 'connected@example.com', + 'delivery_option' => '1,', + 'paymentMethod' => 'ps_checkpayment', + 'conditions_to_approve' => [], + ]); + + self::assertTrue($response['success']); + } + + public function testProcessReturnsAddressErrorsWhenFormSubmitFails(): void + { + $this->paymentOptionsFinder->method('present')->willReturn([]); + $this->conditionsToApproveFinder->method('getConditionsToApproveForTemplate')->willReturn([]); + $this->checkoutSession->method('getDeliveryOptions')->willReturn([ + '1,' => [ + 'is_module' => false, + ], + ]); + $this->opcForm->method('validate')->willReturn(true); + $this->opcForm->expects($this->exactly(2))->method('fillWith')->willReturnSelf(); + $this->opcForm->method('submit')->willReturn(false); + $this->opcForm->method('getErrors')->willReturn([ + 'address1' => ['Address is invalid.'], + ]); + + $response = $this->processor->process($this->checkoutSession, [ + 'email' => 'connected@example.com', + 'delivery_option' => '1,', + 'paymentMethod' => 'ps_checkpayment', + 'conditions_to_approve' => [], + ]); + + self::assertFalse($response['success']); + self::assertSame([ + 'address' => [ + 'address1' => ['Address is invalid.'], + ], + ], $response['validation_errors']); + } +} 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..e0c181b --- /dev/null +++ b/tests/php/Unit/Checkout/CheckoutOnePageStepRenderTest.php @@ -0,0 +1,194 @@ +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 getIdAddressDelivery() + { + return 0; + } + + public function getIdAddressInvoice() + { + return 0; + } + + 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..70a0d07 --- /dev/null +++ b/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php @@ -0,0 +1,229 @@ +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 \PaymentOptionsFinder|MockObject $paymentOptionsFinder; + private \ConditionsToApproveFinder|MockObject $conditionsToApproveFinder; + private TestableCheckoutOnePageStep $step; + + protected function setUp(): void + { + $this->cart = $this->createMock(\Cart::class); + $this->cart->method('isVirtualCart')->willReturn(false); + $this->cart->method('getOrderTotal')->willReturn(10.0); + + $customer = $this->createMock(\Customer::class); + $customer->method('isLogged')->willReturn(true); + $customer->method('isGuest')->willReturn(false); + $customer->id = 42; + $customer->email = 'existing@example.com'; + + $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->context->cookie = new class { + public array $values = []; + + public function __set(string $key, string $value): void + { + $this->values[$key] = $value; + } + + public function __get(string $key): string + { + return $this->values[$key] ?? ''; + } + + public function __unset(string $key): void + { + unset($this->values[$key]); + } + + public function write(): void + { + } + }; + + $this->session = $this->createMock(\CheckoutSession::class); + $this->session->method('getCart')->willReturn($this->cart); + $this->session->method('getCustomer')->willReturn($customer); + $this->session->method('getIdAddressDelivery')->willReturn(0); + $this->session->method('getIdAddressInvoice')->willReturn(0); + + $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->paymentOptionsFinder = $this->createMock(\PaymentOptionsFinder::class); + $this->conditionsToApproveFinder = $this->createMock(\ConditionsToApproveFinder::class); + + $this->step = new TestableCheckoutOnePageStep( + $this->context, + $translator, + $this->opcForm, + $this->paymentOptionsFinder, + $this->conditionsToApproveFinder + ); + $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 testDeliveryOptionArrayIsPersistedWithoutSubmitSpecificBranch(): void + { + $this->session->expects($this->once()) + ->method('setDeliveryOption') + ->with([5 => '1,']); + + $this->step->handleRequest([ + 'delivery_option' => [5 => '1,'], + ]); + } + + public function testPersistedValidationErrorsAreFlushedAfterFirstRestore(): void + { + $this->step->restorePersistedData([ + 'validation_errors' => [ + 'payment' => [ + 'paymentMethod' => 'Please select a payment method.', + ], + ], + ]); + + self::assertSame([ + 'payment' => [ + 'paymentMethod' => 'Please select a payment method.', + ], + ], $this->step->getValidationErrors()); + self::assertSame([ + 'validation_errors' => [], + ], $this->step->getDataToPersist()); + } + + public function testStepRestoresLastFailedSubmitStateFromModuleStorage(): void + { + $this->context->cookie->__set('opc_submit_validation_state', json_encode([ + 'validation_errors' => [ + 'payment' => [ + 'paymentMethod' => 'Please select a payment method.', + ], + ], + 'form_errors' => [ + 'firstname' => ['The firstname field is required.'], + ], + 'submitted_values' => [ + 'firstname' => 'Ada', + 'use_same_address' => '0', + ], + ])); + + $this->opcForm->expects($this->once()) + ->method('restoreSubmissionState') + ->with( + [ + 'firstname' => 'Ada', + 'use_same_address' => '0', + ], + [ + 'firstname' => ['The firstname field is required.'], + ] + ) + ->willReturnSelf(); + + $this->step->handleRequest([]); + + self::assertSame([ + 'payment' => [ + 'paymentMethod' => 'Please select a payment method.', + ], + ], $this->step->getValidationErrors()); + self::assertSame([ + 'validation_errors' => [], + ], $this->step->getDataToPersist()); + self::assertSame('', $this->context->cookie->__get('opc_submit_validation_state')); + } + + public function testDeliveryOptionNotPersistedOnVirtualCart(): void + { + $cart = $this->createMock(\Cart::class); + $cart->method('isVirtualCart')->willReturn(true); + + $session = $this->createMock(\CheckoutSession::class); + $session->method('getCart')->willReturn($cart); + $session->expects($this->never())->method('setDeliveryOption'); + $session->method('getCustomer')->willReturn($this->context->customer); + $session->method('getIdAddressDelivery')->willReturn(0); + $session->method('getIdAddressInvoice')->willReturn(0); + + $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/ExistingCustomerStateTest.php b/tests/php/Unit/Checkout/ExistingCustomerStateTest.php index 2c367f9..8faf330 100644 --- a/tests/php/Unit/Checkout/ExistingCustomerStateTest.php +++ b/tests/php/Unit/Checkout/ExistingCustomerStateTest.php @@ -34,6 +34,11 @@ public function testItBuildsAGuestStateFromCustomer(): void public function __construct() { } + + public function isGuest(): bool + { + return (bool) $this->is_guest; + } }; $customer->id = 42; $customer->is_guest = 1; @@ -52,6 +57,11 @@ public function testItBuildsARegisteredStateFromCustomer(): void public function __construct() { } + + public function isGuest(): bool + { + return (bool) $this->is_guest; + } }; $customer->id = 99; $customer->is_guest = 0; diff --git a/tests/php/Unit/Checkout/OpcCheckoutProcessBuilderTest.php b/tests/php/Unit/Checkout/OpcCheckoutProcessBuilderTest.php index 5c3ebe2..a0b559c 100644 --- a/tests/php/Unit/Checkout/OpcCheckoutProcessBuilderTest.php +++ b/tests/php/Unit/Checkout/OpcCheckoutProcessBuilderTest.php @@ -16,6 +16,7 @@ use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutForm; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutFormFactory; use Symfony\Contracts\Translation\TranslatorInterface; +use Tests\Fixtures\CheckoutTestFixtures; class OpcCheckoutProcessBuilderTest extends TestCase { @@ -80,26 +81,10 @@ public function testItConfiguresDeliveryOptionsForNonVirtualCart(): void private function createContextWithCart(bool $isVirtual): \Context { - $context = new class extends \Context { - public function __construct() - { - } - }; - $context->cart = new class($isVirtual) extends \Cart { - private bool $isVirtual; - - public function __construct(bool $isVirtual) - { - $this->isVirtual = $isVirtual; - } - - public function isVirtualCart() - { - return $this->isVirtual; - } - }; - - return $context; + return CheckoutTestFixtures::context([ + 'cart' => CheckoutTestFixtures::virtualCart($isVirtual), + 'smarty' => CheckoutTestFixtures::smarty(), + ]); } } 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..26199e9 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 Tests\Fixtures\CheckoutTestFixtures; class AddressFormControllerTest extends TestCase { @@ -24,7 +25,7 @@ public function testHandleAddressFormRefreshReturnsTechnicalErrorWhenModuleIsDis $controller = new TestAddressFormController(); $controller->module = $this->createDisabledModule(); - $response = $controller->callHandleAddressFormRefresh(); + $response = $controller->callHandleOpcRequest(); self::assertFalse($response['success']); self::assertSame('technical-error', $response['error']); @@ -34,6 +35,7 @@ public function testHandleAddressFormRefreshReturnsRenderedPartialWhenEnabled(): { $controller = new TestAddressFormController(); $controller->module = $this->createEnabledModule(); + $controller->setTestContext($this->createControllerContext()); $handler = $this->getMockBuilder(OnePageCheckoutAddressFormHandler::class) ->disableOriginalConstructor() @@ -52,9 +54,9 @@ public function testHandleAddressFormRefreshReturnsRenderedPartialWhenEnabled(): ->disableOriginalConstructor() ->getMock(); - $response = $controller->callHandleAddressFormRefresh(); + $response = $controller->callHandleOpcRequest(); - 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 @@ -70,7 +72,7 @@ public function testHandleAddressFormRefreshReturnsTechnicalErrorOnRuntimeExcept return true; }, E_WARNING); try { - $response = $controller->callHandleAddressFormRefresh(); + $response = $controller->callHandleOpcRequest(); } finally { restore_error_handler(); } @@ -88,6 +90,33 @@ private function createDisabledModule(): \Ps_Onepagecheckout { return new DisabledPsOnepagecheckoutModuleForAddressForm(); } + + private function createControllerContext(): \Context + { + return CheckoutTestFixtures::context([ + 'smarty' => CheckoutTestFixtures::smarty(), + 'customer' => CheckoutTestFixtures::customer( + [ + [ + 'id' => 0, + 'alias' => 'Home', + ], + ], + false, + true, + [ + 'id' => 42, + 'firstname' => 'Alice', + 'lastname' => 'Doe', + 'id_gender' => 0, + 'id_risk' => 0, + 'is_guest' => true, + ] + ), + 'language' => CheckoutTestFixtures::language(1), + 'cart' => CheckoutTestFixtures::cart(), + ]); + } } class TestAddressFormController extends \Ps_OnepagecheckoutAddressFormModuleFrontController @@ -100,9 +129,14 @@ public function __construct() { } - public function callHandleAddressFormRefresh(): array + public function callHandleOpcRequest(): array + { + return $this->handleOpcRequest(); + } + + public function setTestContext(\Context $context): void { - return $this->handleAddressFormRefresh(); + $this->context = $context; } protected function createAddressFormHandler(OnePageCheckoutFormFactory $opcFormFactory): OnePageCheckoutAddressFormHandler diff --git a/tests/php/Unit/Controller/AddressesListControllerTest.php b/tests/php/Unit/Controller/AddressesListControllerTest.php new file mode 100644 index 0000000..d28fc5f --- /dev/null +++ b/tests/php/Unit/Controller/AddressesListControllerTest.php @@ -0,0 +1,45 @@ +handleOpcRequest(); + } + + 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->callHandleOpcRequest()['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..15f1358 --- /dev/null +++ b/tests/php/Unit/Controller/CarriersControllerTest.php @@ -0,0 +1,45 @@ +handleOpcRequest(); + } + + 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->callHandleOpcRequest()['error']); + } +} diff --git a/tests/php/Unit/Controller/GuestInitControllerTest.php b/tests/php/Unit/Controller/GuestInitControllerTest.php index c42cb8c..e43da96 100644 --- a/tests/php/Unit/Controller/GuestInitControllerTest.php +++ b/tests/php/Unit/Controller/GuestInitControllerTest.php @@ -24,7 +24,7 @@ public function testHandleGuestInitReturnsTechnicalErrorWhenModuleIsDisabled(): $controller = new TestGuestInitController(); $controller->module = $this->createDisabledModule(); - $response = $controller->callHandleGuestInit(); + $response = $controller->callHandleOpcRequest(); self::assertFalse($response['success']); self::assertSame('technical-error', $response['error']); @@ -53,7 +53,7 @@ public function testHandleGuestInitReturnsHandlerPayloadWhenModuleIsEnabled(): v ->disableOriginalConstructor() ->getMock(); - $response = $controller->callHandleGuestInit(); + $response = $controller->callHandleOpcRequest(); self::assertTrue($response['success']); self::assertSame(42, $response['id_customer']); @@ -72,7 +72,7 @@ public function testHandleGuestInitReturnsTechnicalErrorOnRuntimeException(): vo return true; }, E_WARNING); try { - $response = $controller->callHandleGuestInit(); + $response = $controller->callHandleOpcRequest(); } finally { restore_error_handler(); } @@ -102,9 +102,9 @@ public function __construct() { } - public function callHandleGuestInit(): array + public function callHandleOpcRequest(): array { - return $this->handleGuestInit(); + return $this->handleOpcRequest(); } protected function createGuestInitHandler(OnePageCheckoutFormFactory $opcFormFactory): OnePageCheckoutGuestInitHandler diff --git a/tests/php/Unit/Controller/OpcSubmitControllerTest.php b/tests/php/Unit/Controller/OpcSubmitControllerTest.php new file mode 100644 index 0000000..9daa944 --- /dev/null +++ b/tests/php/Unit/Controller/OpcSubmitControllerTest.php @@ -0,0 +1,124 @@ +module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return false; + } + }; + + $response = $controller->callHandleOpcRequest(); + + self::assertFalse($response['success']); + self::assertSame('technical-error', $response['error']); + } + + public function testHandleOpcSubmitReturnsHandlerPayloadWhenModuleIsEnabled(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $controller = new TestOpcSubmitController(); + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return true; + } + }; + $controller->submitHandler = $this->getMockBuilder(OnePageCheckoutSubmitHandler::class) + ->disableOriginalConstructor() + ->onlyMethods(['handle']) + ->getMock(); + $controller->submitHandler + ->expects($this->once()) + ->method('handle') + ->willReturn([ + 'success' => true, + 'reload' => false, + 'checkout_url' => '/commande', + ]); + + $response = $controller->callHandleOpcRequest(); + + self::assertTrue($response['success']); + self::assertFalse($response['reload']); + } + + public function testHandleOpcSubmitRejectsNonPostRequests(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $controller = new TestOpcSubmitController(); + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return true; + } + }; + + $response = $controller->callHandleOpcRequest(); + + self::assertFalse($response['success']); + self::assertTrue($response['reload']); + } +} + +class TestOpcSubmitController extends \Ps_OnepagecheckoutOpcSubmitModuleFrontController +{ + public ?OnePageCheckoutSubmitHandler $submitHandler = null; + + public function __construct() + { + } + + public function callHandleOpcRequest(): array + { + return $this->handleOpcRequest(); + } + + protected function createSubmitHandler(): OnePageCheckoutSubmitHandler + { + if (!$this->submitHandler instanceof OnePageCheckoutSubmitHandler) { + throw new \RuntimeException('submit handler not configured'); + } + + return $this->submitHandler; + } + + protected function buildTechnicalErrorResponse(): array + { + return [ + 'success' => false, + 'error' => 'technical-error', + 'reload' => true, + 'checkout_url' => '/commande', + ]; + } +} diff --git a/tests/php/Unit/Controller/PaymentMethodsControllerTest.php b/tests/php/Unit/Controller/PaymentMethodsControllerTest.php new file mode 100644 index 0000000..6ce7d0a --- /dev/null +++ b/tests/php/Unit/Controller/PaymentMethodsControllerTest.php @@ -0,0 +1,50 @@ +context = $context; + } + + public function __construct() + { + } + + public function callHandleOpcRequest(): array + { + return $this->handleOpcRequest(); + } + + 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->callHandleOpcRequest()['error']); + } +} diff --git a/tests/php/Unit/Controller/SaveAddressControllerTest.php b/tests/php/Unit/Controller/SaveAddressControllerTest.php new file mode 100644 index 0000000..e15b389 --- /dev/null +++ b/tests/php/Unit/Controller/SaveAddressControllerTest.php @@ -0,0 +1,45 @@ +handleOpcRequest(); + } + + 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->callHandleOpcRequest()['error']); + } +} diff --git a/tests/php/Unit/Controller/SelectCarrierControllerTest.php b/tests/php/Unit/Controller/SelectCarrierControllerTest.php new file mode 100644 index 0000000..fce16de --- /dev/null +++ b/tests/php/Unit/Controller/SelectCarrierControllerTest.php @@ -0,0 +1,45 @@ +handleOpcRequest(); + } + + 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->callHandleOpcRequest()['error']); + } +} diff --git a/tests/php/Unit/Controller/SelectPaymentControllerTest.php b/tests/php/Unit/Controller/SelectPaymentControllerTest.php new file mode 100644 index 0000000..f893ada --- /dev/null +++ b/tests/php/Unit/Controller/SelectPaymentControllerTest.php @@ -0,0 +1,45 @@ +handleOpcRequest(); + } + + 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->callHandleOpcRequest()['error']); + } +} diff --git a/tests/php/Unit/Controller/StatesControllerTest.php b/tests/php/Unit/Controller/StatesControllerTest.php new file mode 100644 index 0000000..4aa5a62 --- /dev/null +++ b/tests/php/Unit/Controller/StatesControllerTest.php @@ -0,0 +1,45 @@ +handleOpcRequest(); + } + + 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->callHandleOpcRequest()['error']); + } +} diff --git a/tests/php/Unit/Form/AddressFieldsFormatTraitTest.php b/tests/php/Unit/Form/AddressFieldsFormatTraitTest.php index 9a8b711..5e8d4d3 100644 --- a/tests/php/Unit/Form/AddressFieldsFormatTraitTest.php +++ b/tests/php/Unit/Form/AddressFieldsFormatTraitTest.php @@ -17,16 +17,12 @@ use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; use PrestaShop\Module\PsOnePageCheckout\Form\AddressFieldsFormatTrait; +use Tests\Fixtures\CheckoutTestFixtures; #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] class AddressFieldsFormatTraitTest extends TestCase { - protected function setUp(): void - { - $this->defineGlobalStubs(); - } - public function testItBuildsExpectedAddressFieldsFormatAndAppliesDefinitionMetadata(): void { \AddressFormat::$orderedFields = [ @@ -61,23 +57,15 @@ public function testItBuildsExpectedAddressFieldsFormatAndAppliesDefinitionMetad ], ]; - $country = new \Country(); - $country->id = 33; - $country->need_zip_code = true; - $country->zip_code_format = 'NNNNN'; - $country->need_identification_number = true; - $country->contains_states = true; - - $translator = new class { - public function trans(string $message, array $parameters = [], string $domain = ''): string - { - return $message; - } - }; - - $sut = new AddressFieldsFormatTraitHarness( - $country, - $translator, + $sut = $this->createHarness( + $this->createCountry([ + 'id' => 33, + 'need_zip_code' => true, + 'zip_code_format' => 'NNNNN', + 'need_identification_number' => true, + 'contains_states' => true, + ]), + CheckoutTestFixtures::translator(static fn (string $message): string => $message), [ ['id_country' => 33, 'name' => 'France'], ['id_country' => 34, 'name' => 'Spain'], @@ -122,17 +110,12 @@ public function testItKeepsAliasOptionalWhenAliasRequiredIsFalse(): void \AddressFormat::$orderedFields = []; \AddressFormat::$requiredFields = []; - $country = new \Country(); - $country->id = 1; - - $translator = new class { - public function trans(string $message, array $parameters = [], string $domain = ''): string - { - return $message; - } - }; - - $sut = new AddressFieldsFormatTraitHarness($country, $translator, [], []); + $sut = $this->createHarness( + $this->createCountry(['id' => 1]), + CheckoutTestFixtures::translator(static fn (string $message): string => $message), + [], + [] + ); $format = $sut->exposeGetAddressFieldsFormat('', false); self::assertArrayHasKey('alias', $format); @@ -145,16 +128,12 @@ public function trans(string $message, array $parameters = [], string $domain = */ public function testItReturnsExpectedFieldLabels(string $field, string $expected): void { - $country = new \Country(); - $country->id = 1; - $translator = new class { - public function trans(string $message, array $parameters = [], string $domain = ''): string - { - return $message; - } - }; - - $sut = new AddressFieldsFormatTraitHarness($country, $translator, [], []); + $sut = $this->createHarness( + $this->createCountry(['id' => 1]), + CheckoutTestFixtures::translator(static fn (string $message): string => $message), + [], + [] + ); self::assertSame($expected, $sut->exposeGetFieldLabel($field)); } @@ -182,95 +161,25 @@ public static function provideFieldLabels(): iterable yield 'fallback' => ['custom_field', 'custom_field']; } - private function defineGlobalStubs(): void - { - if (!class_exists('FormField', false)) { - eval(<<<'PHP' -class FormField -{ - public ?string $moduleName = null; - private string $name = ''; - private string $type = 'text'; - private bool $required = false; - private string $label = ''; - private $value = null; - private array $availableValues = []; - private ?int $minLength = null; - private ?int $maxLength = null; - private array $constraints = []; - - public function setName($name) { $this->name = (string) $name; return $this; } - public function getName() { return $this->name; } - public function setType($type) { $this->type = (string) $type; return $this; } - public function getType() { return $this->type; } - public function setRequired($required) { $this->required = (bool) $required; return $this; } - public function isRequired() { return $this->required; } - public function setLabel($label) { $this->label = (string) $label; return $this; } - public function getLabel() { return $this->label; } - public function setValue($value) { $this->value = $value; return $this; } - public function getValue() { return $this->value; } - public function addAvailableValue($value, $label = null) { $this->availableValues[$value] = $label ?? $value; return $this; } - public function getAvailableValues() { return $this->availableValues; } - public function setMinLength($minLength) { $this->minLength = (int) $minLength; return $this; } - public function getMinLength() { return $this->minLength; } - public function setMaxLength($maxLength) { $this->maxLength = (int) $maxLength; return $this; } - public function getMaxLength() { return $this->maxLength; } - public function addConstraint($constraint) { $this->constraints[] = $constraint; return $this; } - public function getConstraints() { return $this->constraints; } -} -PHP - ); - } - - if (!class_exists('Country', false)) { - eval(<<<'PHP' -class Country -{ - public int $id = 0; - public bool $need_zip_code = false; - public string $zip_code_format = ''; - public bool $need_identification_number = false; - public bool $contains_states = false; -} -PHP - ); - } - - if (!class_exists('AddressFormat', false)) { - eval(<<<'PHP' -class AddressFormat -{ - public static array $orderedFields = []; - public static array $requiredFields = []; - - public static function getOrderedAddressFields($idCountry = 0, $splitAll = false, $cleaned = false) - { - return self::$orderedFields; - } - - public static function getFieldsRequired() + /** + * @param array $overrides + */ + private function createCountry(array $overrides): \Country { - return self::$requiredFields; + return CheckoutTestFixtures::country($overrides); } -} -PHP - ); - } - - if (!class_exists('State', false)) { - eval(<<<'PHP' -class State -{ - public static array $statesByCountry = []; - public static function getStatesByIdCountry($idCountry, $active = false, $orderBy = null, $sort = 'ASC') - { - return self::$statesByCountry[(int) $idCountry] ?? []; - } -} -PHP - ); - } + /** + * @param array> $availableCountries + * @param array> $definition + */ + private function createHarness( + \Country $country, + object $translator, + array $availableCountries, + array $definition, + ): AddressFieldsFormatTraitHarness { + return new AddressFieldsFormatTraitHarness($country, $translator, $availableCountries, $definition); } } diff --git a/tests/php/Unit/Form/AddressFormValidationTraitTest.php b/tests/php/Unit/Form/AddressFormValidationTraitTest.php index b5184e5..73382fe 100644 --- a/tests/php/Unit/Form/AddressFormValidationTraitTest.php +++ b/tests/php/Unit/Form/AddressFormValidationTraitTest.php @@ -17,24 +17,21 @@ use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; use PrestaShop\Module\PsOnePageCheckout\Form\AddressFormValidationTrait; +use Tests\Fixtures\CheckoutTestFixtures; #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] class AddressFormValidationTraitTest extends TestCase { - protected function setUp(): void - { - $this->defineGlobalStubs(); - } - public function testItFailsWhenDeliveryPostcodeIsInvalid(): void { - $sut = new AddressFormValidationTraitHarness($this->buildTranslator(), (object) ['id' => 1]); + $sut = $this->createHarness(); - $deliveryCountry = new \Country(); - $deliveryCountry->need_zip_code = true; - $deliveryCountry->zip_code_format = 'NNNNN'; - $deliveryCountry->validZipCodes = ['75001']; + $deliveryCountry = $this->createCountry([ + 'need_zip_code' => true, + 'zip_code_format' => 'NNNNN', + 'validZipCodes' => ['75001'], + ]); $postcode = (new \FormField()) ->setRequired(true) @@ -48,12 +45,13 @@ public function testItFailsWhenDeliveryPostcodeIsInvalid(): void public function testItValidatesInvoicePostcodeAgainstInvoiceCountryField(): void { - $sut = new AddressFormValidationTraitHarness($this->buildTranslator(), (object) ['id' => 1]); + $sut = $this->createHarness(); - $deliveryCountry = new \Country(); - $deliveryCountry->need_zip_code = true; - $deliveryCountry->zip_code_format = 'NNNNN'; - $deliveryCountry->validZipCodes = ['75001']; + $deliveryCountry = $this->createCountry([ + 'need_zip_code' => true, + 'zip_code_format' => 'NNNNN', + 'validZipCodes' => ['75001'], + ]); \Country::$registry[2] = [ 'need_zip_code' => true, @@ -77,14 +75,39 @@ public function testItValidatesInvoicePostcodeAgainstInvoiceCountryField(): void self::assertNotEmpty($invoicePostcode->getErrors()); } + public function testItValidatesInvoicePostcodeAgainstDeliveryCountryWhenNoInvoiceCountryIsProvided(): void + { + $sut = $this->createHarness(); + + $deliveryCountry = $this->createCountry([ + 'need_zip_code' => true, + 'zip_code_format' => 'NNNNN', + 'validZipCodes' => ['75001'], + ]); + + $invoicePostcode = (new \FormField()) + ->setRequired(true) + ->setValue('99999'); + + $isValid = $sut->exposeValidateAddressPostcode( + null, + $deliveryCountry, + $invoicePostcode + ); + + self::assertFalse($isValid); + self::assertNotEmpty($invoicePostcode->getErrors()); + } + public function testItSkipsInvoicePostcodeCheckWhenInvoiceCountryDoesNotNeedZipCode(): void { - $sut = new AddressFormValidationTraitHarness($this->buildTranslator(), (object) ['id' => 1]); + $sut = $this->createHarness(); - $deliveryCountry = new \Country(); - $deliveryCountry->need_zip_code = true; - $deliveryCountry->zip_code_format = 'NNNNN'; - $deliveryCountry->validZipCodes = ['75001']; + $deliveryCountry = $this->createCountry([ + 'need_zip_code' => true, + 'zip_code_format' => 'NNNNN', + 'validZipCodes' => ['75001'], + ]); \Country::$registry[3] = [ 'need_zip_code' => false, @@ -110,12 +133,13 @@ public function testItSkipsInvoicePostcodeCheckWhenInvoiceCountryDoesNotNeedZipC public function testItReturnsTrueWhenNoRequiredPostcodesAreProvided(): void { - $sut = new AddressFormValidationTraitHarness($this->buildTranslator(), (object) ['id' => 1]); + $sut = $this->createHarness(); - $country = new \Country(); - $country->need_zip_code = true; - $country->zip_code_format = 'NNNNN'; - $country->validZipCodes = []; + $country = $this->createCountry([ + 'need_zip_code' => true, + 'zip_code_format' => 'NNNNN', + 'validZipCodes' => [], + ]); $deliveryPostcode = (new \FormField()) ->setRequired(false) @@ -133,7 +157,7 @@ public function testItReturnsTrueWhenNoRequiredPostcodesAreProvided(): void public function testItReturnsFalseWhenValidationHookReturnsFalse(): void { - $sut = new AddressFormValidationTraitHarness($this->buildTranslator(), (object) ['id' => 1]); + $sut = $this->createHarness(); \Hook::$responses['actionValidateCustomerAddressForm'] = false; self::assertFalse($sut->exposeValidateAddressFormHook()); @@ -141,7 +165,7 @@ public function testItReturnsFalseWhenValidationHookReturnsFalse(): void public function testItReturnsTrueWhenValidationHookReturnsNull(): void { - $sut = new AddressFormValidationTraitHarness($this->buildTranslator(), (object) ['id' => 1]); + $sut = $this->createHarness(); \Hook::$responses['actionValidateCustomerAddressForm'] = null; self::assertTrue($sut->exposeValidateAddressFormHook()); @@ -149,77 +173,20 @@ public function testItReturnsTrueWhenValidationHookReturnsNull(): void private function buildTranslator(): object { - return new class { - public function trans(string $message, array $parameters = [], string $domain = ''): string - { - return strtr($message, $parameters); - } - }; - } - - private function defineGlobalStubs(): void - { - if (!class_exists('FormField', false)) { - eval(<<<'PHP' -class FormField -{ - private bool $required = false; - private $value = null; - private array $errors = []; - - public function setRequired($required) { $this->required = (bool) $required; return $this; } - public function isRequired() { return $this->required; } - public function setValue($value) { $this->value = $value; return $this; } - public function getValue() { return $this->value; } - public function addError($error) { $this->errors[] = $error; return $this; } - public function getErrors() { return $this->errors; } -} -PHP - ); - } - - if (!class_exists('Country', false)) { - eval(<<<'PHP' -class Country -{ - public static array $registry = []; - public bool $need_zip_code = false; - public string $zip_code_format = ''; - public array $validZipCodes = []; - - public function __construct(int $id = 0, int $idLang = 0) - { - if ($id > 0 && isset(self::$registry[$id])) { - $data = self::$registry[$id]; - $this->need_zip_code = (bool) ($data['need_zip_code'] ?? false); - $this->zip_code_format = (string) ($data['zip_code_format'] ?? ''); - $this->validZipCodes = (array) ($data['validZipCodes'] ?? []); - } + return CheckoutTestFixtures::translator(); } - public function checkZipCode($postcode): bool + /** + * @param array $overrides + */ + private function createCountry(array $overrides): \Country { - return in_array((string) $postcode, $this->validZipCodes, true); + return CheckoutTestFixtures::country($overrides); } -} -PHP - ); - } - - if (!class_exists('Hook', false)) { - eval(<<<'PHP' -class Hook -{ - public static array $responses = []; - public static function exec($hookName, $params = []) + private function createHarness(): AddressFormValidationTraitHarness { - return self::$responses[$hookName] ?? null; - } -} -PHP - ); - } + return new AddressFormValidationTraitHarness($this->buildTranslator(), (object) ['id' => 1]); } } 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/OnePageCheckoutAddressFormatterTest.php b/tests/php/Unit/Form/OnePageCheckoutAddressFormatterTest.php new file mode 100644 index 0000000..8c22b4a --- /dev/null +++ b/tests/php/Unit/Form/OnePageCheckoutAddressFormatterTest.php @@ -0,0 +1,55 @@ + [ + 'alias' => ['size' => 32], + 'firstname' => ['size' => 64], + 'id_country' => ['size' => 3], + ], + ]; + \AddressFormat::$orderedFields = ['firstname', 'Country:name']; + \AddressFormat::$requiredFields = ['firstname', 'Country:name']; + + $formatter = new OnePageCheckoutAddressFormatter( + CheckoutTestFixtures::country(['id' => 8, 'contains_states' => false]), + $this->createTranslator(), + [ + ['id_country' => 8, 'name' => 'France'], + ] + ); + + $format = $formatter->getFormat(); + + self::assertArrayHasKey('alias', $format); + self::assertFalse($format['alias']->isRequired()); + } + + private function createTranslator(): TranslatorInterface + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return $translator; + } +} 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 ->method('getFormat') ->willReturnCallback([$this, 'getGuestInitFields']) ; - $defaultCountry = new class extends \Country { - public function __construct() - { - } - }; + $defaultCountry = CheckoutTestFixtures::country(); $defaultCountry->id = self::DEFAULT_COUNTRY_ID; $this->formatter ->method('getCountry') @@ -64,6 +61,10 @@ public function __construct() ->method('setInvoiceCountry') ->willReturnSelf() ; + $this->formatter + ->method('getFieldGroup') + ->willReturnCallback([$this, 'getFieldGroupForTest']) + ; $this->customerPersister = $this->getMockBuilder(\CustomerPersister::class) ->disableOriginalConstructor() @@ -75,7 +76,7 @@ public function __construct() ->getMock() ; - $this->context = $this->createMock(\Context::class); + $this->context = new LightweightContext(); $this->context->customer = new LightweightCustomer(); } @@ -100,17 +101,13 @@ public function testItCreatesGuestCustomerWhenEmailAndRequiredCheckboxesAreValid ->willReturn(true) ; - $this->context - ->expects($this->never()) - ->method('updateCustomer') - ; - self::assertTrue($form->submitGuestInit($this->withDefaultCountry([ 'email' => 'guest@example.com', 'psgdpr_privacy' => '1', 'compliance_terms' => '1', 'communication_channel' => 'email', ]))); + self::assertNull($this->context->updatedCustomer); self::assertFalse($form->wasModuleValidationCalled()); } @@ -122,15 +119,11 @@ public function testItDoesNotCreateGuestCustomerWhenThirdPartyRequiredCheckboxIs ->expects($this->never()) ->method('save') ; - $this->context - ->expects($this->never()) - ->method('updateCustomer') - ; - self::assertFalse($form->submitGuestInit($this->withDefaultCountry([ 'email' => 'guest@example.com', 'psgdpr_privacy' => '1', ]))); + self::assertNull($this->context->updatedCustomer); $errors = $form->getErrors(); self::assertArrayHasKey('compliance_terms', $errors); @@ -145,16 +138,12 @@ public function testItDoesNotCreateGuestCustomerWhenEmailIsInvalid(): void ->expects($this->never()) ->method('save') ; - $this->context - ->expects($this->never()) - ->method('updateCustomer') - ; - self::assertFalse($form->submitGuestInit($this->withDefaultCountry([ 'email' => 'not-an-email', 'psgdpr_privacy' => '1', 'compliance_terms' => '1', ]))); + self::assertNull($this->context->updatedCustomer); $errors = $form->getErrors(); self::assertArrayHasKey('email', $errors); @@ -170,17 +159,13 @@ public function testItAllowsGuestInitWhenRequiredNonCheckboxFieldIsMissing(): vo ->method('save') ->willReturn(true) ; - $this->context - ->expects($this->never()) - ->method('updateCustomer') - ; - self::assertTrue($form->submitGuestInit($this->withDefaultCountry([ 'email' => 'guest@example.com', 'psgdpr_privacy' => '1', 'compliance_terms' => '1', 'communication_channel' => 'email', ]))); + self::assertNull($this->context->updatedCustomer); } public function testItDoesNotCreateGuestCustomerWhenRequiredCheckboxIsExplicitlyZero(): void @@ -191,16 +176,12 @@ public function testItDoesNotCreateGuestCustomerWhenRequiredCheckboxIsExplicitly ->expects($this->never()) ->method('save') ; - $this->context - ->expects($this->never()) - ->method('updateCustomer') - ; - self::assertFalse($form->submitGuestInit($this->withDefaultCountry([ 'email' => 'guest@example.com', 'psgdpr_privacy' => '1', 'compliance_terms' => '0', ]))); + self::assertNull($this->context->updatedCustomer); $errors = $form->getErrors(); self::assertArrayHasKey('compliance_terms', $errors); @@ -224,6 +205,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()); @@ -245,17 +227,13 @@ public function testItReturnsPersisterErrorsWhenGuestSaveFails(): void 'email' => ['Unable to save guest customer'], ]) ; - $this->context - ->expects($this->never()) - ->method('updateCustomer') - ; - self::assertFalse($form->submitGuestInit($this->withDefaultCountry([ 'email' => 'guest@example.com', 'psgdpr_privacy' => '1', 'compliance_terms' => '1', 'communication_channel' => 'email', ]))); + self::assertNull($this->context->updatedCustomer); } public function testItDoesNotCreateGuestCustomerWhenRequiredRadioConsentIsMissing(): void @@ -263,12 +241,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 +254,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 +272,7 @@ public function testGuestInitIgnoresRequiredAddressConsentFields(): void 'psgdpr_privacy' => '1', 'compliance_terms' => '1', 'compliance_note' => 'Ready', + 'communication_channel' => 'email', 'marketing_preferences' => '0', ]))); } @@ -314,6 +292,7 @@ public function testGuestInitIgnoresFinalSubmitVeto(): void 'psgdpr_privacy' => '1', 'compliance_terms' => '1', 'compliance_note' => 'Ready', + 'communication_channel' => 'email', ]))); } @@ -367,6 +346,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 +377,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 +416,22 @@ 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('customer', $templateVariables); + self::assertArrayHasKey('useSameAddressField', $templateVariables); + self::assertArrayHasKey('deliveryFields', $templateVariables); + self::assertArrayHasKey('invoiceFields', $templateVariables); + self::assertArrayHasKey('invoiceMetaFields', $templateVariables); + self::assertArrayHasKey('token', $templateVariables); + self::assertArrayHasKey('addresses', $templateVariables['customer']); 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 +440,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()) @@ -457,14 +452,6 @@ public function testSubmitPersistsDeliveryAndInvoiceAddressesWhenUseSameAddressI }) ; - $this->context - ->expects($this->once()) - ->method('updateCustomer') - ->with($this->callback(static function (\Customer $customer): bool { - return (int) $customer->id === 42; - })) - ; - $savedAddresses = []; $this->addressPersister ->expects($this->exactly(2)) @@ -498,6 +485,8 @@ public function testSubmitPersistsDeliveryAndInvoiceAddressesWhenUseSameAddressI 'id_address_delivery' => 101, 'id_address_invoice' => 202, ], $result); + self::assertInstanceOf(\Customer::class, $this->context->updatedCustomer); + self::assertSame(42, (int) $this->context->updatedCustomer->id); self::assertCount(2, $savedAddresses); self::assertSame('John', (string) $savedAddresses[0]->firstname); self::assertSame('Doe', (string) $savedAddresses[0]->lastname); @@ -513,7 +502,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()) @@ -525,11 +514,6 @@ public function testSubmitPersistsOnlyDeliveryAddressWhenUseSameAddressIsEnabled }) ; - $this->context - ->expects($this->once()) - ->method('updateCustomer') - ; - $this->addressPersister ->expects($this->once()) ->method('save') @@ -556,6 +540,156 @@ public function testSubmitPersistsOnlyDeliveryAddressWhenUseSameAddressIsEnabled 'id_address_delivery' => 303, 'id_address_invoice' => 303, ], $result); + self::assertInstanceOf(\Customer::class, $this->context->updatedCustomer); + self::assertSame(43, (int) $this->context->updatedCustomer->id); + } + + public function testSubmitUsesConnectedCustomerEmailWhenCheckoutPostOmitsIt(): void + { + $form = $this->createSubmitForm(); + $form->forceValidateResult(true); + + $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', + ])); + self::assertSame('registered@example.com', (string) $form->getValue('email')); + + $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 testGetAddressUsesSelectedSavedDeliveryAddressIdFromDeliveryRadioPost(): void + { + $form = $this->createSubmitForm(); + + $previousPost = $_POST ?? []; + $previousRequest = $_REQUEST ?? []; + + $_POST['id_address_delivery'] = '505'; + $_REQUEST['id_address_delivery'] = '505'; + + try { + $form->fillWith($this->withDefaultCountry([ + 'firstname' => 'Saved', + 'lastname' => 'Address', + 'address1' => '5 Existing street', + 'city' => 'Nantes', + 'postcode' => '44000', + 'psgdpr_privacy' => '1', + 'compliance_terms' => '1', + 'communication_channel' => 'email', + 'use_same_address' => '1', + ])); + $address = $form->getAddress(); + } finally { + $_POST = $previousPost; + $_REQUEST = $previousRequest; + } + + self::assertSame(505, (int) $address->id); + } + + public function testFillWithKeepsSelectedDeliveryAndInvoiceCountriesWithoutManualOverride(): void + { + $language = CheckoutTestFixtures::language(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 = CheckoutTestFixtures::country(); + $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')); } /** @@ -702,12 +836,7 @@ public function getSubmitFields(): array private function createForm(): OnePageCheckoutForm { - $language = new class extends \Language { - public function __construct() - { - } - }; - $language->id = 1; + $language = CheckoutTestFixtures::language(1); return new TestableOnePageCheckoutForm( $this->createMock(\Smarty::class), @@ -722,16 +851,12 @@ public function __construct() private function createSubmitForm(): OnePageCheckoutForm { - $language = new class extends \Language { - public function __construct() - { - } - }; + $language = CheckoutTestFixtures::language(); $language->id = 1; $submitFormatter = $this->getMockBuilder(OnePageCheckoutFormatter::class) ->disableOriginalConstructor() - ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry']) + ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry', 'getFieldGroup']) ->getMock() ; $submitFormatter @@ -739,11 +864,7 @@ public function __construct() ->willReturn($this->getSubmitFields()) ; - $defaultCountry = new class extends \Country { - public function __construct() - { - } - }; + $defaultCountry = CheckoutTestFixtures::country(); $defaultCountry->id = self::DEFAULT_COUNTRY_ID; $submitFormatter @@ -758,6 +879,10 @@ public function __construct() ->method('setInvoiceCountry') ->willReturnSelf() ; + $submitFormatter + ->method('getFieldGroup') + ->willReturnCallback([$this, 'getFieldGroupForTest']) + ; return new TestableOnePageCheckoutForm( $this->createMock(\Smarty::class), @@ -770,6 +895,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 * @@ -869,6 +1001,11 @@ class LightweightCustomer extends \Customer public function __construct() { } + + public function isGuest(): bool + { + return (bool) $this->is_guest; + } } class LightweightCart extends \Cart @@ -877,3 +1014,19 @@ public function __construct() { } } + +class LightweightContext extends \Context +{ + public ?\Customer $updatedCustomer = null; + + public function __construct() + { + $this->cart = new LightweightCart(); + } + + public function updateCustomer(\Customer $customer): void + { + $this->updatedCustomer = $customer; + $this->customer = $customer; + } +} diff --git a/tests/php/Unit/Form/OnePageCheckoutFormatterTest.php b/tests/php/Unit/Form/OnePageCheckoutFormatterTest.php index 5c79a16..38a98aa 100644 --- a/tests/php/Unit/Form/OnePageCheckoutFormatterTest.php +++ b/tests/php/Unit/Form/OnePageCheckoutFormatterTest.php @@ -18,18 +18,16 @@ use PHPUnit\Framework\TestCase; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutFormatter; use Symfony\Contracts\Translation\TranslatorInterface; +use Tests\Fixtures\CheckoutTestFixtures; #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] class OnePageCheckoutFormatterTest extends TestCase { - protected function setUp(): void - { - $this->defineGlobalStubs(); - } - public function testItBuildsExpectedFormatWhenOptinHooksAndInvoiceCountryAreConfigured(): void { + \Context::getContext()->customer = CheckoutTestFixtures::customer([], true); + \Address::$definition = [ 'fields' => [ 'firstname' => ['validate' => 'isName', 'size' => 64], @@ -69,24 +67,26 @@ public function testItBuildsExpectedFormatWhenOptinHooksAndInvoiceCountryAreConf 'broken' => 123, ]; - $deliveryCountry = new \Country(); - $deliveryCountry->id = 33; - $deliveryCountry->need_zip_code = true; - $deliveryCountry->zip_code_format = 'NNNNN'; - $deliveryCountry->need_identification_number = true; - $deliveryCountry->contains_states = true; - - $invoiceCountry = new \Country(); - $invoiceCountry->id = 44; - $invoiceCountry->need_zip_code = true; - $invoiceCountry->zip_code_format = 'NNNN'; - $invoiceCountry->need_identification_number = true; - $invoiceCountry->contains_states = true; + $deliveryCountry = $this->createCountry([ + 'id' => 33, + 'need_zip_code' => true, + 'zip_code_format' => 'NNNNN', + 'need_identification_number' => true, + 'contains_states' => true, + ]); + + $invoiceCountry = $this->createCountry([ + 'id' => 44, + 'need_zip_code' => true, + 'zip_code_format' => 'NNNN', + 'need_identification_number' => true, + 'contains_states' => true, + ]); $translator = $this->createMock(TranslatorInterface::class); $translator->method('trans')->willReturnArgument(0); - $formatter = new OnePageCheckoutFormatter( + $formatter = $this->createFormatter( $deliveryCountry, $translator, [ @@ -106,11 +106,13 @@ public function testItBuildsExpectedFormatWhenOptinHooksAndInvoiceCountryAreConf self::assertTrue($format['optin']->isRequired()); self::assertArrayHasKey('use_same_address', $format); self::assertTrue($format['use_same_address']->getValue()); + self::assertArrayHasKey('id_address_delivery', $format); self::assertArrayHasKey('id_address_invoice', $format); + self::assertArrayNotHasKey('id_address', $format); self::assertArrayHasKey('alias', $format); self::assertArrayHasKey('invoice_alias', $format); - self::assertTrue($format['alias']->isRequired()); - self::assertTrue($format['invoice_alias']->isRequired()); + self::assertFalse($format['alias']->isRequired()); + self::assertFalse($format['invoice_alias']->isRequired()); self::assertSame(33, $format['id_country']->getValue()); self::assertSame(44, $format['invoice_id_country']->getValue()); self::assertSame([33 => 'France', 44 => 'Belgium'], $format['id_country']->getAvailableValues()); @@ -136,6 +138,8 @@ public function testItBuildsExpectedFormatWhenOptinHooksAndInvoiceCountryAreConf public function testItBuildsFormatWithoutOptinAndUsesDeliveryCountryForInvoiceWhenInvoiceCountryIsNotSet(): void { + \Context::getContext()->customer = CheckoutTestFixtures::customer([], false); + \Address::$definition = [ 'fields' => [ 'firstname' => ['validate' => 'isName', 'size' => 64], @@ -150,14 +154,15 @@ public function testItBuildsFormatWithoutOptinAndUsesDeliveryCountryForInvoiceWh \Hook::$responses['additionalCustomerFormFields'] = []; \Hook::$responses['additionalCustomerAddressFields'] = []; - $deliveryCountry = new \Country(); - $deliveryCountry->id = 99; - $deliveryCountry->contains_states = false; + $deliveryCountry = $this->createCountry([ + 'id' => 99, + 'contains_states' => false, + ]); $translator = $this->createMock(TranslatorInterface::class); $translator->method('trans')->willReturnArgument(0); - $formatter = new OnePageCheckoutFormatter( + $formatter = $this->createFormatter( $deliveryCountry, $translator, [['id_country' => 99, 'name' => 'Portugal']] @@ -166,6 +171,8 @@ public function testItBuildsFormatWithoutOptinAndUsesDeliveryCountryForInvoiceWh $format = $formatter->getFormat(); self::assertArrayNotHasKey('optin', $format); + self::assertFalse($format['alias']->isRequired()); + self::assertFalse($format['invoice_alias']->isRequired()); self::assertSame(99, $format['id_country']->getValue()); self::assertSame(99, $format['invoice_id_country']->getValue()); @@ -174,161 +181,22 @@ public function testItBuildsFormatWithoutOptinAndUsesDeliveryCountryForInvoiceWh self::assertSame('firstname', $getDefinitionKey->invoke($formatter, 'firstname')); } - private function defineGlobalStubs(): void - { - if (!interface_exists('FormFormatterInterface', false)) { - eval(<<<'PHP' -interface FormFormatterInterface -{ - public function getFormat(); -} -PHP - ); - } - - if (!class_exists('FormField', false)) { - eval(<<<'PHP' -class FormField -{ - public ?string $moduleName = null; - private string $name = ''; - private string $type = 'text'; - private bool $required = false; - private $value = null; - private string $label = ''; - private array $availableValues = []; - private ?int $minLength = null; - private ?int $maxLength = null; - private array $constraints = []; - - public function setName($name) { $this->name = (string) $name; return $this; } - public function getName() { return $this->name; } - public function setType($type) { $this->type = (string) $type; return $this; } - public function getType() { return $this->type; } - public function setRequired($required) { $this->required = (bool) $required; return $this; } - public function isRequired() { return $this->required; } - public function setValue($value) { $this->value = $value; return $this; } - public function getValue() { return $this->value; } - public function setLabel($label) { $this->label = (string) $label; return $this; } - public function getLabel() { return $this->label; } - public function addAvailableValue($value, $label = null) { $this->availableValues[$value] = $label ?? $value; return $this; } - public function getAvailableValues() { return $this->availableValues; } - public function setMinLength($minLength) { $this->minLength = (int) $minLength; return $this; } - public function getMinLength() { return $this->minLength; } - public function setMaxLength($maxLength) { $this->maxLength = (int) $maxLength; return $this; } - public function getMaxLength() { return $this->maxLength; } - public function addConstraint($constraint) { $this->constraints[] = $constraint; return $this; } - public function getConstraints() { return $this->constraints; } -} -PHP - ); - } - - if (!class_exists('Country', false)) { - eval(<<<'PHP' -class Country -{ - public int $id = 0; - public bool $need_zip_code = false; - public string $zip_code_format = ''; - public bool $need_identification_number = false; - public bool $contains_states = false; -} -PHP - ); - } - - if (!class_exists('Address', false)) { - eval(<<<'PHP' -class Address -{ - public static array $definition = ['fields' => []]; -} -PHP - ); - } - - if (!class_exists('Customer', false)) { - eval(<<<'PHP' -class Customer -{ - public static array $requiredFields = []; - - public function isFieldRequired($fieldName) - { - return in_array($fieldName, self::$requiredFields, true); - } -} -PHP - ); - } - - if (!class_exists('Configuration', false)) { - eval(<<<'PHP' -class Configuration -{ - public static array $values = []; - - public static function get($key) + /** + * @param array $overrides + */ + private function createCountry(array $overrides): \Country { - return self::$values[$key] ?? false; + return CheckoutTestFixtures::country($overrides); } -} -PHP - ); - } - - if (!class_exists('AddressFormat', false)) { - eval(<<<'PHP' -class AddressFormat -{ - public static array $orderedFields = []; - public static array $requiredFields = []; - - public static function getOrderedAddressFields($idCountry = 0, $splitAll = false, $cleaned = false) - { - return self::$orderedFields; - } - - public static function getFieldsRequired() - { - return self::$requiredFields; - } -} -PHP - ); - } - if (!class_exists('State', false)) { - eval(<<<'PHP' -class State -{ - public static array $statesByCountry = []; - - public static function getStatesByIdCountry($idCountry, $active = false, $orderBy = null, $sort = 'ASC') - { - return self::$statesByCountry[(int) $idCountry] ?? []; - } -} -PHP - ); - } - - if (!class_exists('Hook', false)) { - eval(<<<'PHP' -class Hook -{ - public static array $responses = []; - - public static function exec($hookName, $hookArgs = [], $idModule = null, $arrayReturn = false) - { - $response = self::$responses[$hookName] ?? ($arrayReturn ? [] : null); - - return $response; - } -} -PHP - ); - } + /** + * @param array> $availableCountries + */ + private function createFormatter( + \Country $country, + TranslatorInterface $translator, + array $availableCountries, + ): OnePageCheckoutFormatter { + return new OnePageCheckoutFormatter($country, $translator, $availableCountries); } } diff --git a/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php b/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php new file mode 100644 index 0000000..452cdbe --- /dev/null +++ b/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php @@ -0,0 +1,32 @@ +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']['urls']['opcSubmit']); } public function testHookActionFrontControllerSetMediaAssignsFlagAndSkipsAssetsWhenDisabled(): void @@ -271,6 +280,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..5fcca0e 100644 --- a/tests/php/bootstrap-autoload.php +++ b/tests/php/bootstrap-autoload.php @@ -39,6 +39,35 @@ $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', + 'Ps_OnepagecheckoutOpcSubmitModuleFrontController' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/controllers/front/opcsubmit.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\\CheckoutCustomerTemplateBuilder' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Customer/CheckoutCustomerTemplateBuilder.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', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\Submit\\OnePageCheckoutSubmitHandler' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitHandler.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\Submit\\OnePageCheckoutSubmitProcessor' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php', + 'PrestaShop\\Module\\PsOnePageCheckout\\Checkout\\Ajax\\Submit\\OnePageCheckoutSubmitValidationStateStorage' => _PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php', ]); diff --git a/tests/php/bootstrap-unit.php b/tests/php/bootstrap-unit.php index 65467ae..952b6d9 100644 --- a/tests/php/bootstrap-unit.php +++ b/tests/php/bootstrap-unit.php @@ -1,5 +1,20 @@ 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..8cc7e25 --- /dev/null +++ b/views/js/events.js @@ -0,0 +1,26 @@ +const OPC_EVENTS = { + opcCarrierSelected: 'opcCarrierSelected', + opcCarriersUpdated: 'opcCarriersUpdated', + opcCarriersFailed: 'opcCarriersFailed', + opcCarriersLoading: 'opcCarriersLoading', + opcPaymentMethodsLoading: 'opcPaymentMethodsLoading', + opcPaymentMethodsUpdated: 'opcPaymentMethodsUpdated', + opcPaymentMethodsFailed: 'opcPaymentMethodsFailed', + opcPaymentMethodSelected: 'opcPaymentMethodSelected', + opcGuestInitSuccess: 'opcGuestInitSuccess', + opcFinalSubmitStarted: 'opcFinalSubmitStarted', + opcFormValidated: 'opcFormValidated', + opcBillingSectionToggled: 'opcBillingSectionToggled', + opcSubmitFailed: 'opcSubmitFailed', + opcDeliveryAddressUpdated: 'opcDeliveryAddressUpdated', + opcBillingAddressUpdated: 'opcBillingAddressUpdated', + opcDeliveryAddressSelected: 'opcDeliveryAddressSelected', + opcBillingAddressSelected: 'opcBillingAddressSelected', + opcCartSummaryBeforeUpdate: 'opcCartSummaryBeforeUpdate', + opcCartSummaryUpdated: 'opcCartSummaryUpdated', + updatedOpcAddressForm: 'updatedOpcAddressForm', + opcCarriersRetry: 'opcCarriersRetry', + opcPaymentMethodsRetry: 'opcPaymentMethodsRetry', +}; + +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..d10eea0 --- /dev/null +++ b/views/js/opc-address-modal.js @@ -0,0 +1,1138 @@ +import OPC_EVENTS from './events'; +import OPC_SELECTORS from './selectors'; +import {getConfiguredOpcUrl} 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 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 SERVER_MANAGED_FIELDS = new Set([ + 'id_country', + 'invoice_id_country', + 'id_state', + 'invoice_id_state', + 'use_same_address', +]); +const NON_PRESERVABLE_FIELD_TYPES = new Set(['hidden', 'file', 'submit', 'button', 'image', 'reset']); + +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'; +const RESTORE_SELECTION_ID_ATTRIBUTE = 'data-opc-restore-address-id'; +const RESTORE_SELECTION_RADIO_NAME_ATTRIBUTE = 'data-opc-restore-radio-name'; +const SKIP_RESTORE_SELECTION_ATTRIBUTE = 'data-opc-skip-restore-selection'; + +function isNonSubmittableField($field) { + return $field.is(':button, [type="button"], [type="submit"], [type="reset"], [type="image"], [type="file"]'); +} + +function getFieldType($field) { + return String($field.prop('type') || '').toLowerCase(); +} + +function isServerManagedField(name) { + return !name || SERVER_MANAGED_FIELDS.has(name); +} + +function isClientPreservableField(name, $field) { + if (isServerManagedField(name)) { + return false; + } + + return !NON_PRESERVABLE_FIELD_TYPES.has(getFieldType($field)); +} + +function preserveFieldValue(preservedFields, name, $field) { + const type = getFieldType($field); + + if (type === 'checkbox') { + if (!Array.isArray(preservedFields[name])) { + preservedFields[name] = []; + } + + if ($field.is(':checked')) { + preservedFields[name].push(String($field.val())); + } + + return; + } + + if (type === 'radio') { + if ($field.is(':checked')) { + preservedFields[name] = String($field.val()); + } + + return; + } + + preservedFields[name] = $field.val(); +} + +function restoreFieldValue($field, preservedValue) { + const type = getFieldType($field); + + if (typeof preservedValue === 'undefined') { + return; + } + + if (type === 'checkbox') { + const checkedValues = Array.isArray(preservedValue) ? preservedValue : []; + $field.prop('checked', checkedValues.includes(String($field.val()))); + + return; + } + + if (type === 'radio') { + $field.prop('checked', String($field.val()) === String(preservedValue)); + + return; + } + + $field.val(preservedValue); +} + +function preserveAddressesSectionFields($addressForm) { + const preservedFields = Object.create(null); + + $addressForm.find('input, select, textarea').each((_, field) => { + const $field = $(field); + const name = String($field.prop('name') || ''); + + if (!isClientPreservableField(name, $field)) { + return; + } + + preserveFieldValue(preservedFields, name, $field); + }); + + return preservedFields; +} + +function restoreAddressesSectionFields($addressForm, preservedFields) { + $addressForm.find('input, select, textarea').each((_, field) => { + const $field = $(field); + const name = String($field.prop('name') || ''); + + if (!isClientPreservableField(name, $field)) { + return; + } + + restoreFieldValue($field, preservedFields[name]); + }); +} + +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 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 getUseSameAddressField($scope) { + return $scope.find(OPC_SELECTORS.opc.useSameAddress).first(); +} + +function getUseSameAddressState($scope) { + const $field = getUseSameAddressField($scope); + + if (!$field.length) { + return false; + } + + return $field.is(':checked'); +} + +function setUseSameAddressState($scope, useSameAddress) { + getUseSameAddressField($scope).prop('checked', useSameAddress); +} + +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 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($('