diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2839ac5ee9d32..dae954a0970b7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,34 +1,34 @@ # Contributing to Magento 2 code Contributions to the Magento 2 codebase are done using the fork & pull model. -This contribution model has contributors maintaining their own copy of the forked codebase (which can easily be synced with the main copy). The forked repository is then used to submit a request to the base repository to “pull” a set of changes (hence the phrase “pull request”). +This contribution model has contributors maintaining their own copy of the forked codebase (which can easily be synced with the main copy). The forked repository is then used to submit a request to the base repository to “pull” a set of changes. For more information on pull requests please refer to [GitHub Help](https://help.github.com/articles/about-pull-requests/). -Contributions can take the form of new components/features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes, optimizations or just good suggestions. +Contributions can take the form of new components or features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes or optimizations. -The Magento 2 development team will review all issues and contributions submitted by the community of developers in the first in, first out order. During the review we might require clarifications from the contributor. If there is no response from the contributor for two weeks, the issue is closed. +The Magento 2 development team will review all issues and contributions submitted by the community of developers in the first in, first out order. During the review we might require clarifications from the contributor. If there is no response from the contributor within two weeks, the pull request will be closed. ## Contribution requirements -1. Contributions must adhere to [Magento coding standards](http://devdocs.magento.com/guides/v2.0/coding-standards/bk-coding-standards.html). -2. Pull requests (PRs) must be accompanied by a meaningful description of their purpose. Comprehensive descriptions increase the chances of a pull request to be merged quickly and without additional clarification requests. -3. Commits must be accompanied by meaningful commit messages. -4. PRs which include bug fixing, must be accompanied with step-by-step description of how to reproduce the bug. +1. Contributions must adhere to the [Magento coding standards](https://devdocs.magento.com/guides/v2.2/coding-standards/bk-coding-standards.html). +2. Pull requests (PRs) must be accompanied by a meaningful description of their purpose. Comprehensive descriptions increase the chances of a pull request being merged quickly and without additional clarification requests. +3. Commits must be accompanied by meaningful commit messages. Please see the [Magento Pull Request Template](https://github.com/magento/magento2/blob/2.2-develop/.github/PULL_REQUEST_TEMPLATE.md) for more information. +4. PRs which include bug fixes must be accompanied with a step-by-step description of how to reproduce the bug. 3. PRs which include new logic or new features must be submitted along with: -* Unit/integration test coverage (we will be releasing more information on writing test coverage in the near future). -* Proposed [documentation](http://devdocs.magento.com) update. Documentation contributions can be submitted [here](https://github.com/magento/devdocs). -4. For large features or changes, please [open an issue](https://github.com/magento/magento2/issues) and discuss first. This may prevent duplicate or unnecessary effort, and it may gain you some additional contributors. -5. All automated tests are passed successfully (all builds on [Travis CI](https://travis-ci.org/magento/magento2) must be green). +* Unit/integration test coverage +* Proposed [documentation](http://devdocs.magento.com) updates. Documentation contributions can be submitted via the [devdocs GitHub](https://github.com/magento/devdocs). +4. For larger features or changes, please [open an issue](https://github.com/magento/magento2/issues) to discuss the proposed changes prior to development. This may prevent duplicate or unnecessary effort and allow other contributors to provide input. +5. All automated tests must pass (all builds on [Travis CI](https://travis-ci.org/magento/magento2) must be green). ## Contribution process -If you are a new GitHub user, we recommend that you create your own [free github account](https://github.com/signup/free). By doing that, you will be able to collaborate with the Magento 2 development team, “fork” the Magento 2 project and be able to easily send “pull requests”. +If you are a new GitHub user, we recommend that you create your own [free github account](https://github.com/signup/free). This will allow you to collaborate with the Magento 2 development team, fork the Magento 2 project and send pull requests. 1. Search current [listed issues](https://github.com/magento/magento2/issues) (open or closed) for similar proposals of intended contribution before starting work on a new contribution. 2. Review the [Contributor License Agreement](https://magento.com/legaldocuments/mca) if this is your first time contributing. 3. Create and test your work. -4. Fork the Magento 2 repository according to [Fork a repository instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#fork) and when you are ready to send us a pull request – follow [Create a pull request instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#pull_request). -5. Once your contribution is received, Magento 2 development team will review the contribution and collaborate with you as needed to improve the quality of the contribution. +4. Fork the Magento 2 repository according to the [Fork A Repository instructions](http://devdocs.magento.com/guides/v2.2/contributor-guide/contributing.html#fork) and when you are ready to send us a pull request – follow the [Create A Pull Request instructions](http://devdocs.magento.com/guides/v2.2/contributor-guide/contributing.html#pull_request). +5. Once your contribution is received the Magento 2 development team will review the contribution and collaborate with you as needed. ## Code of Conduct diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 3ac68076d4353..12ad4e452b1c7 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,24 +1,35 @@ - - + ### Preconditions - - + 1. 2. ### Steps to reproduce - + 1. 2. 3. ### Expected result -1. +1. [Screenshots, logs or description] ### Actual result -1. [Screenshot, logs] - - +1. [Screenshots, logs or description] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d1f01ba9f2640..5b0b9d74e453b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,32 @@ - + + + ### Description - + ### Fixed Issues (if relevant) - + 1. magento/magento2#: Issue title 2. ... ### Manual testing scenarios - + 1. ... 2. ... diff --git a/.travis.yml b/.travis.yml index 99059dbc433f2..cc730ca5a2cd4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,11 +17,12 @@ services: language: php php: - 7.1 + - 7.2 env: global: - COMPOSER_BIN_DIR=~/bin - INTEGRATION_SETS=3 - - NODE_JS_VERSION=6 + - NODE_JS_VERSION=8 - MAGENTO_HOST_NAME="magento2.travis" matrix: - TEST_SUITE=unit @@ -32,6 +33,16 @@ env: - TEST_SUITE=integration INTEGRATION_INDEX=2 - TEST_SUITE=integration INTEGRATION_INDEX=3 - TEST_SUITE=functional +matrix: + exclude: + - php: 7.1 + env: TEST_SUITE=static + - php: 7.1 + env: TEST_SUITE=js GRUNT_COMMAND=spec + - php: 7.1 + env: TEST_SUITE=js GRUNT_COMMAND=static + - php: 7.1 + env: TEST_SUITE=functional cache: apt: true directories: diff --git a/COPYING.txt b/COPYING.txt index 2ba7d78d58a25..040bdd5f3ce72 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ -Copyright © 2013-2018 Magento, Inc. +Copyright © 2013-present Magento, Inc. Each Magento source file included in this distribution is licensed under OSL 3.0 or the Magento Enterprise Edition (MEE) license diff --git a/README.md b/README.md index b20a3738ba775..a2cf536bb6520 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,15 @@ [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/magento/magento2?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/magento-2/localized.png)](https://crowdin.com/project/magento-2)

Welcome

-Welcome to Magento 2 installation! We're glad you chose to install Magento 2, a cutting edge, feature-rich eCommerce solution that gets results. +Welcome to Magento 2 installation! We're glad you chose to install Magento 2, a cutting-edge, feature-rich eCommerce solution that gets results. ## Magento system requirements -[Magento system requirements](http://devdocs.magento.com/guides/v2.2/install-gde/system-requirements2.html) +[Magento system requirements](http://devdocs.magento.com/guides/v2.3/install-gde/system-requirements2.html) ## Install Magento To install Magento, see either: -* [Magento DevBox](https://magento.com/tech-resources/download), the easiest way to get started with Magento. -* [Installation guide](http://devdocs.magento.com/guides/v2.2/install-gde/bk-install-guide.html) +* [Installation guide](http://devdocs.magento.com/guides/v2.3/install-gde/bk-install-guide.html)

Contributing to the Magento 2 code base

Contributions can take the form of new components or features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes, optimizations, or just good suggestions. @@ -23,11 +22,24 @@ To learn about issues, click [here][2]. To open an issue, click [here][3]. To suggest documentation improvements, click [here][4]. -[1]: -[2]: +[1]: +[2]: [3]: [4]: +

Community Maintainers

+The members of this team have been recognized for their outstanding commitment to maintaining and improving Magento. Magento has granted them permission to accept, merge, and reject pull requests, as well as review issues, and thanks these Community Maintainers for their valuable contributions. + + + + + +

Top Contributors

+Magento is thankful for any contribution that can improve our code base, documentation or increase test coverage. We always recognize our most active members, as their contributions are the foundation of the Magento Open Source platform. + + + +

Labels applied by the Magento team

| Label | Description | diff --git a/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php b/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php index 303675b968256..d58a7ec31f77d 100644 --- a/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php +++ b/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php @@ -62,8 +62,10 @@ public function execute() if (empty($result)) { $result[] = [ 'severity' => (string)\Magento\Framework\Notification\MessageInterface::SEVERITY_NOTICE, - 'text' => 'You have viewed and resolved all recent system notices. ' - . 'Please refresh the web page to clear the notice alert.', + 'text' => __( + 'You have viewed and resolved all recent system notices. ' + . 'Please refresh the web page to clear the notice alert.' + ) ]; } /** @var \Magento\Framework\Controller\Result\Json $resultJson */ diff --git a/app/code/Magento/AdminNotification/composer.json b/app/code/Magento/AdminNotification/composer.json index 8267c89a9b182..e5cf487908cd7 100644 --- a/app/code/Magento/AdminNotification/composer.json +++ b/app/code/Magento/AdminNotification/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "lib-libxml": "*", "magento/framework": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/AdminNotification/i18n/en_US.csv b/app/code/Magento/AdminNotification/i18n/en_US.csv index 16c5abb9db0d2..db5a4c9254814 100644 --- a/app/code/Magento/AdminNotification/i18n/en_US.csv +++ b/app/code/Magento/AdminNotification/i18n/en_US.csv @@ -48,3 +48,4 @@ Severity,Severity "Date Added","Date Added" Message,Message Actions,Actions +"You have viewed and resolved all recent system notices. Please refresh the web page to clear the notice alert.","You have viewed and resolved all recent system notices. Please refresh the web page to clear the notice alert." diff --git a/app/code/Magento/AdminNotification/view/adminhtml/templates/system/messages/popup.phtml b/app/code/Magento/AdminNotification/view/adminhtml/templates/system/messages/popup.phtml index a97293547e132..d654504a41e5c 100644 --- a/app/code/Magento/AdminNotification/view/adminhtml/templates/system/messages/popup.phtml +++ b/app/code/Magento/AdminNotification/view/adminhtml/templates/system/messages/popup.phtml @@ -19,20 +19,12 @@ - + \ No newline at end of file diff --git a/app/code/Magento/AdminNotification/view/adminhtml/web/js/system/messages/popup.js b/app/code/Magento/AdminNotification/view/adminhtml/web/js/system/messages/popup.js new file mode 100644 index 0000000000000..f3f6a5fb1a123 --- /dev/null +++ b/app/code/Magento/AdminNotification/view/adminhtml/web/js/system/messages/popup.js @@ -0,0 +1,24 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. +*/ + +define([ + 'jquery', + 'Magento_Ui/js/modal/modal' +], function ($) { + 'use strict'; + + return function (data, element) { + if (this.modal) { + this.modal.html($(element).html()); + } else { + this.modal = $(element).modal({ + modalClass: data.class, + type: 'popup', + buttons: [] + }); + } + this.modal.modal('openModal'); + }; +}); diff --git a/app/code/Magento/AdvancedPricingImportExport/composer.json b/app/code/Magento/AdvancedPricingImportExport/composer.json index 7c963144ad546..12e1d9938f4bd 100644 --- a/app/code/Magento/AdvancedPricingImportExport/composer.json +++ b/app/code/Magento/AdvancedPricingImportExport/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-catalog-import-export": "*", diff --git a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php index 8721b600ed396..c2379e9dff062 100644 --- a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php +++ b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php @@ -10,6 +10,10 @@ use Magento\Framework\Model\ResourceModel\Db\Context; use Magento\Framework\EntityManager\MetadataPool; use Magento\Catalog\Api\Data\CategoryInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver as TableResolver; +use Magento\Framework\Search\Request\Dimension; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; /** * @api @@ -29,22 +33,30 @@ class Index extends AbstractDb */ protected $metadataPool; + /** + * @var TableResolver + */ + private $tableResolver; + /** * Index constructor. * @param Context $context * @param StoreManagerInterface $storeManager * @param MetadataPool $metadataPool * @param null $connectionName + * @param TableResolver|null $tableResolver */ public function __construct( Context $context, StoreManagerInterface $storeManager, MetadataPool $metadataPool, - $connectionName = null + $connectionName = null, + TableResolver $tableResolver = null ) { parent::__construct($context, $connectionName); $this->storeManager = $storeManager; $this->metadataPool = $metadataPool; + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); } /** @@ -116,8 +128,17 @@ public function getCategoryProductIndexData($storeId = null, $productIds = null) { $connection = $this->getConnection(); + $catalogCategoryProductDimension = new Dimension(\Magento\Store\Model\Store::ENTITY, $storeId); + + $catalogCategoryProductTableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); + $select = $connection->select()->from( - [$this->getTable('catalog_category_product_index')], + [$catalogCategoryProductTableName], ['category_id', 'product_id', 'position', 'store_id'] )->where( 'store_id = ?', diff --git a/app/code/Magento/AdvancedSearch/composer.json b/app/code/Magento/AdvancedSearch/composer.json index 9f5b755553484..a224a1001cd01 100644 --- a/app/code/Magento/AdvancedSearch/composer.json +++ b/app/code/Magento/AdvancedSearch/composer.json @@ -13,7 +13,7 @@ "magento/module-customer": "*", "magento/module-search": "*", "magento/module-store": "*", - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0" + "php": "~7.1.3||~7.2.0" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Amqp/Setup/ConfigOptionsList.php b/app/code/Magento/Amqp/Setup/ConfigOptionsList.php index e4126f148d651..7b857dc2bcc2d 100644 --- a/app/code/Magento/Amqp/Setup/ConfigOptionsList.php +++ b/app/code/Magento/Amqp/Setup/ConfigOptionsList.php @@ -25,6 +25,7 @@ class ConfigOptionsList implements ConfigOptionsListInterface const INPUT_KEY_QUEUE_AMQP_PASSWORD = 'amqp-password'; const INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST = 'amqp-virtualhost'; const INPUT_KEY_QUEUE_AMQP_SSL = 'amqp-ssl'; + const INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS = 'amqp-ssl-options'; /** * Path to the values in the deployment config @@ -35,6 +36,7 @@ class ConfigOptionsList implements ConfigOptionsListInterface const CONFIG_PATH_QUEUE_AMQP_PASSWORD = 'queue/amqp/password'; const CONFIG_PATH_QUEUE_AMQP_VIRTUAL_HOST = 'queue/amqp/virtualhost'; const CONFIG_PATH_QUEUE_AMQP_SSL = 'queue/amqp/ssl'; + const CONFIG_PATH_QUEUE_AMQP_SSL_OPTIONS = 'queue/amqp/ssl_options'; /** * Default values @@ -109,6 +111,13 @@ public function getOptions() 'Amqp SSL', self::DEFAULT_AMQP_SSL ), + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS, + TextConfigOption::FRONTEND_WIZARD_TEXTAREA, + self::CONFIG_PATH_QUEUE_AMQP_SSL_OPTIONS, + 'Amqp SSL Options (JSON)', + self::DEFAULT_AMQP_SSL + ), ]; } @@ -140,6 +149,21 @@ public function createConfig(array $data, DeploymentConfig $deploymentConfig) if (!$this->isDataEmpty($data, self::INPUT_KEY_QUEUE_AMQP_SSL)) { $configData->set(self::CONFIG_PATH_QUEUE_AMQP_SSL, $data[self::INPUT_KEY_QUEUE_AMQP_SSL]); } + if (!$this->isDataEmpty( + $data, + self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS + )) { + $options = json_decode( + $data[self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS], + true + ); + if ($options !== null) { + $configData->set( + self::CONFIG_PATH_QUEUE_AMQP_SSL_OPTIONS, + $options + ); + } + } } return [$configData]; @@ -154,12 +178,28 @@ public function validate(array $options, DeploymentConfig $deploymentConfig) if (isset($options[self::INPUT_KEY_QUEUE_AMQP_HOST]) && $options[self::INPUT_KEY_QUEUE_AMQP_HOST] !== '') { + if (!$this->isDataEmpty( + $options, + self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS + )) { + $sslOptions = json_decode( + $options[self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS], + true + ); + } else { + $sslOptions = null; + } + $isSslEnabled = !empty($options[self::INPUT_KEY_QUEUE_AMQP_SSL]) + && $options[self::INPUT_KEY_QUEUE_AMQP_SSL] !== 'false'; + $result = $this->connectionValidator->isConnectionValid( $options[self::INPUT_KEY_QUEUE_AMQP_HOST], $options[self::INPUT_KEY_QUEUE_AMQP_PORT], $options[self::INPUT_KEY_QUEUE_AMQP_USER], $options[self::INPUT_KEY_QUEUE_AMQP_PASSWORD], - $options[self::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST] + $options[self::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST], + $isSslEnabled, + $sslOptions ); if (!$result) { diff --git a/app/code/Magento/Amqp/Setup/ConnectionValidator.php b/app/code/Magento/Amqp/Setup/ConnectionValidator.php index eb17b3517f0e2..55a11286c7c43 100644 --- a/app/code/Magento/Amqp/Setup/ConnectionValidator.php +++ b/app/code/Magento/Amqp/Setup/ConnectionValidator.php @@ -5,13 +5,27 @@ */ namespace Magento\Amqp\Setup; -use PhpAmqpLib\Connection\AMQPStreamConnection; +use Magento\Framework\Amqp\Connection\Factory as ConnectionFactory; +use Magento\Framework\Amqp\Connection\FactoryOptions; /** * Class ConnectionValidator - validates Amqp related settings */ class ConnectionValidator { + /** + * @var ConnectionFactory + */ + private $connectionFactory; + + /** + * @param ConnectionFactory $connectionFactory + */ + public function __construct(ConnectionFactory $connectionFactory) + { + $this->connectionFactory = $connectionFactory; + } + /** * Checks Amqp Connection * @@ -20,18 +34,33 @@ class ConnectionValidator * @param string $user * @param string $password * @param string $virtualHost + * @param bool $ssl + * @param string[]|null $sslOptions * @return bool true if the connection succeeded, false otherwise */ - public function isConnectionValid($host, $port, $user, $password = '', $virtualHost = '') - { + public function isConnectionValid( + $host, + $port, + $user, + $password = '', + $virtualHost = '', + bool $ssl = false, + array $sslOptions = null + ) { try { - $connection = new AMQPStreamConnection( - $host, - $port, - $user, - $password, - $virtualHost - ); + $options = new FactoryOptions(); + $options->setHost($host); + $options->setPort($port); + $options->setUsername($user); + $options->setPassword($password); + $options->setVirtualHost($virtualHost); + $options->setSslEnabled($ssl); + + if ($sslOptions) { + $options->setSslOptions($sslOptions); + } + + $connection = $this->connectionFactory->create($options); $connection->close(); } catch (\Exception $e) { diff --git a/app/code/Magento/Amqp/Test/Unit/Setup/ConfigOptionsListTest.php b/app/code/Magento/Amqp/Test/Unit/Setup/ConfigOptionsListTest.php index 974be6dbff492..8db9ae73034a2 100644 --- a/app/code/Magento/Amqp/Test/Unit/Setup/ConfigOptionsListTest.php +++ b/app/code/Magento/Amqp/Test/Unit/Setup/ConfigOptionsListTest.php @@ -47,7 +47,7 @@ protected function setUp() ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD => 'password', ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST => 'virtual host', ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL => 'ssl', - + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS => '{"ssl_option":"test"}', ]; $this->objectManager = new ObjectManager($this); @@ -113,7 +113,14 @@ public function testGetOptions() ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_SSL, 'Amqp SSL', ConfigOptionsList::DEFAULT_AMQP_SSL - ) + ), + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS, + TextConfigOption::FRONTEND_WIZARD_TEXTAREA, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_SSL_OPTIONS, + 'Amqp SSL Options (JSON)', + ConfigOptionsList::DEFAULT_AMQP_SSL + ), ]; $this->assertEquals($expectedOptions, $this->model->getOptions()); } @@ -167,6 +174,7 @@ public function getCreateConfigDataProvider() ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD => 'password', ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST => 'virtual host', ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL => 'ssl', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS => '{"ssl_option":"test"}', ], ['queue' => ['amqp' => @@ -177,6 +185,7 @@ public function getCreateConfigDataProvider() 'password' => 'password', 'virtualhost' => 'virtual host', 'ssl' => 'ssl', + 'ssl_options' => ['ssl_option' => 'test'], ] ] ], @@ -189,6 +198,7 @@ public function getCreateConfigDataProvider() ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD => 'password', ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST => 'virtual host', ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL => 'ssl', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS => '{"ssl_option":"test"}', ], ['queue' => ['amqp' => @@ -199,6 +209,7 @@ public function getCreateConfigDataProvider() 'password' => 'password', 'virtualhost' => 'virtual host', 'ssl' => 'ssl', + 'ssl_options' => ['ssl_option' => 'test'], ] ] ], diff --git a/app/code/Magento/Amqp/composer.json b/app/code/Magento/Amqp/composer.json index 2c3971e594b4f..23130dfb01a4e 100644 --- a/app/code/Magento/Amqp/composer.json +++ b/app/code/Magento/Amqp/composer.json @@ -8,7 +8,7 @@ "magento/framework": "*", "magento/framework-amqp": "*", "magento/framework-message-queue": "*", - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0" + "php": "~7.1.3||~7.2.0" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Analytics/composer.json b/app/code/Magento/Analytics/composer.json index 7c1e25ab0577c..88127f3c62a92 100644 --- a/app/code/Magento/Analytics/composer.json +++ b/app/code/Magento/Analytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-backend": "*", "magento/module-config": "*", "magento/module-integration": "*", diff --git a/app/code/Magento/Analytics/etc/adminhtml/system.xml b/app/code/Magento/Analytics/etc/adminhtml/system.xml index 889517e629e04..4e21648d00ce8 100644 --- a/app/code/Magento/Analytics/etc/adminhtml/system.xml +++ b/app/code/Magento/Analytics/etc/adminhtml/system.xml @@ -17,14 +17,14 @@ Your reports can be accessed securely on a personalized dashboard outside of the admin panel by clicking on the "Go to Advanced Reporting" link.
For more information, see our terms and conditions.]]> - + Magento\Config\Model\Config\Source\Enabledisable Magento\Analytics\Model\Config\Backend\Enabled Magento\Analytics\Block\Adminhtml\System\Config\SubscriptionStatusLabel analytics/subscription/enabled - + Magento\Analytics\Block\Adminhtml\System\Config\CollectionTimeLabel Magento\Analytics\Model\Config\Backend\CollectionTime diff --git a/app/code/Magento/AsynchronousOperations/Api/BulkStatusInterface.php b/app/code/Magento/AsynchronousOperations/Api/BulkStatusInterface.php index 88db2d6d80141..76410794900e2 100644 --- a/app/code/Magento/AsynchronousOperations/Api/BulkStatusInterface.php +++ b/app/code/Magento/AsynchronousOperations/Api/BulkStatusInterface.php @@ -9,7 +9,8 @@ namespace Magento\AsynchronousOperations\Api; /** - * Interface BulkStatusInterface + * Interface BulkStatusInterface. + * * Bulk summary data with list of operations items short data. * * @api diff --git a/app/code/Magento/AsynchronousOperations/Api/Data/DetailedBulkOperationsStatusInterface.php b/app/code/Magento/AsynchronousOperations/Api/Data/DetailedBulkOperationsStatusInterface.php index 3edc5167e0935..6e39177630857 100644 --- a/app/code/Magento/AsynchronousOperations/Api/Data/DetailedBulkOperationsStatusInterface.php +++ b/app/code/Magento/AsynchronousOperations/Api/Data/DetailedBulkOperationsStatusInterface.php @@ -23,14 +23,14 @@ interface DetailedBulkOperationsStatusInterface extends BulkSummaryInterface /** * Retrieve operations list. * - * @return \Magento\AsynchronousOperations\Api\Data\DetailedOperationStatusInterface[] + * @return \Magento\AsynchronousOperations\Api\Data\OperationInterface[] */ public function getOperationsList(); /** * Set operations list. * - * @param \Magento\AsynchronousOperations\Api\Data\DetailedOperationStatusInterface[] $operationStatusList + * @param \Magento\AsynchronousOperations\Api\Data\OperationInterface[] $operationStatusList * @return $this */ public function setOperationsList($operationStatusList); diff --git a/app/code/Magento/AsynchronousOperations/Api/Data/DetailedOperationStatusInterface.php b/app/code/Magento/AsynchronousOperations/Api/Data/DetailedOperationStatusInterface.php deleted file mode 100644 index 217d4ad9279b6..0000000000000 --- a/app/code/Magento/AsynchronousOperations/Api/Data/DetailedOperationStatusInterface.php +++ /dev/null @@ -1,30 +0,0 @@ -entityManager = $entityManager; @@ -65,7 +65,6 @@ public function changeOperationStatus( $operationEntity->setResultMessage($message); $operationEntity->setSerializedData($data); $operationEntity->setResultSerializedData($resultData); - $operationEntity->setResultSerializedData($resultData); $this->entityManager->save($operationEntity); } catch (\Exception $exception) { $this->logger->critical($exception->getMessage()); diff --git a/app/code/Magento/AsynchronousOperations/Model/OperationRepository.php b/app/code/Magento/AsynchronousOperations/Model/OperationRepository.php new file mode 100644 index 0000000000000..ec76ff6519757 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/OperationRepository.php @@ -0,0 +1,108 @@ +entityManager = $entityManager; + $this->collectionFactory = $collectionFactory; + $this->searchResultFactory = $searchResultFactory; + $this->joinProcessor = $joinProcessor; + $this->operationExtensionFactory = $operationExtension; + $this->collectionProcessor = $collectionProcessor; + $this->logger = $logger; + $this->collectionProcessor = $collectionProcessor; + } + + /** + * @inheritDoc + */ + public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria) + { + /** @var \Magento\AsynchronousOperations\Api\Data\OperationSearchResultsInterface $searchResult */ + $searchResult = $this->searchResultFactory->create(); + + /** @var \Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection $collection */ + $collection = $this->collectionFactory->create(); + $this->joinProcessor->process($collection, \Magento\AsynchronousOperations\Api\Data\OperationInterface::class); + $this->collectionProcessor->process($searchCriteria, $collection); + $searchResult->setSearchCriteria($searchCriteria); + $searchResult->setTotalCount($collection->getSize()); + $searchResult->setItems($collection->getItems()); + + return $searchResult; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/OperationManagementTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/OperationManagementTest.php index 0a4e5f2f3ecc3..4c3e240da2a8a 100644 --- a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/OperationManagementTest.php +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/OperationManagementTest.php @@ -40,11 +40,11 @@ protected function setUp() $this->entityManagerMock = $this->createMock(\Magento\Framework\EntityManager\EntityManager::class); $this->metadataPoolMock = $this->createMock(\Magento\Framework\EntityManager\MetadataPool::class); $this->operationFactoryMock = $this->createPartialMock( - \Magento\AsynchronousOperations\Api\Data\DetailedOperationStatusInterfaceFactory::class, + \Magento\AsynchronousOperations\Api\Data\OperationInterfaceFactory::class, ['create'] ); $this->operationMock = - $this->createMock(\Magento\AsynchronousOperations\Api\Data\DetailedOperationStatusInterface::class); + $this->createMock(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class); $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); $this->model = new \Magento\AsynchronousOperations\Model\OperationManagement( $this->entityManagerMock, diff --git a/app/code/Magento/AsynchronousOperations/composer.json b/app/code/Magento/AsynchronousOperations/composer.json index bcff433ff2094..3acb92710e62b 100644 --- a/app/code/Magento/AsynchronousOperations/composer.json +++ b/app/code/Magento/AsynchronousOperations/composer.json @@ -11,7 +11,7 @@ "magento/module-backend": "*", "magento/module-ui": "*", "magento/module-user": "*", - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0" + "php": "~7.1.3||~7.2.0" }, "suggest": { "magento/module-admin-notification": "*", diff --git a/app/code/Magento/AsynchronousOperations/etc/di.xml b/app/code/Magento/AsynchronousOperations/etc/di.xml index c8fee29cd6838..42b62ff8ea374 100644 --- a/app/code/Magento/AsynchronousOperations/etc/di.xml +++ b/app/code/Magento/AsynchronousOperations/etc/di.xml @@ -8,7 +8,6 @@ - @@ -17,10 +16,12 @@ + + - + magento_operation id diff --git a/app/code/Magento/AsynchronousOperations/etc/extension_attributes.xml b/app/code/Magento/AsynchronousOperations/etc/extension_attributes.xml new file mode 100644 index 0000000000000..6eeda62373f06 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/extension_attributes.xml @@ -0,0 +1,19 @@ + + + + + + + + + + start_time + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/webapi.xml b/app/code/Magento/AsynchronousOperations/etc/webapi.xml index 253dedd1c7a0c..4c10a5756c8d6 100644 --- a/app/code/Magento/AsynchronousOperations/etc/webapi.xml +++ b/app/code/Magento/AsynchronousOperations/etc/webapi.xml @@ -29,4 +29,11 @@ + + + + + + + diff --git a/app/code/Magento/Authorization/composer.json b/app/code/Magento/Authorization/composer.json index ab0e6cad0ed55..5f5e7c62ef83b 100644 --- a/app/code/Magento/Authorization/composer.json +++ b/app/code/Magento/Authorization/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*" }, diff --git a/app/code/Magento/Authorizenet/Model/Directpost.php b/app/code/Magento/Authorizenet/Model/Directpost.php index 0f10fd633cb5b..5476fd05a0fed 100644 --- a/app/code/Magento/Authorizenet/Model/Directpost.php +++ b/app/code/Magento/Authorizenet/Model/Directpost.php @@ -5,10 +5,9 @@ */ namespace Magento\Authorizenet\Model; -use Magento\Framework\HTTP\ZendClientFactory; +use Magento\Framework\App\ObjectManager; use Magento\Payment\Model\Method\ConfigInterface; use Magento\Payment\Model\Method\TransparentInterface; -use Magento\Sales\Model\Order\Email\Sender\OrderSender; /** * Authorize.net DirectPost payment method model. @@ -102,7 +101,7 @@ class Directpost extends \Magento\Authorizenet\Model\Authorizenet implements Tra protected $response; /** - * @var OrderSender + * @var \Magento\Sales\Model\Order\Email\Sender\OrderSender */ protected $orderSender; @@ -123,6 +122,16 @@ class Directpost extends \Magento\Authorizenet\Model\Authorizenet implements Tra */ private $psrLogger; + /** + * @var \Magento\Sales\Api\PaymentFailuresInterface + */ + private $paymentFailures; + + /** + * @var \Magento\Sales\Model\Order + */ + private $order; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -134,18 +143,19 @@ class Directpost extends \Magento\Authorizenet\Model\Authorizenet implements Tra * @param \Magento\Framework\Module\ModuleListInterface $moduleList * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate * @param \Magento\Authorizenet\Helper\Data $dataHelper - * @param Directpost\Request\Factory $requestFactory - * @param Directpost\Response\Factory $responseFactory + * @param \Magento\Authorizenet\Model\Directpost\Request\Factory $requestFactory + * @param \Magento\Authorizenet\Model\Directpost\Response\Factory $responseFactory * @param \Magento\Authorizenet\Model\TransactionService $transactionService * @param \Magento\Framework\HTTP\ZendClientFactory $httpClientFactory * @param \Magento\Sales\Model\OrderFactory $orderFactory * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Quote\Api\CartRepositoryInterface $quoteRepository - * @param OrderSender $orderSender + * @param \Magento\Sales\Model\Order\Email\Sender\OrderSender $orderSender * @param \Magento\Sales\Api\TransactionRepositoryInterface $transactionRepository * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data + * @param \Magento\Sales\Api\PaymentFailuresInterface|null $paymentFailures * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -161,8 +171,8 @@ public function __construct( \Magento\Authorizenet\Helper\Data $dataHelper, \Magento\Authorizenet\Model\Directpost\Request\Factory $requestFactory, \Magento\Authorizenet\Model\Directpost\Response\Factory $responseFactory, - TransactionService $transactionService, - ZendClientFactory $httpClientFactory, + \Magento\Authorizenet\Model\TransactionService $transactionService, + \Magento\Framework\HTTP\ZendClientFactory $httpClientFactory, \Magento\Sales\Model\OrderFactory $orderFactory, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Quote\Api\CartRepositoryInterface $quoteRepository, @@ -170,7 +180,8 @@ public function __construct( \Magento\Sales\Api\TransactionRepositoryInterface $transactionRepository, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + \Magento\Sales\Api\PaymentFailuresInterface $paymentFailures = null ) { $this->orderFactory = $orderFactory; $this->storeManager = $storeManager; @@ -179,6 +190,8 @@ public function __construct( $this->orderSender = $orderSender; $this->transactionRepository = $transactionRepository; $this->_code = static::METHOD_CODE; + $this->paymentFailures = $paymentFailures ? : ObjectManager::getInstance() + ->get(\Magento\Sales\Api\PaymentFailuresInterface::class); parent::__construct( $context, @@ -561,13 +574,10 @@ public function process(array $responseData) $this->validateResponse(); $response = $this->getResponse(); - //operate with order - $orderIncrementId = $response->getXInvoiceNum(); $responseText = $this->dataHelper->wrapGatewayError($response->getXResponseReasonText()); $isError = false; - if ($orderIncrementId) { - /* @var $order \Magento\Sales\Model\Order */ - $order = $this->orderFactory->create()->loadByIncrementId($orderIncrementId); + if ($this->getOrderIncrementId()) { + $order = $this->getOrderFromResponse(); //check payment method $payment = $order->getPayment(); if (!$payment || $payment->getMethod() != $this->getCode()) { @@ -632,9 +642,10 @@ public function checkResponseCode() return true; case self::RESPONSE_CODE_DECLINED: case self::RESPONSE_CODE_ERROR: - throw new \Magento\Framework\Exception\LocalizedException( - $this->dataHelper->wrapGatewayError($this->getResponse()->getXResponseReasonText()) - ); + $errorMessage = $this->dataHelper->wrapGatewayError($this->getResponse()->getXResponseReasonText()); + $order = $this->getOrderFromResponse(); + $this->paymentFailures->handle((int)$order->getQuoteId(), $errorMessage); + throw new \Magento\Framework\Exception\LocalizedException($errorMessage); default: throw new \Magento\Framework\Exception\LocalizedException( __('There was a payment authorization error.') @@ -988,12 +999,40 @@ protected function getTransactionResponse($transactionId) private function getPsrLogger() { if (null === $this->psrLogger) { - $this->psrLogger = \Magento\Framework\App\ObjectManager::getInstance() + $this->psrLogger = ObjectManager::getInstance() ->get(\Psr\Log\LoggerInterface::class); } return $this->psrLogger; } + /** + * Fetch order by increment id from response. + * + * @return \Magento\Sales\Model\Order + */ + private function getOrderFromResponse(): \Magento\Sales\Model\Order + { + if (!$this->order) { + $this->order = $this->orderFactory->create(); + + if ($incrementId = $this->getOrderIncrementId()) { + $this->order = $this->order->loadByIncrementId($incrementId); + } + } + + return $this->order; + } + + /** + * Fetch order increment id from response. + * + * @return string + */ + private function getOrderIncrementId(): string + { + return $this->getResponse()->getXInvoiceNum(); + } + /** * Checks if filter action is Report Only. Transactions that trigger this filter are processed as normal, * but are also reported in the Merchant Interface as triggering this filter. diff --git a/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php b/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php index dbb6ac8333c14..95c67f67852da 100644 --- a/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php +++ b/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php @@ -5,6 +5,7 @@ */ namespace Magento\Authorizenet\Test\Unit\Model; +use Magento\Sales\Api\PaymentFailuresInterface; use Magento\Framework\Simplexml\Element; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; use Magento\Authorizenet\Model\Directpost; @@ -74,6 +75,14 @@ class DirectpostTest extends \PHPUnit\Framework\TestCase */ protected $requestFactory; + /** + * @var PaymentFailuresInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentFailures; + + /** + * @inheritdoc + */ protected function setUp() { $this->scopeConfigMock = $this->getMockBuilder(\Magento\Framework\App\Config\ScopeConfigInterface::class) @@ -104,6 +113,12 @@ protected function setUp() ->setMethods(['getTransactionDetails']) ->getMock(); + $this->paymentFailures = $this->getMockBuilder( + PaymentFailuresInterface::class + ) + ->disableOriginalConstructor() + ->getMock(); + $this->requestFactory = $this->getRequestFactoryMock(); $httpClientFactoryMock = $this->getHttpClientFactoryMock(); @@ -117,7 +132,8 @@ protected function setUp() 'responseFactory' => $this->responseFactoryMock, 'transactionRepository' => $this->transactionRepositoryMock, 'transactionService' => $this->transactionServiceMock, - 'httpClientFactory' => $httpClientFactoryMock + 'httpClientFactory' => $httpClientFactoryMock, + 'paymentFailures' => $this->paymentFailures, ] ); } @@ -313,12 +329,16 @@ public function checkResponseCodeSuccessDataProvider() } /** - * @param bool $responseCode + * Checks response failures behaviour. + * + * @param int $responseCode + * @param int $failuresHandlerCalls + * @return void * * @expectedException \Magento\Framework\Exception\LocalizedException * @dataProvider checkResponseCodeFailureDataProvider */ - public function testCheckResponseCodeFailure($responseCode) + public function testCheckResponseCodeFailure(int $responseCode, int $failuresHandlerCalls): void { $reasonText = 'reason text'; @@ -333,18 +353,35 @@ public function testCheckResponseCodeFailure($responseCode) ->with($reasonText) ->willReturn(__('Gateway error: %1', $reasonText)); + $orderMock = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->getMock(); + + $orderMock->expects($this->exactly($failuresHandlerCalls)) + ->method('getQuoteId') + ->willReturn(1); + + $this->paymentFailures->expects($this->exactly($failuresHandlerCalls)) + ->method('handle') + ->with(1); + + $reflection = new \ReflectionClass($this->directpost); + $order = $reflection->getProperty('order'); + $order->setAccessible(true); + $order->setValue($this->directpost, $orderMock); + $this->directpost->checkResponseCode(); } /** * @return array */ - public function checkResponseCodeFailureDataProvider() + public function checkResponseCodeFailureDataProvider(): array { return [ - ['responseCode' => Directpost::RESPONSE_CODE_DECLINED], - ['responseCode' => Directpost::RESPONSE_CODE_ERROR], - ['responseCode' => 999999] + ['responseCode' => Directpost::RESPONSE_CODE_DECLINED, 1], + ['responseCode' => Directpost::RESPONSE_CODE_ERROR, 1], + ['responseCode' => 999999, 0], ]; } diff --git a/app/code/Magento/Authorizenet/composer.json b/app/code/Magento/Authorizenet/composer.json index 35059c6319a67..31f2295da4307 100644 --- a/app/code/Magento/Authorizenet/composer.json +++ b/app/code/Magento/Authorizenet/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/Backend/App/AbstractAction.php b/app/code/Magento/Backend/App/AbstractAction.php index 99ee86b2b6407..3f658ee90bf4e 100644 --- a/app/code/Magento/Backend/App/AbstractAction.php +++ b/app/code/Magento/Backend/App/AbstractAction.php @@ -217,6 +217,7 @@ public function dispatch(\Magento\Framework\App\RequestInterface $request) $this->_view->loadLayout(['default', 'adminhtml_denied'], true, true, false); $this->_view->renderLayout(); $this->_request->setDispatched(true); + return $this->_response; } @@ -226,6 +227,11 @@ public function dispatch(\Magento\Framework\App\RequestInterface $request) $this->_processLocaleSettings(); + // Need to preload isFirstPageAfterLogin (see https://github.com/magento/magento2/issues/15510) + if ($this->_auth->isLoggedIn()) { + $this->_auth->getAuthStorage()->isFirstPageAfterLogin(); + } + return parent::dispatch($request); } diff --git a/app/code/Magento/Backend/Block/Dashboard.php b/app/code/Magento/Backend/Block/Dashboard.php index 8d0a061621fe3..e1e87d8d4c5a3 100644 --- a/app/code/Magento/Backend/Block/Dashboard.php +++ b/app/code/Magento/Backend/Block/Dashboard.php @@ -20,7 +20,7 @@ class Dashboard extends \Magento\Backend\Block\Template /** * @var string */ - protected $_template = 'dashboard/index.phtml'; + protected $_template = 'Magento_Backend::dashboard/index.phtml'; /** * @return void diff --git a/app/code/Magento/Backend/Block/Dashboard/Graph.php b/app/code/Magento/Backend/Block/Dashboard/Graph.php index 301dffbdc4987..8e238ccab44cb 100644 --- a/app/code/Magento/Backend/Block/Dashboard/Graph.php +++ b/app/code/Magento/Backend/Block/Dashboard/Graph.php @@ -90,7 +90,7 @@ class Graph extends \Magento\Backend\Block\Dashboard\AbstractDashboard /** * @var string */ - protected $_template = 'dashboard/graph.phtml'; + protected $_template = 'Magento_Backend::dashboard/graph.phtml'; /** * Adminhtml dashboard data diff --git a/app/code/Magento/Backend/Block/Dashboard/Grid.php b/app/code/Magento/Backend/Block/Dashboard/Grid.php index 602b5e414d538..f7f9a79f17eb0 100644 --- a/app/code/Magento/Backend/Block/Dashboard/Grid.php +++ b/app/code/Magento/Backend/Block/Dashboard/Grid.php @@ -17,7 +17,7 @@ class Grid extends \Magento\Backend\Block\Widget\Grid\Extended /** * @var string */ - protected $_template = 'dashboard/grid.phtml'; + protected $_template = 'Magento_Backend::dashboard/grid.phtml'; /** * Setting default for every grid on dashboard diff --git a/app/code/Magento/Backend/Block/Dashboard/Sales.php b/app/code/Magento/Backend/Block/Dashboard/Sales.php index d0f056230bcd1..6d7a4d6458a8e 100644 --- a/app/code/Magento/Backend/Block/Dashboard/Sales.php +++ b/app/code/Magento/Backend/Block/Dashboard/Sales.php @@ -15,7 +15,7 @@ class Sales extends \Magento\Backend\Block\Dashboard\Bar /** * @var string */ - protected $_template = 'dashboard/salebar.phtml'; + protected $_template = 'Magento_Backend::dashboard/salebar.phtml'; /** * @var \Magento\Framework\Module\Manager diff --git a/app/code/Magento/Backend/Block/Dashboard/Totals.php b/app/code/Magento/Backend/Block/Dashboard/Totals.php index 96ae6dd636380..4dcda3677584c 100644 --- a/app/code/Magento/Backend/Block/Dashboard/Totals.php +++ b/app/code/Magento/Backend/Block/Dashboard/Totals.php @@ -16,7 +16,7 @@ class Totals extends \Magento\Backend\Block\Dashboard\Bar /** * @var string */ - protected $_template = 'dashboard/totalbar.phtml'; + protected $_template = 'Magento_Backend::dashboard/totalbar.phtml'; /** * @var \Magento\Framework\Module\Manager diff --git a/app/code/Magento/Backend/Block/GlobalSearch.php b/app/code/Magento/Backend/Block/GlobalSearch.php index f4a46283808f4..3cea12fea205c 100644 --- a/app/code/Magento/Backend/Block/GlobalSearch.php +++ b/app/code/Magento/Backend/Block/GlobalSearch.php @@ -31,6 +31,7 @@ public function getWidgetInitOptions() 'filterProperty' => 'name', 'preventClickPropagation' => false, 'minLength' => 2, + 'submitInputOnEnter' => false, ] ]; } diff --git a/app/code/Magento/Backend/Block/Menu.php b/app/code/Magento/Backend/Block/Menu.php index d6bdeb4ea8968..7d86497288a69 100644 --- a/app/code/Magento/Backend/Block/Menu.php +++ b/app/code/Magento/Backend/Block/Menu.php @@ -352,7 +352,7 @@ protected function _addSubMenu($menuItem, $level, $limit, $id = null) return $output; } $output .= ' - + diff --git a/app/code/Magento/Backend/view/adminhtml/templates/widget/grid.phtml b/app/code/Magento/Backend/view/adminhtml/templates/widget/grid.phtml index c99759a60d1bf..c665c1095a549 100644 --- a/app/code/Magento/Backend/view/adminhtml/templates/widget/grid.phtml +++ b/app/code/Magento/Backend/view/adminhtml/templates/widget/grid.phtml @@ -19,7 +19,7 @@ * */ /* @var $block \Magento\Backend\Block\Widget\Grid */ -$numColumns = sizeof($block->getColumns()); +$numColumns = !is_null($block->getColumns()) ? sizeof($block->getColumns()) : 0; ?> getCollection()): ?> diff --git a/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js b/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js index 03403a4ec4a04..f9f43cebc592b 100644 --- a/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js +++ b/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js @@ -13,11 +13,19 @@ define([ 'jquery', 'mage/template', 'Magento_Ui/js/modal/alert', + 'Magento_Ui/js/form/element/file-uploader', 'mage/translate', 'jquery/file-uploader' -], function ($, mageTemplate, alert) { +], function ($, mageTemplate, alert, FileUploader) { 'use strict'; + var fileUploader = new FileUploader({ + dataScope: '', + isMultipleFiles: true + }); + + fileUploader.initUploader(); + $.widget('mage.mediaUploader', { /** @@ -79,10 +87,9 @@ define([ if (data.result && !data.result.error) { self.element.trigger('addItem', data.result); } else { - alert({ - content: $.mage.__('We don\'t recognize or support this file extension type.') - }); + fileUploader.aggregateError(data.files[0].name, data.result.error); } + self.element.find('#' + data.fileId).remove(); }, @@ -108,7 +115,9 @@ define([ .delay(2000) .hide('highlight') .remove(); - } + }, + + stop: fileUploader.uploaderConfig.stop }); this.element.find('input[type=file]').fileupload('option', { diff --git a/app/code/Magento/Backup/composer.json b/app/code/Magento/Backup/composer.json index edd1a4e9707bc..81225430b0fc8 100644 --- a/app/code/Magento/Backup/composer.json +++ b/app/code/Magento/Backup/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-cron": "*", diff --git a/app/code/Magento/Braintree/Gateway/Response/CancelDetailsHandler.php b/app/code/Magento/Braintree/Gateway/Response/CancelDetailsHandler.php new file mode 100644 index 0000000000000..3d6ed025791bf --- /dev/null +++ b/app/code/Magento/Braintree/Gateway/Response/CancelDetailsHandler.php @@ -0,0 +1,43 @@ +subjectReader = $subjectReader; + } + + /** + * @inheritdoc + */ + public function handle(array $handlingSubject, array $response) + { + $paymentDO = $this->subjectReader->readPayment($handlingSubject); + /** @var Payment $orderPayment */ + $orderPayment = $paymentDO->getPayment(); + $orderPayment->setIsTransactionClosed(true); + $orderPayment->setShouldCloseParentTransaction(true); + } +} diff --git a/app/code/Magento/Braintree/Gateway/SubjectReader.php b/app/code/Magento/Braintree/Gateway/SubjectReader.php index d5dc43a4c5e34..7cf00233e7f8f 100644 --- a/app/code/Magento/Braintree/Gateway/SubjectReader.php +++ b/app/code/Magento/Braintree/Gateway/SubjectReader.php @@ -43,19 +43,20 @@ public function readPayment(array $subject) } /** - * Reads transaction from subject + * Reads transaction from the subject. * * @param array $subject - * @return \Braintree\Transaction + * @return Transaction + * @throws \InvalidArgumentException if the subject doesn't contain transaction details. */ public function readTransaction(array $subject) { if (!isset($subject['object']) || !is_object($subject['object'])) { - throw new \InvalidArgumentException('Response object does not exist'); + throw new \InvalidArgumentException('Response object does not exist.'); } if (!isset($subject['object']->transaction) - && !$subject['object']->transaction instanceof Transaction + || !$subject['object']->transaction instanceof Transaction ) { throw new \InvalidArgumentException('The object is not a class \Braintree\Transaction.'); } diff --git a/app/code/Magento/Braintree/Gateway/Validator/CancelResponseValidator.php b/app/code/Magento/Braintree/Gateway/Validator/CancelResponseValidator.php new file mode 100644 index 0000000000000..5e31547e9503c --- /dev/null +++ b/app/code/Magento/Braintree/Gateway/Validator/CancelResponseValidator.php @@ -0,0 +1,90 @@ +generalResponseValidator = $generalResponseValidator; + $this->subjectReader = $subjectReader; + } + + /** + * @inheritdoc + */ + public function validate(array $validationSubject): ResultInterface + { + $result = $this->generalResponseValidator->validate($validationSubject); + if (!$result->isValid()) { + $response = $this->subjectReader->readResponseObject($validationSubject); + if ($this->isErrorAcceptable($response->errors)) { + $result = $this->createResult(true, [__('Transaction is cancelled offline.')]); + } + } + + return $result; + } + + /** + * Checks if error collection has an acceptable error code. + * + * @param ErrorCollection $errorCollection + * @return bool + */ + private function isErrorAcceptable(ErrorCollection $errorCollection): bool + { + $errors = $errorCollection->deepAll(); + // there is should be only one acceptable error + if (count($errors) > 1) { + return false; + } + + /** @var Validation $error */ + $error = array_pop($errors); + + return (int)$error->code === self::$acceptableTransactionCode; + } +} diff --git a/app/code/Magento/Braintree/Gateway/Validator/ErrorCodeProvider.php b/app/code/Magento/Braintree/Gateway/Validator/ErrorCodeProvider.php new file mode 100644 index 0000000000000..167fcb1569cbf --- /dev/null +++ b/app/code/Magento/Braintree/Gateway/Validator/ErrorCodeProvider.php @@ -0,0 +1,43 @@ +errors; + + /** @var Validation $error */ + foreach ($collection->deepAll() as $error) { + $result[] = $error->code; + } + + return $result; + } +} diff --git a/app/code/Magento/Braintree/Gateway/Validator/GeneralResponseValidator.php b/app/code/Magento/Braintree/Gateway/Validator/GeneralResponseValidator.php index 8028bace0cf24..6aac588c38374 100644 --- a/app/code/Magento/Braintree/Gateway/Validator/GeneralResponseValidator.php +++ b/app/code/Magento/Braintree/Gateway/Validator/GeneralResponseValidator.php @@ -18,16 +18,26 @@ class GeneralResponseValidator extends AbstractValidator */ protected $subjectReader; + /** + * @var ErrorCodeProvider + */ + private $errorCodeProvider; + /** * Constructor * * @param ResultInterfaceFactory $resultFactory * @param SubjectReader $subjectReader + * @param ErrorCodeProvider $errorCodeProvider */ - public function __construct(ResultInterfaceFactory $resultFactory, SubjectReader $subjectReader) - { + public function __construct( + ResultInterfaceFactory $resultFactory, + SubjectReader $subjectReader, + ErrorCodeProvider $errorCodeProvider + ) { parent::__construct($resultFactory); $this->subjectReader = $subjectReader; + $this->errorCodeProvider = $errorCodeProvider; } /** @@ -49,8 +59,9 @@ public function validate(array $validationSubject) $errorMessages = array_merge($errorMessages, $validationResult[1]); } } + $errorCodes = $this->errorCodeProvider->getErrorCodes($response); - return $this->createResult($isValid, $errorMessages); + return $this->createResult($isValid, $errorMessages, $errorCodes); } /** @@ -62,7 +73,7 @@ protected function getResponseValidators() function ($response) { return [ property_exists($response, 'success') && $response->success === true, - [__('Braintree error response.')] + [$response->message ?? __('Braintree error response.')] ]; } ]; diff --git a/app/code/Magento/Braintree/Model/Adminhtml/System/Config/CountryCreditCard.php b/app/code/Magento/Braintree/Model/Adminhtml/System/Config/CountryCreditCard.php index f68b7eca047f5..2a9923a333cef 100644 --- a/app/code/Magento/Braintree/Model/Adminhtml/System/Config/CountryCreditCard.php +++ b/app/code/Magento/Braintree/Model/Adminhtml/System/Config/CountryCreditCard.php @@ -66,6 +66,13 @@ public function __construct( public function beforeSave() { $value = $this->getValue(); + if (!is_array($value)) { + try { + $value = $this->serializer->unserialize($value); + } catch (\InvalidArgumentException $e) { + $value = []; + } + } $result = []; foreach ($value as $data) { if (empty($data['country_id']) || empty($data['cc_types'])) { diff --git a/app/code/Magento/Braintree/Model/AvsEmsCodeMapper.php b/app/code/Magento/Braintree/Model/AvsEmsCodeMapper.php index 1d5057d83d6cf..f9fae8a469b1d 100644 --- a/app/code/Magento/Braintree/Model/AvsEmsCodeMapper.php +++ b/app/code/Magento/Braintree/Model/AvsEmsCodeMapper.php @@ -24,7 +24,7 @@ class AvsEmsCodeMapper implements PaymentVerificationInterface * * @var string */ - private static $unavailableCode = 'U'; + private static $unavailableCode = ''; /** * List of mapping AVS codes diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Command/GetPaymentNonceCommandTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Command/GetPaymentNonceCommandTest.php index bf15d038297bc..23167af02a97b 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/Command/GetPaymentNonceCommandTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Command/GetPaymentNonceCommandTest.php @@ -99,7 +99,7 @@ protected function setUp() ->getMock(); $this->validationResultMock = $this->getMockBuilder(ResultInterface::class) - ->setMethods(['isValid', 'getFailsDescription']) + ->setMethods(['isValid', 'getFailsDescription', 'getErrorCodes']) ->getMock(); $this->responseValidatorMock = $this->getMockBuilder(PaymentNonceResponseValidator::class) diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Response/CancelDetailsHandlerTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Response/CancelDetailsHandlerTest.php new file mode 100644 index 0000000000000..2fa3d2ea65836 --- /dev/null +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Response/CancelDetailsHandlerTest.php @@ -0,0 +1,61 @@ +handler = new CancelDetailsHandler(new SubjectReader()); + } + + /** + * Checks a case when cancel handler closes the current and parent transactions. + * + * @return void + */ + public function testHandle(): void + { + /** @var OrderAdapterInterface|MockObject $order */ + $order = $this->getMockForAbstractClass(OrderAdapterInterface::class); + /** @var Payment|MockObject $payment */ + $payment = $this->getMockBuilder(Payment::class) + ->disableOriginalConstructor() + ->setMethods(['setOrder']) + ->getMock(); + + $paymentDO = new PaymentDataObject($order, $payment); + $response = [ + 'payment' => $paymentDO, + ]; + + $this->handler->handle($response, []); + + self::assertTrue($payment->getIsTransactionClosed(), 'The current transaction should be closed.'); + self::assertTrue($payment->getShouldCloseParentTransaction(), 'The parent transaction should be closed.'); + } +} diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Response/PayPal/VaultDetailsHandlerTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Response/PayPal/VaultDetailsHandlerTest.php index ebadc1703ecad..b3a7f8b9ee76a 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/Response/PayPal/VaultDetailsHandlerTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Response/PayPal/VaultDetailsHandlerTest.php @@ -88,7 +88,7 @@ protected function setUp() $this->paymentInfoMock = $this->getMockBuilder(Payment::class) ->disableOriginalConstructor() - ->setMethods(['__wakeup']) + ->setMethods(['__wakeup', 'getExtensionAttributes']) ->getMock(); $this->paymentTokenMock = $objectManager->getObject(PaymentToken::class); @@ -107,6 +107,10 @@ protected function setUp() ->setMethods(['create']) ->getMock(); + $this->paymentInfoMock->expects(self::any()) + ->method('getExtensionAttributes') + ->willReturn($this->paymentExtensionMock); + $this->subject = [ 'payment' => $this->paymentDataObjectMock, ]; @@ -184,7 +188,7 @@ public function testHandleWithoutToken() ->method('create'); $this->handler->handle($this->subject, $response); - self::assertNull($this->paymentInfoMock->getExtensionAttributes()); + self::assertNotNull($this->paymentInfoMock->getExtensionAttributes()); } /** diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Response/VaultDetailsHandlerTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Response/VaultDetailsHandlerTest.php index 74592c6869ed3..c8ec52560be29 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/Response/VaultDetailsHandlerTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Response/VaultDetailsHandlerTest.php @@ -80,9 +80,11 @@ protected function setUp() $this->payment = $this->getMockBuilder(Payment::class) ->disableOriginalConstructor() - ->setMethods(['__wakeup']) + ->setMethods(['__wakeup', 'getExtensionAttributes']) ->getMock(); + $this->payment->expects(self::any())->method('getExtensionAttributes')->willReturn($this->paymentExtension); + $config = $this->getConfigMock(); $this->paymentHandler = new VaultDetailsHandler( diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/SubjectReaderTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/SubjectReaderTest.php index 4213acc8b4ff0..fd524a10ba531 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/SubjectReaderTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/SubjectReaderTest.php @@ -5,6 +5,7 @@ */ namespace Magento\Braintree\Test\Unit\Gateway; +use Braintree\Result\Successful; use Braintree\Transaction; use Magento\Braintree\Gateway\SubjectReader; @@ -18,6 +19,9 @@ class SubjectReaderTest extends \PHPUnit\Framework\TestCase */ private $subjectReader; + /** + * @inheritdoc + */ protected function setUp() { $this->subjectReader = new SubjectReader(); @@ -27,67 +31,137 @@ protected function setUp() * @covers \Magento\Braintree\Gateway\SubjectReader::readCustomerId * @expectedException \InvalidArgumentException * @expectedExceptionMessage The "customerId" field does not exists + * @return void */ - public function testReadCustomerIdWithException() + public function testReadCustomerIdWithException(): void { $this->subjectReader->readCustomerId([]); } /** * @covers \Magento\Braintree\Gateway\SubjectReader::readCustomerId + * @return void */ - public function testReadCustomerId() + public function testReadCustomerId(): void { $customerId = 1; - static::assertEquals($customerId, $this->subjectReader->readCustomerId(['customer_id' => $customerId])); + $this->assertEquals($customerId, $this->subjectReader->readCustomerId(['customer_id' => $customerId])); } /** * @covers \Magento\Braintree\Gateway\SubjectReader::readPublicHash * @expectedException \InvalidArgumentException * @expectedExceptionMessage The "public_hash" field does not exists + * @return void */ - public function testReadPublicHashWithException() + public function testReadPublicHashWithException(): void { $this->subjectReader->readPublicHash([]); } /** * @covers \Magento\Braintree\Gateway\SubjectReader::readPublicHash + * @return void */ - public function testReadPublicHash() + public function testReadPublicHash(): void { $hash = 'fj23djf2o1fd'; - static::assertEquals($hash, $this->subjectReader->readPublicHash(['public_hash' => $hash])); + $this->assertEquals($hash, $this->subjectReader->readPublicHash(['public_hash' => $hash])); } /** * @covers \Magento\Braintree\Gateway\SubjectReader::readPayPal * @expectedException \InvalidArgumentException * @expectedExceptionMessage Transaction has't paypal attribute + * @return void */ - public function testReadPayPalWithException() + public function testReadPayPalWithException(): void { $transaction = Transaction::factory([ - 'id' => 'u38rf8kg6vn' + 'id' => 'u38rf8kg6vn', ]); $this->subjectReader->readPayPal($transaction); } /** * @covers \Magento\Braintree\Gateway\SubjectReader::readPayPal + * @return void */ - public function testReadPayPal() + public function testReadPayPal(): void { $paypal = [ 'paymentId' => '3ek7dk7fn0vi1', - 'payerEmail' => 'payer@example.com' + 'payerEmail' => 'payer@example.com', ]; $transaction = Transaction::factory([ 'id' => '4yr95vb', - 'paypal' => $paypal + 'paypal' => $paypal, ]); - static::assertEquals($paypal, $this->subjectReader->readPayPal($transaction)); + $this->assertEquals($paypal, $this->subjectReader->readPayPal($transaction)); + } + + /** + * Checks a case when subject reader retrieves successful Braintree transaction. + * + * @return void + */ + public function testReadTransaction(): void + { + $transaction = Transaction::factory(['id' => 1]); + $response = [ + 'object' => new Successful($transaction, 'transaction'), + ]; + $actual = $this->subjectReader->readTransaction($response); + + $this->assertSame($transaction, $actual); + } + + /** + * Checks a case when subject reader retrieves invalid data instead transaction details. + * + * @param array $response + * @param string $expectedMessage + * @dataProvider invalidTransactionResponseDataProvider + * @expectedException \InvalidArgumentException + * @return void + */ + public function testReadTransactionWithInvalidResponse(array $response, string $expectedMessage): void + { + $this->expectExceptionMessage($expectedMessage); + $this->subjectReader->readTransaction($response); + } + + /** + * Gets list of variations with invalid subject data. + * + * @return array + */ + public function invalidTransactionResponseDataProvider(): array + { + $transaction = new \stdClass(); + $response = new \stdClass(); + $response->transaction = $transaction; + + return [ + [ + 'response' => [ + 'object' => [], + ], + 'expectedMessage' => 'Response object does not exist.', + ], + [ + 'response' => [ + 'object' => new \stdClass(), + ], + 'expectedMessage' => 'The object is not a class \Braintree\Transaction.', + ], + [ + 'response' => [ + 'object' => $response, + ], + 'expectedMessage' => 'The object is not a class \Braintree\Transaction.', + ], + ]; } } diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/CancelResponseValidatorTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/CancelResponseValidatorTest.php new file mode 100644 index 0000000000000..65386272fe511 --- /dev/null +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/CancelResponseValidatorTest.php @@ -0,0 +1,179 @@ +generalValidator = $this->getMockBuilder(GeneralResponseValidator::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->resultFactory = $this->getMockBuilder(ResultInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->validator = new CancelResponseValidator( + $this->resultFactory, + $this->generalValidator, + new SubjectReader() + ); + } + + /** + * Checks a case when response is successful and additional validation doesn't needed. + * + * @return void + */ + public function testValidateSuccessfulTransaction(): void + { + /** @var ResultInterface|MockObject $result */ + $result = $this->getMockForAbstractClass(ResultInterface::class); + $result->method('isValid')->willReturn(true); + $this->generalValidator->method('validate')->willReturn($result); + $actual = $this->validator->validate([]); + + $this->assertSame($result, $actual); + } + + /** + * Checks a case when response contains error related to expired authorization transaction and + * validator should return positive result. + * + * @return void + */ + public function testValidateExpiredTransaction(): void + { + /** @var ResultInterface|MockObject $result */ + $result = $this->getMockForAbstractClass(ResultInterface::class); + $result->method('isValid')->willReturn(false); + $this->generalValidator->method('validate')->willReturn($result); + + $expected = $this->getMockForAbstractClass(ResultInterface::class); + $expected->method('isValid')->willReturn(true); + $this->resultFactory->method('create') + ->with( + [ + 'isValid' => true, + 'failsDescription' => ['Transaction is cancelled offline.'], + 'errorCodes' => [] + ] + )->willReturn($expected); + + $errors = [ + 'errors' => [ + [ + 'code' => 91504, + 'message' => 'Transaction can only be voided if status is authorized.', + ], + ], + ]; + $buildSubject = [ + 'response' => [ + 'object' => new Error(['errors' => $errors]), + ], + ]; + + $actual = $this->validator->validate($buildSubject); + + $this->assertSame($expected, $actual); + } + + /** + * Checks a case when response contains multiple errors and validator should return negative result. + * + * @param array $responseErrors + * @return void + * @dataProvider getErrorsDataProvider + */ + public function testValidateWithMultipleErrors(array $responseErrors): void + { + /** @var ResultInterface|MockObject $result */ + $result = $this->getMockForAbstractClass(ResultInterface::class); + $result->method('isValid')->willReturn(false); + + $this->generalValidator->method('validate')->willReturn($result); + + $this->resultFactory->expects($this->never())->method('create'); + + $errors = [ + 'errors' => $responseErrors, + ]; + $buildSubject = [ + 'response' => [ + 'object' => new Error(['errors' => $errors]), + ] + ]; + + $actual = $this->validator->validate($buildSubject); + + $this->assertSame($result, $actual); + } + + /** + * Gets list of errors variations. + * + * @return array + */ + public function getErrorsDataProvider(): array + { + return [ + [ + 'errors' => [ + [ + 'code' => 91734, + 'message' => 'Credit card type is not accepted by this merchant account.', + ], + [ + 'code' => 91504, + 'message' => 'Transaction can only be voided if status is authorized.', + ], + ], + ], + [ + 'errors' => [ + [ + 'code' => 91734, + 'message' => 'Credit card type is not accepted by this merchant account.', + ], + ], + ], + ]; + } +} diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/GeneralResponseValidatorTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/GeneralResponseValidatorTest.php index c8a46da504fef..4741a3ea38c6f 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/GeneralResponseValidatorTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/GeneralResponseValidatorTest.php @@ -5,12 +5,14 @@ */ namespace Magento\Braintree\Test\Unit\Gateway\Validator; -use Braintree\Transaction; +use Braintree\Result\Error; +use Magento\Braintree\Gateway\SubjectReader; +use Magento\Braintree\Gateway\Validator\ErrorCodeProvider; +use Magento\Braintree\Gateway\Validator\GeneralResponseValidator; use Magento\Framework\Phrase; -use Magento\Payment\Gateway\Validator\ResultInterface; +use Magento\Payment\Gateway\Validator\Result; use Magento\Payment\Gateway\Validator\ResultInterfaceFactory; -use Magento\Braintree\Gateway\Validator\GeneralResponseValidator; -use Magento\Braintree\Gateway\SubjectReader; +use PHPUnit_Framework_MockObject_MockObject as MockObject; class GeneralResponseValidatorTest extends \PHPUnit\Framework\TestCase { @@ -20,14 +22,9 @@ class GeneralResponseValidatorTest extends \PHPUnit\Framework\TestCase private $responseValidator; /** - * @var ResultInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject - */ - private $resultInterfaceFactoryMock; - - /** - * @var SubjectReader|\PHPUnit_Framework_MockObject_MockObject + * @var ResultInterfaceFactory|MockObject */ - private $subjectReaderMock; + private $resultInterfaceFactory; /** * Set up @@ -36,85 +33,105 @@ class GeneralResponseValidatorTest extends \PHPUnit\Framework\TestCase */ protected function setUp() { - $this->resultInterfaceFactoryMock = $this->getMockBuilder( - \Magento\Payment\Gateway\Validator\ResultInterfaceFactory::class - )->disableOriginalConstructor() - ->setMethods(['create']) - ->getMock(); - $this->subjectReaderMock = $this->getMockBuilder(SubjectReader::class) + $this->resultInterfaceFactory = $this->getMockBuilder(ResultInterfaceFactory::class) ->disableOriginalConstructor() + ->setMethods(['create']) ->getMock(); $this->responseValidator = new GeneralResponseValidator( - $this->resultInterfaceFactoryMock, - $this->subjectReaderMock + $this->resultInterfaceFactory, + new SubjectReader(), + new ErrorCodeProvider() ); } /** - * Run test for validate method + * Checks a case when the validator processes successful and failed transactions. * * @param array $validationSubject * @param bool $isValid * @param Phrase[] $messages + * @param array $errorCodes * @return void * * @dataProvider dataProviderTestValidate */ - public function testValidate(array $validationSubject, $isValid, $messages) + public function testValidate(array $validationSubject, bool $isValid, $messages, array $errorCodes) { - /** @var ResultInterface|\PHPUnit_Framework_MockObject_MockObject $resultMock */ - $resultMock = $this->createMock(ResultInterface::class); - - $this->subjectReaderMock->expects(self::once()) - ->method('readResponseObject') - ->with($validationSubject) - ->willReturn($validationSubject['response']['object']); + $result = new Result($isValid, $messages); - $this->resultInterfaceFactoryMock->expects(self::once()) - ->method('create') + $this->resultInterfaceFactory->method('create') ->with([ 'isValid' => $isValid, - 'failsDescription' => $messages + 'failsDescription' => $messages, + 'errorCodes' => $errorCodes ]) - ->willReturn($resultMock); + ->willReturn($result); - $actualMock = $this->responseValidator->validate($validationSubject); + $actual = $this->responseValidator->validate($validationSubject); - self::assertEquals($resultMock, $actualMock); + self::assertEquals($result, $actual); } /** + * Gets variations for different type of response. + * * @return array */ public function dataProviderTestValidate() { - $successTrue = new \stdClass(); - $successTrue->success = true; + $successTransaction = new \stdClass(); + $successTransaction->success = true; + + $failureTransaction = new \stdClass(); + $failureTransaction->success = false; + $failureTransaction->message = 'Transaction was failed.'; - $successFalse = new \stdClass(); - $successFalse->success = false; + $errors = [ + 'errors' => [ + [ + 'code' => 81804, + 'attribute' => 'base', + 'message' => 'Cannot process transaction.' + ] + ] + ]; + $errorTransaction = new Error(['errors' => $errors]); return [ [ 'validationSubject' => [ 'response' => [ - 'object' => $successTrue + 'object' => $successTransaction ], ], 'isValid' => true, - [] + [], + 'errorCodes' => [] + ], + [ + 'validationSubject' => [ + 'response' => [ + 'object' => $failureTransaction + ] + ], + 'isValid' => false, + [ + __('Transaction was failed.') + ], + 'errorCodes' => [] ], [ 'validationSubject' => [ 'response' => [ - 'object' => $successFalse + 'object' => $errorTransaction ] ], 'isValid' => false, [ __('Braintree error response.') - ] + ], + 'errorCodes' => ['81804'] ] ]; } diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/PaymentNonceResponseValidatorTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/PaymentNonceResponseValidatorTest.php index 03363b5463d78..530945c974ceb 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/PaymentNonceResponseValidatorTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/PaymentNonceResponseValidatorTest.php @@ -5,15 +5,13 @@ */ namespace Magento\Braintree\Test\Unit\Gateway\Validator; -use Braintree\Transaction; +use Magento\Braintree\Gateway\SubjectReader; +use Magento\Braintree\Gateway\Validator\ErrorCodeProvider; use Magento\Braintree\Gateway\Validator\PaymentNonceResponseValidator; -use Magento\Payment\Gateway\Validator\ResultInterface; +use Magento\Payment\Gateway\Validator\Result; use Magento\Payment\Gateway\Validator\ResultInterfaceFactory; -use Magento\Braintree\Gateway\SubjectReader; +use PHPUnit_Framework_MockObject_MockObject as MockObject; -/** - * Class PaymentNonceResponseValidatorTest - */ class PaymentNonceResponseValidatorTest extends \PHPUnit\Framework\TestCase { /** @@ -22,35 +20,24 @@ class PaymentNonceResponseValidatorTest extends \PHPUnit\Framework\TestCase private $validator; /** - * @var ResultInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject + * @var ResultInterfaceFactory|MockObject */ private $resultInterfaceFactory; - /** - * @var SubjectReader|\PHPUnit_Framework_MockObject_MockObject - */ - private $subjectReader; - protected function setUp() { $this->resultInterfaceFactory = $this->getMockBuilder(ResultInterfaceFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->subjectReader = $this->getMockBuilder(SubjectReader::class) - ->disableOriginalConstructor() - ->setMethods(['readResponseObject']) - ->getMock(); $this->validator = new PaymentNonceResponseValidator( $this->resultInterfaceFactory, - $this->subjectReader + new SubjectReader(), + new ErrorCodeProvider() ); } - /** - * @covers \Magento\Braintree\Gateway\Validator\PaymentNonceResponseValidator::validate - */ public function testFailedValidate() { $obj = new \stdClass(); @@ -61,23 +48,12 @@ public function testFailedValidate() ] ]; - $this->subjectReader->expects(static::once()) - ->method('readResponseObject') - ->willReturn($obj); - - $result = $this->createMock(ResultInterface::class); - $this->resultInterfaceFactory->expects(self::once()) - ->method('create') - ->with([ - 'isValid' => false, - 'failsDescription' => [ - __('Payment method nonce can\'t be retrieved.') - ] - ]) + $result = new Result(false, [__('Payment method nonce can\'t be retrieved.')]); + $this->resultInterfaceFactory->method('create') ->willReturn($result); $actual = $this->validator->validate($subject); - static::assertEquals($result, $actual); + self::assertEquals($result, $actual); } public function testValidateSuccess() @@ -93,20 +69,11 @@ public function testValidateSuccess() ] ]; - $this->subjectReader->expects(static::once()) - ->method('readResponseObject') - ->willReturn($obj); - - $result = $this->createMock(ResultInterface::class); - $this->resultInterfaceFactory->expects(self::once()) - ->method('create') - ->with([ - 'isValid' => true, - 'failsDescription' => [] - ]) + $result = new Result(true); + $this->resultInterfaceFactory->method('create') ->willReturn($result); $actual = $this->validator->validate($subject); - static::assertEquals($result, $actual); + self::assertEquals($result, $actual); } } diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ResponseValidatorTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ResponseValidatorTest.php index 4bd446079f9a7..360e1ff0525b6 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ResponseValidatorTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ResponseValidatorTest.php @@ -5,15 +5,16 @@ */ namespace Magento\Braintree\Test\Unit\Gateway\Validator; +use Braintree\Result\Successful; use Braintree\Transaction; +use Magento\Braintree\Gateway\SubjectReader; +use Magento\Braintree\Gateway\Validator\ErrorCodeProvider; +use Magento\Braintree\Gateway\Validator\ResponseValidator; use Magento\Framework\Phrase; +use Magento\Payment\Gateway\Validator\Result; use Magento\Payment\Gateway\Validator\ResultInterface; use Magento\Payment\Gateway\Validator\ResultInterfaceFactory; -use Magento\Braintree\Gateway\Validator\ResponseValidator; -use Magento\Braintree\Gateway\SubjectReader; use PHPUnit_Framework_MockObject_MockObject as MockObject; -use Braintree\Result\Error; -use Braintree\Result\Successful; /** * Class ResponseValidatorTest @@ -30,11 +31,6 @@ class ResponseValidatorTest extends \PHPUnit\Framework\TestCase */ private $resultInterfaceFactory; - /** - * @var SubjectReader|MockObject - */ - private $subjectReader; - /** * Set up * @@ -46,13 +42,11 @@ protected function setUp() ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->subjectReader = $this->getMockBuilder(SubjectReader::class) - ->disableOriginalConstructor() - ->getMock(); $this->responseValidator = new ResponseValidator( $this->resultInterfaceFactory, - $this->subjectReader + new SubjectReader(), + new ErrorCodeProvider() ); } @@ -65,11 +59,6 @@ public function testValidateReadResponseException() 'response' => null ]; - $this->subjectReader->expects(self::once()) - ->method('readResponseObject') - ->with($validationSubject) - ->willThrowException(new \InvalidArgumentException()); - $this->responseValidator->validate($validationSubject); } @@ -82,11 +71,6 @@ public function testValidateReadResponseObjectException() 'response' => ['object' => null] ]; - $this->subjectReader->expects(self::once()) - ->method('readResponseObject') - ->with($validationSubject) - ->willThrowException(new \InvalidArgumentException()); - $this->responseValidator->validate($validationSubject); } @@ -103,19 +87,9 @@ public function testValidateReadResponseObjectException() public function testValidate(array $validationSubject, $isValid, $messages) { /** @var ResultInterface|MockObject $result */ - $result = $this->createMock(ResultInterface::class); - - $this->subjectReader->expects(self::once()) - ->method('readResponseObject') - ->with($validationSubject) - ->willReturn($validationSubject['response']['object']); - - $this->resultInterfaceFactory->expects(self::once()) - ->method('create') - ->with([ - 'isValid' => $isValid, - 'failsDescription' => $messages - ]) + $result = new Result($isValid, $messages); + + $this->resultInterfaceFactory->method('create') ->willReturn($result); $actual = $this->responseValidator->validate($validationSubject); @@ -141,8 +115,6 @@ public function dataProviderTestValidate() $transactionDeclined->transaction = new \stdClass(); $transactionDeclined->transaction->status = Transaction::SETTLEMENT_DECLINED; - $errorResult = new Error(['errors' => []]); - return [ [ 'validationSubject' => [ @@ -175,18 +147,6 @@ public function dataProviderTestValidate() [ __('Wrong transaction status') ] - ], - [ - 'validationSubject' => [ - 'response' => [ - 'object' => $errorResult, - ] - ], - 'isValid' => false, - [ - __('Braintree error response.'), - __('Wrong transaction status') - ] ] ]; } diff --git a/app/code/Magento/Braintree/Test/Unit/Model/AvsEmsCodeMapperTest.php b/app/code/Magento/Braintree/Test/Unit/Model/AvsEmsCodeMapperTest.php index 9b80a2237a8fb..c82634d36db31 100644 --- a/app/code/Magento/Braintree/Test/Unit/Model/AvsEmsCodeMapperTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Model/AvsEmsCodeMapperTest.php @@ -84,11 +84,11 @@ public function testGetCodeWithException() public function getCodeDataProvider() { return [ - ['avsZip' => null, 'avsStreet' => null, 'expected' => 'U'], - ['avsZip' => null, 'avsStreet' => 'M', 'expected' => 'U'], - ['avsZip' => 'M', 'avsStreet' => null, 'expected' => 'U'], - ['avsZip' => 'M', 'avsStreet' => 'Unknown', 'expected' => 'U'], - ['avsZip' => 'I', 'avsStreet' => 'A', 'expected' => 'U'], + ['avsZip' => null, 'avsStreet' => null, 'expected' => ''], + ['avsZip' => null, 'avsStreet' => 'M', 'expected' => ''], + ['avsZip' => 'M', 'avsStreet' => null, 'expected' => ''], + ['avsZip' => 'M', 'avsStreet' => 'Unknown', 'expected' => ''], + ['avsZip' => 'I', 'avsStreet' => 'A', 'expected' => ''], ['avsZip' => 'M', 'avsStreet' => 'M', 'expected' => 'Y'], ['avsZip' => 'N', 'avsStreet' => 'M', 'expected' => 'A'], ['avsZip' => 'M', 'avsStreet' => 'N', 'expected' => 'Z'], diff --git a/app/code/Magento/Braintree/Test/Unit/Model/Paypal/Helper/QuoteUpdaterTest.php b/app/code/Magento/Braintree/Test/Unit/Model/Paypal/Helper/QuoteUpdaterTest.php index 39863e6561c43..76bf5b659bda3 100644 --- a/app/code/Magento/Braintree/Test/Unit/Model/Paypal/Helper/QuoteUpdaterTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Model/Paypal/Helper/QuoteUpdaterTest.php @@ -13,6 +13,7 @@ use Magento\Braintree\Observer\DataAssignObserver; use Magento\Braintree\Gateway\Config\PayPal\Config; use Magento\Braintree\Model\Paypal\Helper\QuoteUpdater; +use Magento\Quote\Api\Data\CartExtensionInterface; /** * Class QuoteUpdaterTest @@ -281,7 +282,7 @@ private function updateQuoteStep(\PHPUnit_Framework_MockObject_MockObject $quote */ private function getQuoteMock() { - return $this->getMockBuilder(Quote::class) + $quoteMock = $this->getMockBuilder(Quote::class) ->setMethods( [ 'getIsVirtual', @@ -291,9 +292,21 @@ private function getQuoteMock() 'collectTotals', 'getShippingAddress', 'getBillingAddress', + 'getExtensionAttributes' ] )->disableOriginalConstructor() ->getMock(); + + $cartExtensionMock = $this->getMockBuilder(CartExtensionInterface::class) + ->setMethods(['setShippingAssignments']) + ->disableOriginalConstructor() + ->getMock(); + + $quoteMock->expects(self::any()) + ->method('getExtensionAttributes') + ->willReturn($cartExtensionMock); + + return $quoteMock; } /** diff --git a/app/code/Magento/Braintree/Test/Unit/Model/Report/BraintreeTransactionStub.php b/app/code/Magento/Braintree/Test/Unit/Model/Report/BraintreeTransactionStub.php index 5c28b94ac9811..372415d3530c0 100644 --- a/app/code/Magento/Braintree/Test/Unit/Model/Report/BraintreeTransactionStub.php +++ b/app/code/Magento/Braintree/Test/Unit/Model/Report/BraintreeTransactionStub.php @@ -34,10 +34,9 @@ public function __get($name) { if (array_key_exists($name, $this->_attributes)) { return $this->_attributes[$name]; - } else { - trigger_error('Undefined property on ' . get_class($this) . ': ' . $name, E_USER_NOTICE); - return null; } + trigger_error('Undefined property on ' . get_class($this) . ': ' . $name, E_USER_NOTICE); + return null; } /** diff --git a/app/code/Magento/Braintree/composer.json b/app/code/Magento/Braintree/composer.json index 142e0c40fcfc6..5854a717513ba 100644 --- a/app/code/Magento/Braintree/composer.json +++ b/app/code/Magento/Braintree/composer.json @@ -5,8 +5,8 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "braintree/braintree_php": "3.22.0", + "php": "~7.1.3||~7.2.0", + "braintree/braintree_php": "3.28.0", "magento/framework": "*", "magento/magento-composer-installer": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/Braintree/etc/adminhtml/braintree_error_mapping.xml b/app/code/Magento/Braintree/etc/adminhtml/braintree_error_mapping.xml new file mode 100644 index 0000000000000..611f9372518fc --- /dev/null +++ b/app/code/Magento/Braintree/etc/adminhtml/braintree_error_mapping.xml @@ -0,0 +1,32 @@ + + + + + Credit card type is not accepted by this merchant account. + Transaction can only be voided if status is authorized, submitted_for_settlement, or - for PayPal - settlement_pending. + Credit transactions cannot be refunded. + Cannot refund a transaction unless it is settled. + Cannot submit for settlement unless status is authorized. + Customer does not have any credit cards. + Transaction has already been completely refunded. + Payment instrument type is not accepted by this merchant account. + Processor authorization code cannot be set unless for a voice authorization. + Refund amount is too large. + Settlement amount is too large. + Cannot provide a billing address unless also providing a credit card. + Cannot refund a transaction with a suspended merchant account. + Merchant account does not support refunds. + Cannot refund a transaction transaction in settling status on this merchant account. Try again after the transaction has settled. + PayPal is not enabled for your merchant account. + Partial settlements are not supported by this processor. + Cannot submit for partial settlement. + Transaction can not be voided if status of a PayPal partial settlement child transaction is settlement_pending. + Too many concurrent attempts to refund this transaction. Try again later. + Too many concurrent attempts to void this transaction. Try again later. + + diff --git a/app/code/Magento/Braintree/etc/adminhtml/di.xml b/app/code/Magento/Braintree/etc/adminhtml/di.xml index b7231f54186b5..7a803f803ae89 100644 --- a/app/code/Magento/Braintree/etc/adminhtml/di.xml +++ b/app/code/Magento/Braintree/etc/adminhtml/di.xml @@ -45,6 +45,19 @@ + + + + Magento\Braintree\Gateway\ErrorMapper\VirtualErrorMessageMapper + + + + + Magento\Braintree\Gateway\ErrorMapper\VirtualErrorMessageMapper + + + + diff --git a/app/code/Magento/Braintree/etc/braintree_error_mapping.xml b/app/code/Magento/Braintree/etc/braintree_error_mapping.xml new file mode 100644 index 0000000000000..81da0a252e567 --- /dev/null +++ b/app/code/Magento/Braintree/etc/braintree_error_mapping.xml @@ -0,0 +1,64 @@ + + + + + Credit card type is not accepted by this merchant account. + CVV is required. + CVV must be 4 digits for American Express and 3 digits for other card types. + Expiration date is required. + Expiration date is invalid. + Expiration year is invalid. It must be between 1975 and 2201. + Expiration month is invalid. + Expiration year is invalid. + Credit card number is required. + Credit card number is invalid. + Credit card number must be 12-19 digits. + Cardholder name is too long. + CVV verification failed. + Postal code verification failed. + Credit card number is prohibited. + Addresses must have at least one field filled in. + Company is too long. + Extended address is too long. + First name is too long. + Last name is too long. + Locality is too long. + Postal code is required. + Postal code may contain no more than 9 letter or number characters. + Region is too long. + Street address is required. + Street address is too long. + Postal code can only contain letters, numbers, spaces, and hyphens. + US state codes must be two characters to meet PayPal Seller Protection requirements. + Incomplete PayPal account information. + Invalid PayPal account information. + PayPal Accounts are not accepted by this merchant account. + Credit card type is not accepted by this merchant account. + Credit card type is not accepted by this merchant account. + Billing address format is invalid. + Country name is not an accepted country. + Country code is not accepted. Please contact the store administrator. + Provided country information is inconsistent. + Country code is not accepted. Please contact the store administrator. + Country code is not accepted. Please contact the store administrator. + Customer has already reached the maximum of 50 addresses. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Address is invalid. Please contact the store administrator. + Error communicating with PayPal. + PayPal authentication expired. + Error executing PayPal order. + Error executing PayPal billing agreement. + + diff --git a/app/code/Magento/Braintree/etc/di.xml b/app/code/Magento/Braintree/etc/di.xml index 1b839f469a144..290fb5be58f34 100644 --- a/app/code/Magento/Braintree/etc/di.xml +++ b/app/code/Magento/Braintree/etc/di.xml @@ -133,8 +133,8 @@ BraintreeVaultCaptureCommand BraintreeVoidCommand BraintreeRefundCommand - BraintreeVoidCommand - BraintreeVoidCommand + Magento\Braintree\Gateway\CancelCommand + Magento\Braintree\Gateway\CancelCommand @@ -150,7 +150,7 @@ BraintreeVaultCaptureCommand BraintreeVoidCommand BraintreeRefundCommand - BraintreeVoidCommand + Magento\Braintree\Gateway\CancelCommand @@ -193,6 +193,23 @@ + + + braintree_error_mapping.xml + + + + + Magento\Braintree\Gateway\ErrorMapper\VirtualConfigReader + braintree_error_mapper + + + + + Magento\Braintree\Gateway\ErrorMapper\VirtualMappingData + + + @@ -201,6 +218,7 @@ Magento\Braintree\Gateway\Http\Client\TransactionSale BraintreeAuthorizationHandler Magento\Braintree\Gateway\Validator\ResponseValidator + Magento\Braintree\Gateway\ErrorMapper\VirtualErrorMessageMapper @@ -240,6 +258,7 @@ Magento\Braintree\Gateway\Http\Client\TransactionSubmitForSettlement Magento\Braintree\Gateway\Response\TransactionIdHandler Magento\Braintree\Gateway\Validator\ResponseValidator + Magento\Braintree\Gateway\ErrorMapper\VirtualErrorMessageMapper @@ -258,6 +277,7 @@ Magento\Braintree\Gateway\Http\Client\TransactionSale BraintreeVaultResponseHandler Magento\Braintree\Gateway\Validator\ResponseValidator + Magento\Braintree\Gateway\ErrorMapper\VirtualErrorMessageMapper @@ -296,6 +316,7 @@ Magento\Braintree\Gateway\Http\Client\TransactionSale Magento\Braintree\Gateway\Response\TransactionIdHandler Magento\Braintree\Gateway\Validator\ResponseValidator + Magento\Braintree\Gateway\ErrorMapper\VirtualErrorMessageMapper @@ -426,7 +447,7 @@ - Magento\Vault\Model\AccountPaymentTokenFactory + Magento\Vault\Api\Data\PaymentTokenFactoryInterface @@ -484,6 +505,14 @@ + + + + Magento\Braintree\Gateway\Response\CancelDetailsHandler + Magento\Braintree\Gateway\Validator\CancelResponseValidator + + + diff --git a/app/code/Magento/Braintree/i18n/en_US.csv b/app/code/Magento/Braintree/i18n/en_US.csv index 116f459a1c1c8..194ad14d49751 100644 --- a/app/code/Magento/Braintree/i18n/en_US.csv +++ b/app/code/Magento/Braintree/i18n/en_US.csv @@ -129,3 +129,65 @@ Amount,Amount "Refund Ids","Refund Ids" "Settlement Batch ID","Settlement Batch ID" Currency,Currency +"Addresses must have at least one field filled in.","Addresses must have at least one field filled in." +"Company is too long.","Company is too long." +"Extended address is too long.","Extended address is too long." +"First name is too long.","First name is too long." +"Last name is too long.","Last name is too long." +"Locality is too long.","Locality is too long." +"Postal code can only contain letters, numbers, spaces, and hyphens.","Postal code can only contain letters, numbers, spaces, and hyphens." +"Postal code is required.","Postal code is required." +"Postal code may contain no more than 9 letter or number characters.","Postal code may contain no more than 9 letter or number characters." +"Region is too long.","Region is too long." +"Street address is required.","Street address is required." +"Street address is too long.","Street address is too long." +"US state codes must be two characters to meet PayPal Seller Protection requirements.","US state codes must be two characters to meet PayPal Seller Protection requirements." +"Country name is not an accepted country.","Country name is not an accepted country." +"Provided country information is inconsistent.","Provided country information is inconsistent." +"Country code is not accepted. Please contact the store administrator.","Country code is not accepted. Please contact the store administrator." +"Customer has already reached the maximum of 50 addresses.","Customer has already reached the maximum of 50 addresses." +"Address is invalid. Please contact the store administrator.","Address is invalid. Please contact the store administrator." +"Address is invalid.","Address is invalid." +"Billing address format is invalid.","Billing address format is invalid." +"Cardholder name is too long.","Cardholder name is too long." +"Credit card type is not accepted by this merchant account.","Credit card type is not accepted by this merchant account." +"CVV is required.","CVV is required." +"CVV must be 4 digits for American Express and 3 digits for other card types.","CVV must be 4 digits for American Express and 3 digits for other card types." +"Expiration date is required.","Expiration date is required." +"Expiration date is invalid.","Expiration date is invalid." +"Expiration year is invalid. It must be between 1975 and 2201.","Expiration year is invalid. It must be between 1975 and 2201." +"Expiration month is invalid.","Expiration month is invalid." +"Expiration year is invalid.","Expiration year is invalid." +"Credit card number is required.","Credit card number is required." +"Credit card number is invalid.","Credit card number is invalid." +"Credit card number must be 12-19 digits.","Credit card number must be 12-19 digits." +"CVV verification failed.","CVV verification failed." +"Postal code verification failed.","Postal code verification failed." +"Credit card number is prohibited.","Credit card number is prohibited." +"Incomplete PayPal account information.","Incomplete PayPal account information." +"Invalid PayPal account information.","Invalid PayPal account information." +"PayPal Accounts are not accepted by this merchant account.","PayPal Accounts are not accepted by this merchant account." +"Error communicating with PayPal.","Error communicating with PayPal." +"PayPal authentication expired.","PayPal authentication expired." +"Error executing PayPal order.","Error executing PayPal order." +"Error executing PayPal billing agreement.","Error executing PayPal billing agreement." +"Cannot provide a billing address unless also providing a credit card.","Cannot provide a billing address unless also providing a credit card." +"Transaction can only be voided if status is authorized, submitted_for_settlement, or - for PayPal - settlement_pending.","Transaction can only be voided if status is authorized, submitted_for_settlement, or - for PayPal - settlement_pending." +"Credit transactions cannot be refunded.","Credit transactions cannot be refunded." +"Cannot refund a transaction unless it is settled.","Cannot refund a transaction unless it is settled." +"Cannot submit for settlement unless status is authorized.","Cannot submit for settlement unless status is authorized." +"Customer does not have any credit cards.","Customer does not have any credit cards." +"Transaction has already been completely refunded.","Transaction has already been completely refunded." +"Payment instrument type is not accepted by this merchant account.","Payment instrument type is not accepted by this merchant account." +"Processor authorization code cannot be set unless for a voice authorization.","Processor authorization code cannot be set unless for a voice authorization." +"Refund amount is too large.","Refund amount is too large." +"Settlement amount is too large.","Settlement amount is too large." +"Cannot refund a transaction with a suspended merchant account.","Cannot refund a transaction with a suspended merchant account." +"Merchant account does not support refunds.","Merchant account does not support refunds." +"PayPal is not enabled for your merchant account.","PayPal is not enabled for your merchant account." +"Cannot refund a transaction transaction in settling status on this merchant account. Try again after the transaction has settled.","Cannot refund a transaction transaction in settling status on this merchant account. Try again after the transaction has settled." +"Cannot submit for partial settlement.","Cannot submit for partial settlement." +"Partial settlements are not supported by this processor.","Partial settlements are not supported by this processor." +"Transaction can not be voided if status of a PayPal partial settlement child transaction is settlement_pending.","Transaction can not be voided if status of a PayPal partial settlement child transaction is settlement_pending." +"Too many concurrent attempts to refund this transaction. Try again later.","Too many concurrent attempts to refund this transaction. Try again later." +"Too many concurrent attempts to void this transaction. Try again later.","Too many concurrent attempts to void this transaction. Try again later." \ No newline at end of file diff --git a/app/code/Magento/Braintree/view/adminhtml/templates/form/cc.phtml b/app/code/Magento/Braintree/view/adminhtml/templates/form/cc.phtml index 13249cd8e0e80..535a5a852fe70 100644 --- a/app/code/Magento/Braintree/view/adminhtml/templates/form/cc.phtml +++ b/app/code/Magento/Braintree/view/adminhtml/templates/form/cc.phtml @@ -84,7 +84,7 @@ $ccType = $block->getInfoData('cc_type'); name="payment[is_active_payment_token_enabler]" class="admin__control-checkbox"/> diff --git a/app/code/Magento/Braintree/view/adminhtml/web/js/braintree.js b/app/code/Magento/Braintree/view/adminhtml/web/js/braintree.js index 5e1e85e6a3c48..9c95b79196e9d 100644 --- a/app/code/Magento/Braintree/view/adminhtml/web/js/braintree.js +++ b/app/code/Magento/Braintree/view/adminhtml/web/js/braintree.js @@ -145,6 +145,8 @@ define([ _initBraintree: function () { var self = this; + this.disableEventListeners(); + self.braintree.setup(self.clientToken, 'custom', { id: self.selector, hostedFields: self.getHostedFields(), @@ -154,6 +156,7 @@ define([ */ onReady: function () { $('body').trigger('processStop'); + self.enableEventListeners(); }, /** diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php index 0b3a938255de1..b220e2c98d77c 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php @@ -17,7 +17,7 @@ class Checkbox extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Op /** * @var string */ - protected $_template = 'product/composite/fieldset/options/type/checkbox.phtml'; + protected $_template = 'Magento_Bundle::product/composite/fieldset/options/type/checkbox.phtml'; /** * @param string $elementId diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php index 304b3a5cf34ed..a4b8c6bde73aa 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php @@ -17,7 +17,7 @@ class Multi extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Optio /** * @var string */ - protected $_template = 'product/composite/fieldset/options/type/multi.phtml'; + protected $_template = 'Magento_Bundle::product/composite/fieldset/options/type/multi.phtml'; /** * @param string $elementId diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Radio.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Radio.php index e011ab36e8029..1519b3a67ac97 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Radio.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Radio.php @@ -17,7 +17,7 @@ class Radio extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Optio /** * @var string */ - protected $_template = 'product/composite/fieldset/options/type/radio.phtml'; + protected $_template = 'Magento_Bundle::product/composite/fieldset/options/type/radio.phtml'; /** * @param string $elementId diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Select.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Select.php index f1206db359b5c..502dfa32044a3 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Select.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Select.php @@ -17,7 +17,7 @@ class Select extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Opti /** * @var string */ - protected $_template = 'product/composite/fieldset/options/type/select.phtml'; + protected $_template = 'Magento_Bundle::product/composite/fieldset/options/type/select.phtml'; /** * @param string $elementId diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle.php index f124740a766ab..8be512a3e6348 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle.php @@ -20,7 +20,7 @@ class Bundle extends \Magento\Backend\Block\Widget implements \Magento\Backend\B /** * @var string */ - protected $_template = 'product/edit/bundle.phtml'; + protected $_template = 'Magento_Bundle::product/edit/bundle.phtml'; /** * Core registry diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option.php index 13c5dcc81afb3..19da6bc6244e5 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option.php @@ -26,7 +26,7 @@ class Option extends \Magento\Backend\Block\Widget /** * @var string */ - protected $_template = 'product/edit/bundle/option.phtml'; + protected $_template = 'Magento_Bundle::product/edit/bundle/option.phtml'; /** * Core registry diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Search.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Search.php index 5b73c22b5781a..cf4814d3cd778 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Search.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Search.php @@ -15,7 +15,7 @@ class Search extends \Magento\Backend\Block\Widget /** * @var string */ - protected $_template = 'product/edit/bundle/option/search.phtml'; + protected $_template = 'Magento_Bundle::product/edit/bundle/option/search.phtml'; /** * @return void diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Selection.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Selection.php index 353808dc66a72..cf88f9b93d32f 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Selection.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Edit/Tab/Bundle/Option/Selection.php @@ -15,7 +15,7 @@ class Selection extends \Magento\Backend\Block\Widget /** * @var string */ - protected $_template = 'product/edit/bundle/option/selection.phtml'; + protected $_template = 'Magento_Bundle::product/edit/bundle/option/selection.phtml'; /** * Catalog data diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Sales/Order/Items/Renderer.php b/app/code/Magento/Bundle/Block/Adminhtml/Sales/Order/Items/Renderer.php index 4cb087df0e1a6..23fc2026ab111 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Sales/Order/Items/Renderer.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Sales/Order/Items/Renderer.php @@ -95,9 +95,8 @@ public function getChildren($item) if (isset($itemsArray[$item->getOrderItem()->getId()])) { return $itemsArray[$item->getOrderItem()->getId()]; - } else { - return null; } + return null; } /** @@ -219,9 +218,8 @@ public function getOrderItem() { if ($this->getItem() instanceof \Magento\Sales\Model\Order\Item) { return $this->getItem(); - } else { - return $this->getItem()->getOrderItem(); } + return $this->getItem()->getOrderItem(); } /** diff --git a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php index 6cb103fc86789..542f170da8c3a 100644 --- a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php +++ b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php @@ -217,10 +217,11 @@ public function getOptionHtml(Option $option) } /** - * Get formed data from option selection item + * Get formed data from option selection item. * * @param Product $product * @param Product $selection + * * @return array */ private function getSelectionItemData(Product $product, Product $selection) @@ -228,31 +229,37 @@ private function getSelectionItemData(Product $product, Product $selection) $qty = ($selection->getSelectionQty() * 1) ?: '1'; $optionPriceAmount = $product->getPriceInfo() - ->getPrice('bundle_option') + ->getPrice(\Magento\Bundle\Pricing\Price\BundleOptionPrice::PRICE_CODE) ->getOptionSelectionAmount($selection); $finalPrice = $optionPriceAmount->getValue(); $basePrice = $optionPriceAmount->getBaseAmount(); + $oldPrice = $product->getPriceInfo() + ->getPrice(\Magento\Bundle\Pricing\Price\BundleOptionRegularPrice::PRICE_CODE) + ->getOptionSelectionAmount($selection) + ->getValue(); + $selection = [ 'qty' => $qty, 'customQty' => $selection->getSelectionCanChangeQty(), 'optionId' => $selection->getId(), 'prices' => [ 'oldPrice' => [ - 'amount' => $basePrice + 'amount' => $oldPrice, ], 'basePrice' => [ - 'amount' => $basePrice + 'amount' => $basePrice, ], 'finalPrice' => [ - 'amount' => $finalPrice - ] + 'amount' => $finalPrice, + ], ], 'priceType' => $selection->getSelectionPriceType(), 'tierPrice' => $this->getTierPrices($product, $selection), 'name' => $selection->getName(), - 'canApplyMsrp' => false + 'canApplyMsrp' => false, ]; + return $selection; } diff --git a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option.php b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option.php index 14778e0f184de..5d326e7c01d19 100644 --- a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option.php +++ b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option.php @@ -189,9 +189,8 @@ public function isSelected($selection) return in_array($selection->getSelectionId(), $selectedOptions); } elseif ($selectedOptions == 'None') { return false; - } else { - return $selection->getIsDefault() && $selection->isSaleable(); } + return $selection->getIsDefault() && $selection->isSaleable(); } /** diff --git a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Checkbox.php b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Checkbox.php index 8ca0cf8a5159e..83730d4eae2bd 100644 --- a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Checkbox.php +++ b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Checkbox.php @@ -16,5 +16,5 @@ class Checkbox extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Op /** * @var string */ - protected $_template = 'catalog/product/view/type/bundle/option/checkbox.phtml'; + protected $_template = 'Magento_Bundle::catalog/product/view/type/bundle/option/checkbox.phtml'; } diff --git a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Multi.php b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Multi.php index 3319db8cff1d5..79e94a18a789e 100644 --- a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Multi.php +++ b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Multi.php @@ -16,7 +16,7 @@ class Multi extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Optio /** * @var string */ - protected $_template = 'catalog/product/view/type/bundle/option/multi.phtml'; + protected $_template = 'Magento_Bundle::catalog/product/view/type/bundle/option/multi.phtml'; /** * @inheritdoc diff --git a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Radio.php b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Radio.php index 84a619dafab52..07c113bd8e4bb 100644 --- a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Radio.php +++ b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Radio.php @@ -16,5 +16,5 @@ class Radio extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Optio /** * @var string */ - protected $_template = 'catalog/product/view/type/bundle/option/radio.phtml'; + protected $_template = 'Magento_Bundle::catalog/product/view/type/bundle/option/radio.phtml'; } diff --git a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Select.php b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Select.php index d7f1cf41057a8..63f0d35bda0f0 100644 --- a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Select.php +++ b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle/Option/Select.php @@ -16,5 +16,5 @@ class Select extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Opti /** * @var string */ - protected $_template = 'catalog/product/view/type/bundle/option/select.phtml'; + protected $_template = 'Magento_Bundle::catalog/product/view/type/bundle/option/select.phtml'; } diff --git a/app/code/Magento/Bundle/Block/Sales/Order/Items/Renderer.php b/app/code/Magento/Bundle/Block/Sales/Order/Items/Renderer.php index a29c93fc4e139..003ddba86ad75 100644 --- a/app/code/Magento/Bundle/Block/Sales/Order/Items/Renderer.php +++ b/app/code/Magento/Bundle/Block/Sales/Order/Items/Renderer.php @@ -142,9 +142,8 @@ public function getValueHtml($item) if ($attributes = $this->getSelectionAttributes($item)) { return sprintf('%d', $attributes['qty']) . ' x ' . $this->escapeHtml($item->getName()) . " " . $this->getOrder()->formatPrice($attributes['price']); - } else { - return $this->escapeHtml($item->getName()); } + return $this->escapeHtml($item->getName()); } /** @@ -179,9 +178,8 @@ public function getChildren($item) if (isset($itemsArray[$item->getOrderItem()->getId()])) { return $itemsArray[$item->getOrderItem()->getId()]; - } else { - return null; } + return null; } /** diff --git a/app/code/Magento/Bundle/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Bundle.php b/app/code/Magento/Bundle/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Bundle.php index 7f21d9e69c6e0..3c9eac68eb9e4 100644 --- a/app/code/Magento/Bundle/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Bundle.php +++ b/app/code/Magento/Bundle/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Bundle.php @@ -105,8 +105,13 @@ public function afterInitialize( if ($result['bundle_options'] && !$compositeReadonly) { $product->setBundleOptionsData($result['bundle_options']); } + $this->processBundleOptionsData($product); $this->processDynamicOptionsData($product); + } elseif (!$compositeReadonly) { + $extension = $product->getExtensionAttributes(); + $extension->setBundleProductOptions([]); + $product->setExtensionAttributes($extension); } $affectProductSelections = (bool)$this->request->getPost('affect_bundle_product_selections'); diff --git a/app/code/Magento/Bundle/Model/Option/SaveAction.php b/app/code/Magento/Bundle/Model/Option/SaveAction.php new file mode 100644 index 0000000000000..00c5b05d532f5 --- /dev/null +++ b/app/code/Magento/Bundle/Model/Option/SaveAction.php @@ -0,0 +1,195 @@ +optionResource = $optionResource; + $this->metadataPool = $metadataPool; + $this->type = $type; + $this->linkManagement = $linkManagement; + } + + /** + * Manage the logic of saving a bundle option, including the coalescence of its parent product data. + * + * @param ProductInterface $bundleProduct + * @param OptionInterface $option + * @return OptionInterface + * @throws CouldNotSaveException + * @throws \Exception + */ + public function save(ProductInterface $bundleProduct, OptionInterface $option) + { + $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + + $option->setStoreId($bundleProduct->getStoreId()); + $parentId = $bundleProduct->getData($metadata->getLinkField()); + $option->setParentId($parentId); + + $optionId = $option->getOptionId(); + $linksToAdd = []; + $optionCollection = $this->type->getOptionsCollection($bundleProduct); + $optionCollection->setIdFilter($option->getOptionId()); + $optionCollection->setProductLinkFilter($parentId); + + /** @var \Magento\Bundle\Model\Option $existingOption */ + $existingOption = $optionCollection->getFirstItem(); + if (!$optionId || $existingOption->getParentId() != $parentId) { + //If option ID is empty or existing option's parent ID is different + //we'd need a new ID for the option. + $option->setOptionId(null); + $option->setDefaultTitle($option->getTitle()); + if (is_array($option->getProductLinks())) { + $linksToAdd = $option->getProductLinks(); + } + } else { + if (!$existingOption->getOptionId()) { + throw new NoSuchEntityException( + __("The option that was requested doesn't exist. Verify the entity and try again.") + ); + } + + $option->setData(array_merge($existingOption->getData(), $option->getData())); + $this->updateOptionSelection($bundleProduct, $option); + } + + try { + $this->optionResource->save($option); + } catch (\Exception $e) { + throw new CouldNotSaveException(__("The option couldn't be saved."), $e); + } + + /** @var \Magento\Bundle\Api\Data\LinkInterface $linkedProduct */ + foreach ($linksToAdd as $linkedProduct) { + $this->linkManagement->addChild($bundleProduct, $option->getOptionId(), $linkedProduct); + } + + $bundleProduct->setIsRelationsChanged(true); + + return $option; + } + + /** + * Update option selections + * + * @param \Magento\Catalog\Api\Data\ProductInterface $product + * @param \Magento\Bundle\Api\Data\OptionInterface $option + * @return void + */ + private function updateOptionSelection(ProductInterface $product, OptionInterface $option) + { + $optionId = $option->getOptionId(); + $existingLinks = $this->linkManagement->getChildren($product->getSku(), $optionId); + $linksToAdd = []; + $linksToUpdate = []; + $linksToDelete = []; + if (is_array($option->getProductLinks())) { + $productLinks = $option->getProductLinks(); + foreach ($productLinks as $productLink) { + if (!$productLink->getId() && !$productLink->getSelectionId()) { + $linksToAdd[] = $productLink; + } else { + $linksToUpdate[] = $productLink; + } + } + /** @var \Magento\Bundle\Api\Data\LinkInterface[] $linksToDelete */ + $linksToDelete = $this->compareLinks($existingLinks, $linksToUpdate); + } + foreach ($linksToUpdate as $linkedProduct) { + $this->linkManagement->saveChild($product->getSku(), $linkedProduct); + } + foreach ($linksToDelete as $linkedProduct) { + $this->linkManagement->removeChild( + $product->getSku(), + $option->getOptionId(), + $linkedProduct->getSku() + ); + } + foreach ($linksToAdd as $linkedProduct) { + $this->linkManagement->addChild($product, $option->getOptionId(), $linkedProduct); + } + } + + /** + * Compute the difference between given arrays. + * + * @param \Magento\Bundle\Api\Data\LinkInterface[] $firstArray + * @param \Magento\Bundle\Api\Data\LinkInterface[] $secondArray + * + * @return array + */ + private function compareLinks(array $firstArray, array $secondArray) + { + $result = []; + + $firstArrayIds = []; + $firstArrayMap = []; + + $secondArrayIds = []; + + foreach ($firstArray as $item) { + $firstArrayIds[] = $item->getId(); + + $firstArrayMap[$item->getId()] = $item; + } + + foreach ($secondArray as $item) { + $secondArrayIds[] = $item->getId(); + } + + foreach (array_diff($firstArrayIds, $secondArrayIds) as $id) { + $result[] = $firstArrayMap[$id]; + } + + return $result; + } +} diff --git a/app/code/Magento/Bundle/Model/OptionRepository.php b/app/code/Magento/Bundle/Model/OptionRepository.php index b5e5244a11fda..59e658b08df28 100644 --- a/app/code/Magento/Bundle/Model/OptionRepository.php +++ b/app/code/Magento/Bundle/Model/OptionRepository.php @@ -7,14 +7,14 @@ namespace Magento\Bundle\Model; +use Magento\Bundle\Model\Option\SaveAction; use Magento\Catalog\Api\Data\ProductInterface; -use Magento\Framework\App\ObjectManager; -use Magento\Framework\EntityManager\MetadataPool; -use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\NoSuchEntityException; /** + * Repository for performing CRUD operations for a bundle product's options. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class OptionRepository implements \Magento\Bundle\Api\ProductOptionRepositoryInterface @@ -39,11 +39,6 @@ class OptionRepository implements \Magento\Bundle\Api\ProductOptionRepositoryInt */ protected $optionResource; - /** - * @var \Magento\Store\Model\StoreManager - */ - protected $storeManager; - /** * @var \Magento\Bundle\Api\ProductLinkManagementInterface */ @@ -54,53 +49,44 @@ class OptionRepository implements \Magento\Bundle\Api\ProductOptionRepositoryInt */ protected $productOptionList; - /** - * @var Product\LinksList - */ - protected $linkList; - /** * @var \Magento\Framework\Api\DataObjectHelper */ protected $dataObjectHelper; /** - * @var \Magento\Framework\EntityManager\MetadataPool + * @var SaveAction */ - private $metadataPool; + private $optionSave; /** * @param \Magento\Catalog\Api\ProductRepositoryInterface $productRepository * @param Product\Type $type * @param \Magento\Bundle\Api\Data\OptionInterfaceFactory $optionFactory * @param \Magento\Bundle\Model\ResourceModel\Option $optionResource - * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Bundle\Api\ProductLinkManagementInterface $linkManagement * @param Product\OptionList $productOptionList - * @param Product\LinksList $linkList * @param \Magento\Framework\Api\DataObjectHelper $dataObjectHelper - * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @param SaveAction $optionSave */ public function __construct( \Magento\Catalog\Api\ProductRepositoryInterface $productRepository, \Magento\Bundle\Model\Product\Type $type, \Magento\Bundle\Api\Data\OptionInterfaceFactory $optionFactory, \Magento\Bundle\Model\ResourceModel\Option $optionResource, - \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Bundle\Api\ProductLinkManagementInterface $linkManagement, \Magento\Bundle\Model\Product\OptionList $productOptionList, - \Magento\Bundle\Model\Product\LinksList $linkList, - \Magento\Framework\Api\DataObjectHelper $dataObjectHelper + \Magento\Framework\Api\DataObjectHelper $dataObjectHelper, + SaveAction $optionSave ) { $this->productRepository = $productRepository; $this->type = $type; $this->optionFactory = $optionFactory; $this->optionResource = $optionResource; - $this->storeManager = $storeManager; $this->linkManagement = $linkManagement; $this->productOptionList = $productOptionList; - $this->linkList = $linkList; $this->dataObjectHelper = $dataObjectHelper; + $this->optionSave = $optionSave; } /** @@ -118,7 +104,7 @@ public function get($sku, $optionId) ); } - $productLinks = $this->linkList->getItems($product, $optionId); + $productLinks = $this->linkManagement->getChildren($product->getSku(), $optionId); /** @var \Magento\Bundle\Api\Data\OptionInterface $option */ $optionDataObject = $this->optionFactory->create(); @@ -177,7 +163,9 @@ public function deleteById($sku, $optionId) $product = $this->getProduct($sku); $optionCollection = $this->type->getOptionsCollection($product); $optionCollection->setIdFilter($optionId); - return $this->delete($optionCollection->getFirstItem()); + $hasBeenDeleted = $this->delete($optionCollection->getFirstItem()); + + return $hasBeenDeleted; } /** @@ -187,51 +175,12 @@ public function save( \Magento\Catalog\Api\Data\ProductInterface $product, \Magento\Bundle\Api\Data\OptionInterface $option ) { - $metadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - - $option->setStoreId($product->getStoreId()); - $parentId = $product->getData($metadata->getLinkField()); - $option->setParentId($parentId); - - $optionId = $option->getOptionId(); - $linksToAdd = []; - $optionCollection = $this->type->getOptionsCollection($product); - $optionCollection->setIdFilter($option->getOptionId()); - $optionCollection->setProductLinkFilter($parentId); - - /** @var \Magento\Bundle\Model\Option $existingOption */ - $existingOption = $optionCollection->getFirstItem(); - if (!$optionId || $existingOption->getParentId() != $parentId) { - //If option ID is empty or existing option's parent ID is different - //we'd need a new ID for the option. - $option->setOptionId(null); - $option->setDefaultTitle($option->getTitle()); - if (is_array($option->getProductLinks())) { - $linksToAdd = $option->getProductLinks(); - } - } else { - if (!$existingOption->getOptionId()) { - throw new NoSuchEntityException( - __("The option that was requested doesn't exist. Verify the entity and try again.") - ); - } + $savedOption = $this->optionSave->save($product, $option); - $option->setData(array_merge($existingOption->getData(), $option->getData())); - $this->updateOptionSelection($product, $option); - } + $productToSave = $this->productRepository->get($product->getSku()); + $this->productRepository->save($productToSave); - try { - $this->optionResource->save($option); - } catch (\Exception $e) { - throw new CouldNotSaveException(__("The option couldn't be saved."), $e); - } - - /** @var \Magento\Bundle\Api\Data\LinkInterface $linkedProduct */ - foreach ($linksToAdd as $linkedProduct) { - $this->linkManagement->addChild($product, $option->getOptionId(), $linkedProduct); - } - $product->setIsRelationsChanged(true); - return $option->getOptionId(); + return $savedOption->getOptionId(); } /** @@ -285,7 +234,7 @@ protected function updateOptionSelection( */ private function getProduct($sku) { - $product = $this->productRepository->get($sku, true); + $product = $this->productRepository->get($sku, true, null, true); if ($product->getTypeId() != \Magento\Catalog\Model\Product\Type::TYPE_BUNDLE) { throw new InputException(__('This is implemented for bundle products only.')); } @@ -325,16 +274,4 @@ private function compareLinks(array $firstArray, array $secondArray) return $result; } - - /** - * Get MetadataPool instance - * @return MetadataPool - */ - private function getMetadataPool() - { - if (!$this->metadataPool) { - $this->metadataPool = ObjectManager::getInstance()->get(MetadataPool::class); - } - return $this->metadataPool; - } } diff --git a/app/code/Magento/Bundle/Model/Plugin/PriceBackend.php b/app/code/Magento/Bundle/Model/Plugin/PriceBackend.php index f3c0548f76e5d..1914d5b5146c3 100644 --- a/app/code/Magento/Bundle/Model/Plugin/PriceBackend.php +++ b/app/code/Magento/Bundle/Model/Plugin/PriceBackend.php @@ -29,8 +29,7 @@ public function aroundValidate( && $object->getPriceType() == \Magento\Bundle\Model\Product\Price::PRICE_TYPE_DYNAMIC ) { return true; - } else { - return $proceed($object); } + return $proceed($object); } } diff --git a/app/code/Magento/Bundle/Model/Product/SaveHandler.php b/app/code/Magento/Bundle/Model/Product/SaveHandler.php index 8517cec6aff6d..fc215aa6b8e20 100644 --- a/app/code/Magento/Bundle/Model/Product/SaveHandler.php +++ b/app/code/Magento/Bundle/Model/Product/SaveHandler.php @@ -5,7 +5,7 @@ */ namespace Magento\Bundle\Model\Product; -use Magento\Bundle\Api\Data\OptionInterface; +use Magento\Bundle\Model\Option\SaveAction; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Bundle\Api\ProductOptionRepositoryInterface as OptionRepository; use Magento\Bundle\Api\ProductLinkManagementInterface; @@ -33,53 +33,30 @@ class SaveHandler implements ExtensionInterface */ private $metadataPool; + /** + * @var SaveAction + */ + private $optionSave; + /** * @param OptionRepository $optionRepository * @param ProductLinkManagementInterface $productLinkManagement + * @param SaveAction $optionSave * @param MetadataPool|null $metadataPool */ public function __construct( OptionRepository $optionRepository, ProductLinkManagementInterface $productLinkManagement, + SaveAction $optionSave, MetadataPool $metadataPool = null ) { $this->optionRepository = $optionRepository; $this->productLinkManagement = $productLinkManagement; - + $this->optionSave = $optionSave; $this->metadataPool = $metadataPool ?: ObjectManager::getInstance()->get(MetadataPool::class); } - /** - * @param ProductInterface $bundle - * @param OptionInterface[] $currentOptions - * - * @return void - */ - private function removeOldOptions( - ProductInterface $bundle, - array $currentOptions - ) { - $oldOptions = $this->optionRepository->getList($bundle->getSku()); - if ($oldOptions) { - $remainingOptions = []; - $metadata - = $this->metadataPool->getMetadata(ProductInterface::class); - $productId = $bundle->getData($metadata->getLinkField()); - - foreach ($currentOptions as $option) { - $remainingOptions[] = $option->getOptionId(); - } - foreach ($oldOptions as $option) { - if (!in_array($option->getOptionId(), $remainingOptions)) { - $option->setParentId($productId); - $this->removeOptionLinks($bundle->getSku(), $option); - $this->optionRepository->delete($option); - } - } - } - } - /** * @param object $entity * @param array $arguments @@ -90,23 +67,30 @@ private function removeOldOptions( */ public function execute($entity, $arguments = []) { - /** @var \Magento\Bundle\Api\Data\OptionInterface[] $options */ - $options = $entity->getExtensionAttributes()->getBundleProductOptions() ?: []; + /** @var \Magento\Bundle\Api\Data\OptionInterface[] $bundleProductOptions */ + $bundleProductOptions = $entity->getExtensionAttributes()->getBundleProductOptions() ?: []; //Only processing bundle products. - if ($entity->getTypeId() !== 'bundle' || empty($options)) { + if ($entity->getTypeId() !== Type::TYPE_CODE || empty($bundleProductOptions)) { return $entity; } - /** @var ProductInterface $entity */ - //Removing old options + + $existingBundleProductOptions = $this->optionRepository->getList($entity->getSku()); + $existingOptionsIds = !empty($existingBundleProductOptions) + ? $this->getOptionIds($existingBundleProductOptions) + : []; + $optionIds = !empty($bundleProductOptions) + ? $this->getOptionIds($bundleProductOptions) + : []; + if (!$entity->getCopyFromView()) { - $this->removeOldOptions($entity, $options); + $this->processRemovedOptions($entity->getSku(), $existingOptionsIds, $optionIds); + $newOptionsIds = array_diff($optionIds, $existingOptionsIds); + $this->saveOptions($entity, $bundleProductOptions, $newOptionsIds); + } else { + //save only labels and not selections + product links + $this->saveOptions($entity, $bundleProductOptions); + $entity->setCopyFromView(false); } - //Saving active options. - foreach ($options as $option) { - $this->optionRepository->save($entity, $option); - } - - $entity->setCopyFromView(false); return $entity; } @@ -125,4 +109,62 @@ protected function removeOptionLinks($entitySku, $option) } } } + + /** + * Perform save for all options entities. + * + * @param object $entity + * @param array $options + * @param array $newOptionsIds + * @return void + */ + private function saveOptions($entity, array $options, array $newOptionsIds = []): void + { + foreach ($options as $option) { + if (in_array($option->getOptionId(), $newOptionsIds, true)) { + $option->setOptionId(null); + } + + $this->optionSave->save($entity, $option); + } + } + + /** + * Get options ids from array of the options entities. + * + * @param array $options + * @return array + */ + private function getOptionIds(array $options): array + { + $optionIds = []; + + if (!empty($options)) { + /** @var \Magento\Bundle\Api\Data\OptionInterface $option */ + foreach ($options as $option) { + if ($option->getOptionId()) { + $optionIds[] = $option->getOptionId(); + } + } + } + + return $optionIds; + } + + /** + * Removes old options that no longer exists. + * + * @param string $entitySku + * @param array $existingOptionsIds + * @param array $optionIds + * @return void + */ + private function processRemovedOptions(string $entitySku, array $existingOptionsIds, array $optionIds): void + { + foreach (array_diff($existingOptionsIds, $optionIds) as $optionId) { + $option = $this->optionRepository->get($entitySku, $optionId); + $this->removeOptionLinks($entitySku, $option); + $this->optionRepository->delete($option); + } + } } diff --git a/app/code/Magento/Bundle/Model/Product/Type.php b/app/code/Magento/Bundle/Model/Product/Type.php index b4071096992c1..f5e05cbc3e212 100644 --- a/app/code/Magento/Bundle/Model/Product/Type.php +++ b/app/code/Magento/Bundle/Model/Product/Type.php @@ -12,6 +12,7 @@ use Magento\Framework\Serialize\Serializer\Json; use Magento\Framework\EntityManager\MetadataPool; use Magento\Bundle\Model\ResourceModel\Selection\Collection\FilterApplier as SelectionCollectionFilterApplier; +use Magento\Bundle\Model\ResourceModel\Selection\Collection as Selections; /** * Bundle Type Model @@ -484,7 +485,9 @@ public function getSelectionsCollection($optionIds, $product) \Magento\Catalog\Api\Data\ProductInterface::class ); - $selectionsCollection = $this->_bundleCollection->create() + /** @var Selections $selectionsCollection */ + $selectionsCollection = $this->_bundleCollection->create(); + $selectionsCollection ->addAttributeToSelect($this->_config->getProductAttributes()) ->addAttributeToSelect('tax_class_id') //used for calculation item taxes in Bundle with Dynamic Price ->setFlag('product_children', true) @@ -706,7 +709,7 @@ protected function _prepareProduct(\Magento\Framework\DataObject $buyRequest, $p $selections = $this->mergeSelectionsWithOptions($options, $selections); } - if (count($selections) > 0 || !$isStrictProcessMode) { + if ((is_array($selections) && count($selections) > 0) || !$isStrictProcessMode) { $uniqueKey = [$product->getId()]; $selectionIds = []; $qtys = $buyRequest->getBundleOptionQty(); @@ -853,8 +856,9 @@ public function getSelectionsByIds($selectionIds, $product) if (!$usedSelections || $usedSelectionsIds !== $selectionIds) { $storeId = $product->getStoreId(); - $usedSelections = $this->_bundleCollection - ->create() + /** @var Selections $usedSelections */ + $usedSelections = $this->_bundleCollection->create(); + $usedSelections ->addAttributeToSelect('*') ->setFlag('product_children', true) ->addStoreFilter($this->getStoreFilter($product)) @@ -1007,9 +1011,8 @@ public function shakeSelections($firstItem, $secondItem) ]; if ($aPosition == $bPosition) { return 0; - } else { - return $aPosition < $bPosition ? -1 : 1; } + return $aPosition < $bPosition ? -1 : 1; } /** @@ -1322,8 +1325,9 @@ protected function checkIsResult($_result) protected function mergeSelectionsWithOptions($options, $selections) { foreach ($options as $option) { - if ($option->getRequired() && count($option->getSelections()) == 1) { - $selections = array_merge($selections, $option->getSelections()); + $optionSelections = $option->getSelections(); + if ($option->getRequired() && is_array($optionSelections) && count($optionSelections) == 1) { + $selections = array_merge($selections, $optionSelections); } else { $selections = []; break; diff --git a/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php b/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php index 401374db86fef..0b6e97cfb9299 100644 --- a/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php +++ b/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php @@ -153,52 +153,43 @@ protected function _prepareBundlePriceByType($priceType, $entityIds = null) $specialPrice = $this->_addAttributeToSelect($select, 'special_price', "e.$linkField", 'cs.store_id'); $specialFrom = $this->_addAttributeToSelect($select, 'special_from_date', "e.$linkField", 'cs.store_id'); $specialTo = $this->_addAttributeToSelect($select, 'special_to_date', "e.$linkField", 'cs.store_id'); - $curentDate = new \Zend_Db_Expr('cwd.website_date'); - - $specialExpr = $connection->getCheckSql( - $connection->getCheckSql( - $specialFrom . ' IS NULL', - '1', - $connection->getCheckSql($specialFrom . ' <= ' . $curentDate, '1', '0') - ) . " > 0 AND " . $connection->getCheckSql( - $specialTo . ' IS NULL', - '1', - $connection->getCheckSql($specialTo . ' >= ' . $curentDate, '1', '0') - ) . " > 0 AND {$specialPrice} > 0 AND {$specialPrice} < 100 ", - $specialPrice, - '0' - ); + $currentDate = new \Zend_Db_Expr('cwd.website_date'); - $tierExpr = new \Zend_Db_Expr("tp.min_price"); + $specialFromDate = $connection->getDatePartSql($specialFrom); + $specialToDate = $connection->getDatePartSql($specialTo); + $specialFromExpr = "{$specialFrom} IS NULL OR {$specialFromDate} <= {$currentDate}"; + $specialToExpr = "{$specialTo} IS NULL OR {$specialToDate} >= {$currentDate}"; + $specialExpr = "{$specialPrice} IS NOT NULL AND {$specialPrice} > 0 AND {$specialPrice} < 100" + . " AND {$specialFromExpr} AND {$specialToExpr}"; + $tierExpr = new \Zend_Db_Expr('tp.min_price'); if ($priceType == \Magento\Bundle\Model\Product\Price::PRICE_TYPE_FIXED) { - $finalPrice = $connection->getCheckSql( - $specialExpr . ' > 0', - 'ROUND(' . $price . ' * (' . $specialExpr . ' / 100), 4)', - $price + $specialPriceExpr = $connection->getCheckSql( + $specialExpr, + 'ROUND(' . $price . ' * (' . $specialPrice . ' / 100), 4)', + 'NULL' ); $tierPrice = $connection->getCheckSql( $tierExpr . ' IS NOT NULL', - 'ROUND(' . $price . ' - ' . '(' . $price . ' * (' . $tierExpr . ' / 100)), 4)', + 'ROUND((1 - ' . $tierExpr . ' / 100) * ' . $price . ', 4)', 'NULL' ); - - $finalPrice = $connection->getCheckSql( - "{$tierPrice} < {$finalPrice}", - $tierPrice, - $finalPrice - ); + $finalPrice = $connection->getLeastSql([ + $price, + $connection->getIfNullSql($specialPriceExpr, $price), + $connection->getIfNullSql($tierPrice, $price), + ]); } else { - $finalPrice = new \Zend_Db_Expr("0"); + $finalPrice = new \Zend_Db_Expr('0'); $tierPrice = $connection->getCheckSql($tierExpr . ' IS NOT NULL', '0', 'NULL'); } $select->columns( [ 'price_type' => new \Zend_Db_Expr($priceType), - 'special_price' => $specialExpr, + 'special_price' => $connection->getCheckSql($specialExpr, $specialPrice, '0'), 'tier_percent' => $tierExpr, - 'orig_price' => $connection->getCheckSql($price . ' IS NULL', '0', $price), + 'orig_price' => $connection->getIfNullSql($price, '0'), 'price' => $finalPrice, 'min_price' => $finalPrice, 'max_price' => $finalPrice, @@ -246,17 +237,20 @@ protected function _calculateBundleOptionPrice() $this->_prepareBundleOptionTable(); $select = $connection->select()->from( - ['i' => $this->_getBundleSelectionTable()], + $this->_getBundleSelectionTable(), ['entity_id', 'customer_group_id', 'website_id', 'option_id'] )->group( - ['entity_id', 'customer_group_id', 'website_id', 'option_id', 'is_required', 'group_type'] - )->columns( + ['entity_id', 'customer_group_id', 'website_id', 'option_id'] + ); + $minPrice = $connection->getCheckSql('is_required = 1', 'price', 'NULL'); + $tierPrice = $connection->getCheckSql('is_required = 1', 'tier_price', 'NULL'); + $select->columns( [ - 'min_price' => $connection->getCheckSql('i.is_required = 1', 'MIN(i.price)', '0'), - 'alt_price' => $connection->getCheckSql('i.is_required = 0', 'MIN(i.price)', '0'), - 'max_price' => $connection->getCheckSql('i.group_type = 1', 'SUM(i.price)', 'MAX(i.price)'), - 'tier_price' => $connection->getCheckSql('i.is_required = 1', 'MIN(i.tier_price)', '0'), - 'alt_tier_price' => $connection->getCheckSql('i.is_required = 0', 'MIN(i.tier_price)', '0'), + 'min_price' => new \Zend_Db_Expr('MIN(' . $minPrice . ')'), + 'alt_price' => new \Zend_Db_Expr('MIN(price)'), + 'max_price' => $connection->getCheckSql('group_type = 0', 'MAX(price)', 'SUM(price)'), + 'tier_price' => new \Zend_Db_Expr('MIN(' . $tierPrice . ')'), + 'alt_tier_price' => new \Zend_Db_Expr('MIN(tier_price)'), ] ); @@ -264,45 +258,8 @@ protected function _calculateBundleOptionPrice() $connection->query($query); $this->_prepareDefaultFinalPriceTable(); - - $minPrice = new \Zend_Db_Expr( - $connection->getCheckSql('SUM(io.min_price) = 0', 'MIN(io.alt_price)', 'SUM(io.min_price)') . ' + i.price' - ); - $maxPrice = new \Zend_Db_Expr("SUM(io.max_price) + i.price"); - $tierPrice = $connection->getCheckSql( - 'MIN(i.tier_percent) IS NOT NULL', - $connection->getCheckSql( - 'SUM(io.tier_price) = 0', - 'SUM(io.alt_tier_price)', - 'SUM(io.tier_price)' - ) . ' + MIN(i.tier_price)', - 'NULL' - ); - - $select = $connection->select()->from( - ['io' => $this->_getBundleOptionTable()], - ['entity_id', 'customer_group_id', 'website_id'] - )->join( - ['i' => $this->_getBundlePriceTable()], - 'i.entity_id = io.entity_id AND i.customer_group_id = io.customer_group_id' . - ' AND i.website_id = io.website_id', - [] - )->group( - ['io.entity_id', 'io.customer_group_id', 'io.website_id', 'i.tax_class_id', 'i.orig_price', 'i.price'] - )->columns( - [ - 'i.tax_class_id', - 'orig_price' => 'i.orig_price', - 'price' => 'i.price', - 'min_price' => $minPrice, - 'max_price' => $maxPrice, - 'tier_price' => $tierPrice, - 'base_tier' => 'MIN(i.base_tier)', - ] - ); - - $query = $select->insertFromSelect($this->_getDefaultFinalPriceTable()); - $connection->query($query); + $this->applyBundlePrice(); + $this->applyBundleOptionPrice(); return $this; } @@ -348,33 +305,33 @@ protected function _calculateBundleSelectionPrice($priceType) 'ROUND(i.base_tier - (i.base_tier * (' . $selectionPriceValue . ' / 100)),4)', $connection->getCheckSql( 'i.tier_percent > 0', - 'ROUND(' . - $selectionPriceValue . - ' - (' . - $selectionPriceValue . - ' * (i.tier_percent / 100)),4)', + 'ROUND((1 - i.tier_percent / 100) * ' . $selectionPriceValue . ',4)', $selectionPriceValue ) ) . ' * bs.selection_qty', 'NULL' ); - $priceExpr = new \Zend_Db_Expr( - $connection->getCheckSql("{$tierExpr} < {$priceExpr}", $tierExpr, $priceExpr) - ); + $priceExpr = $connection->getLeastSql([ + $priceExpr, + $connection->getIfNullSql($tierExpr, $priceExpr), + ]); } else { - $priceExpr = new \Zend_Db_Expr( - $connection->getCheckSql( - 'i.special_price > 0 AND i.special_price < 100', - 'ROUND(idx.min_price * (i.special_price / 100), 4)', - 'idx.min_price' - ) . ' * bs.selection_qty' + $price = 'idx.min_price * bs.selection_qty'; + $specialExpr = $connection->getCheckSql( + 'i.special_price > 0 AND i.special_price < 100', + 'ROUND(' . $price . ' * (i.special_price / 100), 4)', + $price ); $tierExpr = $connection->getCheckSql( - 'i.base_tier IS NOT NULL', - 'ROUND(idx.min_price * (i.base_tier / 100), 4)* bs.selection_qty', + 'i.tier_percent IS NOT NULL', + 'ROUND((1 - i.tier_percent / 100) * ' . $price . ', 4)', 'NULL' ); + $priceExpr = $connection->getLeastSql([ + $specialExpr, + $connection->getIfNullSql($tierExpr, $price), + ]); } $linkField = $this->getMetadataPool()->getMetadata(ProductInterface::class)->getLinkField(); @@ -508,4 +465,76 @@ protected function _prepareTierPriceIndex($entityIds = null) return $this; } + + /** + * Create bundle price. + * + * @return void + */ + private function applyBundlePrice(): void + { + $select = $this->getConnection()->select(); + $select->from( + $this->_getBundlePriceTable(), + [ + 'entity_id', + 'customer_group_id', + 'website_id', + 'tax_class_id', + 'orig_price', + 'price', + 'min_price', + 'max_price', + 'tier_price', + 'base_tier', + ] + ); + + $query = $select->insertFromSelect($this->_getDefaultFinalPriceTable()); + $this->getConnection()->query($query); + } + + /** + * Make insert/update bundle option price. + * + * @return void + */ + private function applyBundleOptionPrice(): void + { + $connection = $this->getConnection(); + + $subSelect = $connection->select()->from( + $this->_getBundleOptionTable(), + [ + 'entity_id', + 'customer_group_id', + 'website_id', + 'min_price' => new \Zend_Db_Expr('SUM(min_price)'), + 'alt_price' => new \Zend_Db_Expr('MIN(alt_price)'), + 'max_price' => new \Zend_Db_Expr('SUM(max_price)'), + 'tier_price' => new \Zend_Db_Expr('SUM(tier_price)'), + 'alt_tier_price' => new \Zend_Db_Expr('MIN(alt_tier_price)'), + ] + )->group( + ['entity_id', 'customer_group_id', 'website_id'] + ); + + $minPrice = 'i.min_price + ' . $connection->getIfNullSql('io.min_price', '0'); + $tierPrice = 'i.tier_price + ' . $connection->getIfNullSql('io.tier_price', '0'); + $select = $connection->select()->join( + ['io' => $subSelect], + 'i.entity_id = io.entity_id AND i.customer_group_id = io.customer_group_id' . + ' AND i.website_id = io.website_id', + [] + )->columns( + [ + 'min_price' => $connection->getCheckSql("{$minPrice} = 0", 'io.alt_price', $minPrice), + 'max_price' => new \Zend_Db_Expr('io.max_price + i.max_price'), + 'tier_price' => $connection->getCheckSql("{$tierPrice} = 0", 'io.alt_tier_price', $tierPrice), + ] + ); + + $query = $select->crossUpdateFromSelect(['i' => $this->_getDefaultFinalPriceTable()]); + $connection->query($query); + } } diff --git a/app/code/Magento/Bundle/Model/ResourceModel/Option.php b/app/code/Magento/Bundle/Model/ResourceModel/Option.php index 46fd8b910f6f1..7babd0b349f02 100644 --- a/app/code/Magento/Bundle/Model/ResourceModel/Option.php +++ b/app/code/Magento/Bundle/Model/ResourceModel/Option.php @@ -81,39 +81,29 @@ protected function _afterSave(\Magento\Framework\Model\AbstractModel $object) { parent::_afterSave($object); - $conditions = [ + $condition = [ 'option_id = ?' => $object->getId(), 'store_id = ? OR store_id = 0' => $object->getStoreId(), 'parent_product_id = ?' => $object->getParentId() ]; $connection = $this->getConnection(); + $connection->delete($this->getTable('catalog_product_bundle_option_value'), $condition); - if ($this->isOptionPresent($conditions)) { - $connection->update( - $this->getTable('catalog_product_bundle_option_value'), - [ - 'title' => $object->getTitle() - ], - $conditions - ); - } else { - $data = new \Magento\Framework\DataObject(); - $data->setOptionId($object->getId()) - ->setStoreId($object->getStoreId()) - ->setParentProductId($object->getParentId()) - ->setTitle($object->getTitle()); + $data = new \Magento\Framework\DataObject(); + $data->setOptionId($object->getId()) + ->setStoreId($object->getStoreId()) + ->setParentProductId($object->getParentId()) + ->setTitle($object->getTitle()); - $connection->insert($this->getTable('catalog_product_bundle_option_value'), $data->getData()); + $connection->insert($this->getTable('catalog_product_bundle_option_value'), $data->getData()); - /** - * also saving default value if this store view scope - */ - if ($object->getStoreId()) { - $data->setStoreId(0); - $data->setTitle($object->getDefaultTitle()); - $connection->insert($this->getTable('catalog_product_bundle_option_value'), $data->getData()); - } + /** + * also saving default fallback value + */ + if (0 !== (int)$object->getStoreId()) { + $data->setStoreId(0)->setTitle($object->getDefaultTitle()); + $connection->insert($this->getTable('catalog_product_bundle_option_value'), $data->getData()); } return $this; @@ -218,26 +208,4 @@ public function save(\Magento\Framework\Model\AbstractModel $object) return $this; } - - /** - * Is Bundle option present in the database - * - * @param array $conditions - * - * @return bool - */ - private function isOptionPresent($conditions) - { - $connection = $this->getConnection(); - - $select = $connection->select()->from($this->getTable('catalog_product_bundle_option_value')); - foreach ($conditions as $condition => $conditionValue) { - $select->where($condition, $conditionValue); - } - $select->limit(1); - - $rowSelect = $connection->fetchRow($select); - - return (is_array($rowSelect) && !empty($rowSelect)); - } } diff --git a/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php b/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php index 0216812199b50..e9295b22674bd 100644 --- a/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php +++ b/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php @@ -163,22 +163,36 @@ public function setPositionOrder() } /** - * Add filtering of product then havent enoght stock + * Add filtering of products that have 0 items left. * * @return $this * @since 100.2.0 */ public function addQuantityFilter() { + $stockItemTable = $this->getTable('cataloginventory_stock_item'); + $stockStatusTable = $this->getTable('cataloginventory_stock_status'); $this->getSelect() ->joinInner( - ['stock' => $this->getTable('cataloginventory_stock_status')], + ['stock' => $stockStatusTable], 'selection.product_id = stock.product_id', [] + )->joinInner( + ['stock_item' => $stockItemTable], + 'selection.product_id = stock_item.product_id', + [] ) ->where( - '(selection.selection_can_change_qty or selection.selection_qty <= stock.qty) and stock.stock_status' - ); + '(' + . 'selection.selection_can_change_qty > 0' + . ' or ' + . 'selection.selection_qty <= stock.qty' + . ' or ' + .'stock_item.manage_stock = 0' + . ')' + ) + ->where('stock.stock_status = 1'); + return $this; } diff --git a/app/code/Magento/Bundle/Model/Sales/Order/Pdf/Items/AbstractItems.php b/app/code/Magento/Bundle/Model/Sales/Order/Pdf/Items/AbstractItems.php index 2f81308f67f50..30e37e54a21db 100644 --- a/app/code/Magento/Bundle/Model/Sales/Order/Pdf/Items/AbstractItems.php +++ b/app/code/Magento/Bundle/Model/Sales/Order/Pdf/Items/AbstractItems.php @@ -92,9 +92,8 @@ public function getChildren($item) if (isset($itemsArray[$item->getOrderItem()->getId()])) { return $itemsArray[$item->getOrderItem()->getId()]; - } else { - return null; } + return null; } /** @@ -244,9 +243,8 @@ public function getOrderItem() { if ($this->getItem() instanceof \Magento\Sales\Model\Order\Item) { return $this->getItem(); - } else { - return $this->getItem()->getOrderItem(); } + return $this->getItem()->getOrderItem(); } /** diff --git a/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php b/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php index 9d035aece57bc..adb0777151b9e 100644 --- a/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php +++ b/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php @@ -271,9 +271,8 @@ public function calculateBundleAmount($basePriceValue, $bundleProduct, $selectio { if ($bundleProduct->getPriceType() == Price::PRICE_TYPE_FIXED) { return $this->calculateFixedBundleAmount($basePriceValue, $bundleProduct, $selectionPriceList, $exclude); - } else { - return $this->calculateDynamicBundleAmount($basePriceValue, $bundleProduct, $selectionPriceList, $exclude); } + return $this->calculateDynamicBundleAmount($basePriceValue, $bundleProduct, $selectionPriceList, $exclude); } /** diff --git a/app/code/Magento/Bundle/Pricing/Adjustment/DefaultSelectionPriceListProvider.php b/app/code/Magento/Bundle/Pricing/Adjustment/DefaultSelectionPriceListProvider.php index 56c403ad9960c..297c4659cb877 100644 --- a/app/code/Magento/Bundle/Pricing/Adjustment/DefaultSelectionPriceListProvider.php +++ b/app/code/Magento/Bundle/Pricing/Adjustment/DefaultSelectionPriceListProvider.php @@ -61,8 +61,8 @@ public function getPriceList(Product $bundleProduct, $searchMin, $useRegularPric if (!$useRegularPrice) { $selectionsCollection->addAttributeToSelect('special_price'); - $selectionsCollection->addAttributeToSelect('special_price_from'); - $selectionsCollection->addAttributeToSelect('special_price_to'); + $selectionsCollection->addAttributeToSelect('special_from_date'); + $selectionsCollection->addAttributeToSelect('special_to_date'); $selectionsCollection->addAttributeToSelect('tax_class_id'); } diff --git a/app/code/Magento/Bundle/Pricing/Price/BundleOptionPrice.php b/app/code/Magento/Bundle/Pricing/Price/BundleOptionPrice.php index 995572636e759..1c724caaa28d8 100644 --- a/app/code/Magento/Bundle/Pricing/Price/BundleOptionPrice.php +++ b/app/code/Magento/Bundle/Pricing/Price/BundleOptionPrice.php @@ -8,9 +8,10 @@ use Magento\Bundle\Pricing\Adjustment\BundleCalculatorInterface; use Magento\Catalog\Model\Product; use Magento\Framework\Pricing\Price\AbstractPrice; +use Magento\Framework\App\ObjectManager; /** - * Bundle option price model + * Bundle option price model with final price. */ class BundleOptionPrice extends AbstractPrice implements BundleOptionPriceInterface { @@ -26,6 +27,7 @@ class BundleOptionPrice extends AbstractPrice implements BundleOptionPriceInterf /** * @var BundleSelectionFactory + * @deprecated */ protected $selectionFactory; @@ -34,23 +36,31 @@ class BundleOptionPrice extends AbstractPrice implements BundleOptionPriceInterf */ protected $maximalPrice; + /** + * @var BundleOptions + */ + private $bundleOptions; + /** * @param Product $saleableItem * @param float $quantity * @param BundleCalculatorInterface $calculator * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency * @param BundleSelectionFactory $bundleSelectionFactory + * @param BundleOptions|null $bundleOptions */ public function __construct( Product $saleableItem, $quantity, BundleCalculatorInterface $calculator, \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, - BundleSelectionFactory $bundleSelectionFactory + BundleSelectionFactory $bundleSelectionFactory, + BundleOptions $bundleOptions = null ) { $this->selectionFactory = $bundleSelectionFactory; parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); $this->product->setQty($this->quantity); + $this->bundleOptions = $bundleOptions ?: ObjectManager::getInstance()->get(BundleOptions::class); } /** @@ -59,94 +69,61 @@ public function __construct( public function getValue() { if (null === $this->value) { - $this->value = $this->calculateOptions(); + $this->value = $this->bundleOptions->calculateOptions($this->product); } + return $this->value; } /** - * Getter for maximal price of options + * Getter for maximal price of options. * * @return bool|float + * @deprecated */ public function getMaxValue() { if (null === $this->maximalPrice) { - $this->maximalPrice = $this->calculateOptions(false); + $this->maximalPrice = $this->bundleOptions->calculateOptions($this->product, false); } + return $this->maximalPrice; } /** - * Get Options with attached Selections collection + * Get Options with attached Selections collection. * * @return \Magento\Bundle\Model\ResourceModel\Option\Collection */ public function getOptions() { - $bundleProduct = $this->product; - /** @var \Magento\Bundle\Model\Product\Type $typeInstance */ - $typeInstance = $bundleProduct->getTypeInstance(); - $typeInstance->setStoreFilter($bundleProduct->getStoreId(), $bundleProduct); - - /** @var \Magento\Bundle\Model\ResourceModel\Option\Collection $optionCollection */ - $optionCollection = $typeInstance->getOptionsCollection($bundleProduct); - - $selectionCollection = $typeInstance->getSelectionsCollection( - $typeInstance->getOptionsIds($bundleProduct), - $bundleProduct - ); - - $priceOptions = $optionCollection->appendSelections($selectionCollection, true, false); - return $priceOptions; + return $this->bundleOptions->getOptions($this->product); } /** - * Get selection amount + * Get selection amount. * * @param \Magento\Bundle\Model\Selection $selection * @return \Magento\Framework\Pricing\Amount\AmountInterface */ public function getOptionSelectionAmount($selection) { - $cacheKey = implode( - '_', - [ - $this->product->getId(), - $selection->getOptionId(), - $selection->getSelectionId() - ] + return $this->bundleOptions->getOptionSelectionAmount( + $this->product, + $selection, + false ); - - if (!isset($this->optionSelecionAmountCache[$cacheKey])) { - $selectionPrice = $this->selectionFactory - ->create($this->product, $selection, $selection->getSelectionQty()); - $this->optionSelecionAmountCache[$cacheKey] = $selectionPrice->getAmount(); - } - - return $this->optionSelecionAmountCache[$cacheKey]; } /** - * Calculate maximal or minimal options value + * Calculate maximal or minimal options value. * * @param bool $searchMin * @return bool|float */ protected function calculateOptions($searchMin = true) { - $priceList = []; - /* @var $option \Magento\Bundle\Model\Option */ - foreach ($this->getOptions() as $option) { - if ($searchMin && !$option->getRequired()) { - continue; - } - $selectionPriceList = $this->calculator->createSelectionPriceList($option, $this->product); - $selectionPriceList = $this->calculator->processOptions($option, $selectionPriceList, $searchMin); - $priceList = array_merge($priceList, $selectionPriceList); - } - $amount = $this->calculator->calculateBundleAmount(0., $this->product, $priceList); - return $amount->getValue(); + return $this->bundleOptions->calculateOptions($this->product, $searchMin); } /** diff --git a/app/code/Magento/Bundle/Pricing/Price/BundleOptionRegularPrice.php b/app/code/Magento/Bundle/Pricing/Price/BundleOptionRegularPrice.php new file mode 100644 index 0000000000000..20262e99281de --- /dev/null +++ b/app/code/Magento/Bundle/Pricing/Price/BundleOptionRegularPrice.php @@ -0,0 +1,100 @@ +product->setQty($this->quantity); + $this->bundleOptions = $bundleOptions; + } + + /** + * {@inheritdoc} + */ + public function getValue() + { + if (null === $this->value) { + $this->value = $this->bundleOptions->calculateOptions($this->product); + } + + return $this->value; + } + + /** + * Get Options with attached Selections collection. + * + * @return \Magento\Bundle\Model\ResourceModel\Option\Collection + */ + public function getOptions() : \Magento\Bundle\Model\ResourceModel\Option\Collection + { + return $this->bundleOptions->getOptions($this->product); + } + + /** + * Get selection amount. + * + * @param \Magento\Bundle\Model\Selection $selection + * @return \Magento\Framework\Pricing\Amount\AmountInterface + */ + public function getOptionSelectionAmount($selection) : \Magento\Framework\Pricing\Amount\AmountInterface + { + return $this->bundleOptions->getOptionSelectionAmount( + $this->product, + $selection, + true + ); + } + + /** + * Get minimal amount of bundle price with options. + * + * @return \Magento\Framework\Pricing\Amount\AmountInterface + */ + public function getAmount() : \Magento\Framework\Pricing\Amount\AmountInterface + { + return $this->calculator->getOptionsAmount($this->product); + } +} diff --git a/app/code/Magento/Bundle/Pricing/Price/BundleOptions.php b/app/code/Magento/Bundle/Pricing/Price/BundleOptions.php new file mode 100644 index 0000000000000..e4951cc311737 --- /dev/null +++ b/app/code/Magento/Bundle/Pricing/Price/BundleOptions.php @@ -0,0 +1,138 @@ +calculator = $calculator; + $this->selectionFactory = $bundleSelectionFactory; + } + + /** + * Get Options with attached Selections collection. + * + * @param SaleableInterface $bundleProduct + * @return \Magento\Bundle\Model\ResourceModel\Option\Collection|array + */ + public function getOptions(SaleableInterface $bundleProduct) + { + /** @var \Magento\Bundle\Model\Product\Type $typeInstance */ + $typeInstance = $bundleProduct->getTypeInstance(); + $typeInstance->setStoreFilter($bundleProduct->getStoreId(), $bundleProduct); + + /** @var \Magento\Bundle\Model\ResourceModel\Option\Collection $optionCollection */ + $optionCollection = $typeInstance->getOptionsCollection($bundleProduct); + + /** @var \Magento\Bundle\Model\ResourceModel\Selection\Collection $selectionCollection */ + $selectionCollection = $typeInstance->getSelectionsCollection( + $typeInstance->getOptionsIds($bundleProduct), + $bundleProduct + ); + + $priceOptions = $optionCollection->appendSelections($selectionCollection, true, false); + + return $priceOptions; + } + + /** + * Calculate maximal or minimal options value. + * + * @param SaleableInterface $bundleProduct + * @param bool $searchMin + * + * @return float + */ + public function calculateOptions( + SaleableInterface $bundleProduct, + bool $searchMin = true + ) : float { + $priceList = []; + /* @var \Magento\Bundle\Model\Option $option */ + foreach ($this->getOptions($bundleProduct) as $option) { + if ($searchMin && !$option->getRequired()) { + continue; + } + /** @var \Magento\Bundle\Pricing\Price\BundleSelectionPrice $selectionPriceList */ + $selectionPriceList = $this->calculator->createSelectionPriceList($option, $bundleProduct); + $selectionPriceList = $this->calculator->processOptions($option, $selectionPriceList, $searchMin); + $priceList = array_merge($priceList, $selectionPriceList); + } + $amount = $this->calculator->calculateBundleAmount(0., $bundleProduct, $priceList); + + return $amount->getValue(); + } + + /** + * Get selection amount. + * + * @param Product $bundleProduct + * @param \Magento\Bundle\Model\Selection|Product $selection + * @param bool $useRegularPrice + * + * @return AmountInterface + */ + public function getOptionSelectionAmount( + Product $bundleProduct, + $selection, + bool $useRegularPrice = false + ) : AmountInterface { + $cacheKey = implode( + '_', + [ + $bundleProduct->getId(), + $selection->getOptionId(), + $selection->getSelectionId(), + $useRegularPrice ? 1 : 0, + ] + ); + + if (!isset($this->optionSelectionAmountCache[$cacheKey])) { + $selectionPrice = $this->selectionFactory + ->create( + $bundleProduct, + $selection, + $selection->getSelectionQty(), + ['useRegularPrice' => $useRegularPrice] + ); + $this->optionSelectionAmountCache[$cacheKey] = $selectionPrice->getAmount(); + } + + return $this->optionSelectionAmountCache[$cacheKey]; + } +} diff --git a/app/code/Magento/Bundle/Pricing/Price/BundleRegularPrice.php b/app/code/Magento/Bundle/Pricing/Price/BundleRegularPrice.php index 034b735764011..184f8b1e85eaa 100644 --- a/app/code/Magento/Bundle/Pricing/Price/BundleRegularPrice.php +++ b/app/code/Magento/Bundle/Pricing/Price/BundleRegularPrice.php @@ -52,7 +52,7 @@ public function getAmount() if ($this->product->getPriceType() == Price::PRICE_TYPE_FIXED) { /** @var \Magento\Catalog\Pricing\Price\CustomOptionPrice $customOptionPrice */ $customOptionPrice = $this->priceInfo->getPrice(CustomOptionPrice::PRICE_CODE); - $price += $customOptionPrice->getCustomOptionRange(true); + $price += $customOptionPrice->getCustomOptionRange(true, $this->getPriceCode()); } $this->amount[$this->getValue()] = $this->calculator->getMinRegularAmount($price, $this->product); } @@ -71,7 +71,7 @@ public function getMaximalPrice() if ($this->product->getPriceType() == Price::PRICE_TYPE_FIXED) { /** @var \Magento\Catalog\Pricing\Price\CustomOptionPrice $customOptionPrice */ $customOptionPrice = $this->priceInfo->getPrice(CustomOptionPrice::PRICE_CODE); - $price += $customOptionPrice->getCustomOptionRange(false); + $price += $customOptionPrice->getCustomOptionRange(false, $this->getPriceCode()); } $this->maximalPrice = $this->calculator->getMaxRegularAmount($price, $this->product); } diff --git a/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php b/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php index 71c1b5c5e98cb..db2b9547e635f 100644 --- a/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php +++ b/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php @@ -93,7 +93,7 @@ public function __construct( } /** - * Get the price value for one of selection product + * Get the price value for one of selection product. * * @return bool|float */ @@ -103,7 +103,10 @@ public function getValue() return $this->value; } $product = $this->selection; - $bundleSelectionKey = 'bundle-selection-value-' . $product->getSelectionId(); + $bundleSelectionKey = 'bundle-selection-' + . ($this->useRegularPrice ? 'regular-' : '') + . 'value-' + . $product->getSelectionId(); if ($product->hasData($bundleSelectionKey)) { return $product->getData($bundleSelectionKey); } @@ -128,7 +131,8 @@ public function getValue() 'catalog_product_get_final_price', ['product' => $product, 'qty' => $this->bundleProduct->getQty()] ); - $value = $product->getData('final_price') * ($selectionPriceValue / 100); + $price = $this->useRegularPrice ? $product->getData('price') : $product->getData('final_price'); + $value = $price * ($selectionPriceValue / 100); } else { // calculate price for selection type fixed $value = $this->priceCurrency->convert($selectionPriceValue); @@ -139,6 +143,7 @@ public function getValue() } $this->value = $this->priceCurrency->round($value); $product->setData($bundleSelectionKey, $this->value); + return $this->value; } @@ -150,7 +155,10 @@ public function getValue() public function getAmount() { $product = $this->selection; - $bundleSelectionKey = 'bundle-selection-amount-' . $product->getSelectionId(); + $bundleSelectionKey = 'bundle-selection' + . ($this->useRegularPrice ? 'regular-' : '') + . '-amount-' + . $product->getSelectionId(); if ($product->hasData($bundleSelectionKey)) { return $product->getData($bundleSelectionKey); } @@ -167,6 +175,7 @@ public function getAmount() ); $product->setData($bundleSelectionKey, $this->amount[$value]); } + return $this->amount[$value]; } @@ -177,8 +186,7 @@ public function getProduct() { if ($this->bundleProduct->getPriceType() == Price::PRICE_TYPE_DYNAMIC) { return parent::getProduct(); - } else { - return $this->bundleProduct; } + return $this->bundleProduct; } } diff --git a/app/code/Magento/Bundle/Pricing/Price/ConfiguredPrice.php b/app/code/Magento/Bundle/Pricing/Price/ConfiguredPrice.php index 274ea95474120..11f7e2f3d1f15 100644 --- a/app/code/Magento/Bundle/Pricing/Price/ConfiguredPrice.php +++ b/app/code/Magento/Bundle/Pricing/Price/ConfiguredPrice.php @@ -11,11 +11,13 @@ use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; use Magento\Catalog\Pricing\Price as CatalogPrice; use Magento\Catalog\Pricing\Price\ConfiguredPriceInterface; +use Magento\Catalog\Pricing\Price\ConfiguredPriceSelection; +use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\Framework\Serialize\Serializer\Json as JsonSerializer; /** * Configured price model * @api - * @since 100.0.2 */ class ConfiguredPrice extends CatalogPrice\FinalPrice implements ConfiguredPriceInterface { @@ -37,29 +39,39 @@ class ConfiguredPrice extends CatalogPrice\FinalPrice implements ConfiguredPrice /** * Serializer interface instance. * - * @var \Magento\Framework\Serialize\Serializer\Json + * @var JsonSerializer */ private $serializer; + /** + * @var ConfiguredPriceSelection + */ + private $configuredPriceSelection; + /** * @param Product $saleableItem * @param float $quantity * @param BundleCalculatorInterface $calculator - * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency + * @param PriceCurrencyInterface $priceCurrency * @param ItemInterface $item - * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer + * @param JsonSerializer|null $serializer + * @param ConfiguredPriceSelection|null $configuredPriceSelection */ public function __construct( Product $saleableItem, $quantity, BundleCalculatorInterface $calculator, - \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, + PriceCurrencyInterface $priceCurrency, ItemInterface $item = null, - \Magento\Framework\Serialize\Serializer\Json $serializer = null + JsonSerializer $serializer = null, + ConfiguredPriceSelection $configuredPriceSelection = null ) { $this->item = $item; $this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Framework\Serialize\Serializer\Json::class); + ->get(JsonSerializer::class); + $this->configuredPriceSelection = $configuredPriceSelection + ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(ConfiguredPriceSelection::class); parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); } @@ -74,7 +86,7 @@ public function setItem(ItemInterface $item) } /** - * Get Options with attached Selections collection + * Get Options with attached Selections collection. * * @return array|\Magento\Bundle\Model\ResourceModel\Option\Collection */ @@ -84,13 +96,14 @@ public function getOptions() $bundleOptions = []; /** @var \Magento\Bundle\Model\Product\Type $typeInstance */ $typeInstance = $bundleProduct->getTypeInstance(); - - // get bundle options - $optionsQuoteItemOption = $this->item->getOptionByCode('bundle_option_ids'); - $bundleOptionsIds = $optionsQuoteItemOption - ? $this->serializer->unserialize($optionsQuoteItemOption->getValue()) - : []; - + $bundleOptionsIds = []; + if ($this->item !== null) { + // get bundle options + $optionsQuoteItemOption = $this->item->getOptionByCode('bundle_option_ids'); + if ($optionsQuoteItemOption && $optionsQuoteItemOption->getValue()) { + $bundleOptionsIds = $this->serializer->unserialize($optionsQuoteItemOption->getValue()); + } + } if ($bundleOptionsIds) { /** @var \Magento\Bundle\Model\ResourceModel\Option\Collection $optionsCollection */ $optionsCollection = $typeInstance->getOptionsByIds($bundleOptionsIds, $bundleProduct); @@ -102,24 +115,20 @@ public function getOptions() $bundleOptions = $optionsCollection->appendSelections($selectionsCollection, true); } } + return $bundleOptions; } /** - * Option amount calculation for bundle product + * Option amount calculation for bundle product. * * @param float $baseValue * @return \Magento\Framework\Pricing\Amount\AmountInterface */ public function getConfiguredAmount($baseValue = 0.) { - $selectionPriceList = []; - foreach ($this->getOptions() as $option) { - $selectionPriceList = array_merge( - $selectionPriceList, - $this->calculator->createSelectionPriceList($option, $this->product) - ); - } + $selectionPriceList = $this->configuredPriceSelection->getSelectionPriceList($this); + return $this->calculator->calculateBundleAmount( $baseValue, $this->product, @@ -140,9 +149,8 @@ public function getValue() $this->priceInfo ->getPrice(BundleDiscountPrice::PRICE_CODE) ->calculateDiscount($configuredOptionsAmount); - } else { - return parent::getValue(); } + return parent::getValue(); } /** diff --git a/app/code/Magento/Bundle/Pricing/Price/ConfiguredRegularPrice.php b/app/code/Magento/Bundle/Pricing/Price/ConfiguredRegularPrice.php new file mode 100644 index 0000000000000..7eaee6d0fae6b --- /dev/null +++ b/app/code/Magento/Bundle/Pricing/Price/ConfiguredRegularPrice.php @@ -0,0 +1,30 @@ +calculator->createSelectionPriceList($option, $this->product, true); + } +} diff --git a/app/code/Magento/Bundle/Test/Unit/Block/Catalog/Product/View/Type/BundleTest.php b/app/code/Magento/Bundle/Test/Unit/Block/Catalog/Product/View/Type/BundleTest.php index 97e8098b8181e..ec250756d5b2b 100644 --- a/app/code/Magento/Bundle/Test/Unit/Block/Catalog/Product/View/Type/BundleTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Block/Catalog/Product/View/Type/BundleTest.php @@ -198,7 +198,7 @@ public function testGetJsonConfigFixedPriceBundle() [ ['price' => new \Magento\Framework\DataObject( ['base_amount' => $baseAmount, 'value' => $basePriceValue] - )] + )], ], true, true @@ -244,12 +244,14 @@ public function testGetJsonConfigFixedPriceBundle() ), ] ); + $bundleOptionPriceMock = $this->getAmountPriceMock( + $baseAmount, + $regularPriceMock, + [['item' => $selections[0], 'value' => $basePriceValue, 'base_amount' => 321321]] + ); $prices = [ - 'bundle_option' => $this->getAmountPriceMock( - $baseAmount, - $regularPriceMock, - [['item' => $selections[0], 'value' => $basePriceValue, 'base_amount' => 321321]] - ), + 'bundle_option' => $bundleOptionPriceMock, + 'bundle_option_regular_price' => $bundleOptionPriceMock, \Magento\Catalog\Pricing\Price\FinalPrice::PRICE_CODE => $finalPriceMock, \Magento\Catalog\Pricing\Price\RegularPrice::PRICE_CODE => $regularPriceMock, ]; @@ -261,8 +263,8 @@ public function testGetJsonConfigFixedPriceBundle() $preconfiguredValues = new \Magento\Framework\DataObject( [ 'bundle_option' => [ - 1 => 123123111 - ] + 1 => 123123111, + ], ] ); $this->product->expects($this->once()) diff --git a/app/code/Magento/Bundle/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/Plugin/BundleTest.php b/app/code/Magento/Bundle/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/Plugin/BundleTest.php index 59a2190a43e0c..1fa7f186786ae 100644 --- a/app/code/Magento/Bundle/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/Plugin/BundleTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/Plugin/BundleTest.php @@ -163,4 +163,27 @@ public function testAfterInitializeIfBundleSelectionsAndCustomOptionsExist() $this->productMock->expects($this->once())->method('setCanSaveBundleSelections')->with(false); $this->model->afterInitialize($this->subjectMock, $this->productMock); } + + /** + * @return void + */ + public function testAfterInitializeIfBundleOptionsNotExist(): void + { + $valueMap = [ + ['bundle_options', null, null], + ['affect_bundle_product_selections', null, false], + ]; + $this->requestMock->expects($this->any())->method('getPost')->will($this->returnValueMap($valueMap)); + $extentionAttribute = $this->getMockBuilder(\Magento\Catalog\Api\Data\ProductExtensionInterface::class) + ->disableOriginalConstructor() + ->setMethods(['setBundleProductOptions']) + ->getMockForAbstractClass(); + $extentionAttribute->expects($this->once())->method('setBundleProductOptions')->with([]); + $this->productMock->expects($this->any())->method('getCompositeReadonly')->will($this->returnValue(false)); + $this->productMock->expects($this->once())->method('getExtensionAttributes')->willReturn($extentionAttribute); + $this->productMock->expects($this->once())->method('setExtensionAttributes')->with($extentionAttribute); + $this->productMock->expects($this->once())->method('setCanSaveBundleSelections')->with(false); + + $this->model->afterInitialize($this->subjectMock, $this->productMock); + } } diff --git a/app/code/Magento/Bundle/Test/Unit/Model/OptionRepositoryTest.php b/app/code/Magento/Bundle/Test/Unit/Model/OptionRepositoryTest.php index 7549d402a57ff..b4a466b413af0 100644 --- a/app/code/Magento/Bundle/Test/Unit/Model/OptionRepositoryTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Model/OptionRepositoryTest.php @@ -14,11 +14,6 @@ */ class OptionRepositoryTest extends \PHPUnit\Framework\TestCase { - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $metadataPoolMock; - /** * @var \Magento\Bundle\Model\OptionRepository */ @@ -67,12 +62,12 @@ class OptionRepositoryTest extends \PHPUnit\Framework\TestCase /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $linkListMock; + protected $dataObjectHelperMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Bundle\Model\Option\SaveAction|\PHPUnit_Framework_MockObject_MockObject */ - protected $dataObjectHelperMock; + private $optionSaveActionMock; protected function setUp() { @@ -94,24 +89,18 @@ protected function setUp() $this->storeManagerMock = $this->createMock(\Magento\Store\Model\StoreManagerInterface::class); $this->linkManagementMock = $this->createMock(\Magento\Bundle\Api\ProductLinkManagementInterface::class); $this->optionListMock = $this->createMock(\Magento\Bundle\Model\Product\OptionList::class); - $this->linkListMock = $this->createMock(\Magento\Bundle\Model\Product\LinksList::class); - $this->metadataPoolMock = $this->createMock(\Magento\Framework\EntityManager\MetadataPool::class); + $this->optionSaveActionMock = $this->createMock(\Magento\Bundle\Model\Option\SaveAction::class); $this->model = new OptionRepository( $this->productRepositoryMock, $this->typeMock, $this->optionFactoryMock, $this->optionResourceMock, - $this->storeManagerMock, $this->linkManagementMock, $this->optionListMock, - $this->linkListMock, - $this->dataObjectHelperMock + $this->dataObjectHelperMock, + $this->optionSaveActionMock ); - $refClass = new \ReflectionClass(OptionRepository::class); - $refProperty = $refClass->getProperty('metadataPool'); - $refProperty->setAccessible(true); - $refProperty->setValue($this->model, $this->metadataPoolMock); } /** @@ -174,7 +163,7 @@ public function testGet() $productMock->expects($this->once()) ->method('getTypeId') ->willReturn(\Magento\Catalog\Model\Product\Type::TYPE_BUNDLE); - $productMock->expects($this->once())->method('getSku')->willReturn($productSku); + $productMock->expects($this->exactly(2))->method('getSku')->willReturn($productSku); $this->productRepositoryMock->expects($this->once()) ->method('get') @@ -194,7 +183,6 @@ public function testGet() $optionMock->expects($this->once())->method('getData')->willReturn($optionData); $linkMock = ['item']; - $this->linkListMock->expects($this->once())->method('getItems')->with($productMock, 100)->willReturn($linkMock); $newOptionMock = $this->createMock(\Magento\Bundle\Api\Data\OptionInterface::class); $this->dataObjectHelperMock->expects($this->once()) @@ -207,10 +195,10 @@ public function testGet() ->with($optionData['title']) ->willReturnSelf(); $newOptionMock->expects($this->once())->method('setSku')->with()->willReturnSelf(); - $newOptionMock->expects($this->once()) - ->method('setProductLinks') - ->with($linkMock) - ->willReturnSelf(); + $this->linkManagementMock->expects($this->once()) + ->method('getChildren') + ->with($productSku, $optionId) + ->willReturn($linkMock); $this->optionFactoryMock->expects($this->once())->method('create')->willReturn($newOptionMock); @@ -272,172 +260,67 @@ public function testDeleteById() */ public function testSaveExistingOption() { - $productId = 1; - - $storeId = 2; $optionId = 5; - $existingLinkToUpdateId = '23'; + + $productSku = 'sku'; $productMock = $this->createMock(\Magento\Catalog\Model\Product::class); - $productMock->expects($this->once())->method('getData')->willReturn($productId); - $productMock->expects($this->once())->method('getStoreId')->willReturn($storeId); - $optionCollectionMock = $this->getMockBuilder(\Magento\Bundle\Model\ResourceModel\Option\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - $this->typeMock->expects($this->once()) - ->method('getOptionsCollection') - ->with($productMock) - ->willReturn($optionCollectionMock); - $optionCollectionMock->expects($this->once())->method('setIdFilter')->with($optionId)->willReturnSelf(); + $productMock->expects($this->once())->method('getSku')->willReturn($productSku); $optionMock = $this->createPartialMock( \Magento\Bundle\Model\Option::class, ['setStoreId', 'setParentId', 'getProductLinks', 'getOptionId', 'getResource'] ); - $optionCollectionMock->expects($this->once())->method('getFirstItem')->willReturn($optionMock); - $metadataMock = $this->createMock(\Magento\Framework\EntityManager\EntityMetadata::class); - $metadataMock->expects($this->once())->method('getLinkField')->willReturn('product_option'); - - $this->metadataPoolMock->expects($this->once())->method('getMetadata') - ->with(\Magento\Catalog\Api\Data\ProductInterface::class) - ->willReturn($metadataMock); $optionMock->expects($this->atLeastOnce())->method('getOptionId')->willReturn($optionId); - $productLinkUpdate = $this->createMock(\Magento\Bundle\Api\Data\LinkInterface::class); - $productLinkUpdate->expects($this->any())->method('getId')->willReturn($existingLinkToUpdateId); - $productLinkNew = $this->createMock(\Magento\Bundle\Api\Data\LinkInterface::class); - $productLinkNew->expects($this->any())->method('getId')->willReturn(null); - $optionMock->expects($this->exactly(2)) - ->method('getProductLinks') - ->willReturn([$productLinkUpdate, $productLinkNew]); + $this->optionSaveActionMock->expects($this->once())->method('save')->with($productMock, $optionMock) + ->willReturn($optionMock); + + $this->productRepositoryMock + ->expects($this->once()) + ->method('get') + ->with($productSku) + ->willReturn($productMock); - $this->linkManagementMock->expects($this->exactly(2)) - ->method('addChild') + $this->productRepositoryMock + ->expects($this->once()) + ->method('save') ->with($productMock); + $this->assertEquals($optionId, $this->model->save($productMock, $optionMock)); } public function testSaveNewOption() { - $productId = 1; - $productSku = 'bundle_sku'; - $storeId = 2; $optionId = 5; - $productMock = $this->createMock(\Magento\Catalog\Model\Product::class); - $productMock->expects($this->once())->method('getData')->willReturn($productId); - $productMock->expects($this->once())->method('getStoreId')->willReturn($storeId); - $productMock->expects($this->any())->method('getSku')->willReturn($productSku); + $productSku = 'sku'; - $optionCollectionMock = $this->getMockBuilder(\Magento\Bundle\Model\ResourceModel\Option\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - $this->typeMock->expects($this->once()) - ->method('getOptionsCollection') - ->with($productMock) - ->willReturn($optionCollectionMock); - $optionCollectionMock->expects($this->once())->method('setIdFilter')->with($optionId)->willReturnSelf(); + $productMock = $this->createMock(\Magento\Catalog\Model\Product::class); + $productMock->expects($this->once())->method('getSku')->willReturn($productSku); $optionMock = $this->createPartialMock( \Magento\Bundle\Model\Option::class, - [ - 'setStoreId', - 'setParentId', - 'getProductLinks', - 'getOptionId', - 'setOptionId', - 'setDefaultTitle', - 'getTitle', - 'getResource' - ] - ); - $optionCollectionMock->expects($this->once())->method('getFirstItem')->willReturn($optionMock); - $metadataMock = $this->createMock( - \Magento\Framework\EntityManager\EntityMetadata::class + ['setStoreId', 'setParentId', 'getProductLinks', 'getOptionId', 'getResource'] ); - $metadataMock->expects($this->once())->method('getLinkField')->willReturn('product_option'); - - $this->metadataPoolMock->expects($this->once())->method('getMetadata') - ->with(\Magento\Catalog\Api\Data\ProductInterface::class) - ->willReturn($metadataMock); - $optionMock->method('getOptionId') - ->willReturn($optionId); - - $productLink1 = $this->createMock(\Magento\Bundle\Api\Data\LinkInterface::class); - $productLink2 = $this->createMock(\Magento\Bundle\Api\Data\LinkInterface::class); - $optionMock->expects($this->exactly(2)) - ->method('getProductLinks') - ->willReturn([$productLink1, $productLink2]); - - $this->optionResourceMock->expects($this->once())->method('save')->with($optionMock)->willReturnSelf(); - $this->linkManagementMock->expects($this->at(0)) - ->method('addChild') - ->with($productMock, $optionId, $productLink1); - $this->linkManagementMock->expects($this->at(1)) - ->method('addChild') - ->with($productMock, $optionId, $productLink2); - $this->assertEquals($optionId, $this->model->save($productMock, $optionMock)); - } - /** - * @expectedException \Magento\Framework\Exception\CouldNotSaveException - * @expectedExceptionMessage The option couldn't be saved. - */ - public function testSaveCanNotSave() - { - $productId = 1; - $productSku = 'bundle_sku'; - $storeId = 2; - $optionId = 5; + $optionMock->expects($this->atLeastOnce())->method('getOptionId')->willReturn($optionId); - $productMock = $this->createMock(\Magento\Catalog\Model\Product::class); - $productMock->expects($this->once())->method('getData')->willReturn($productId); - $productMock->expects($this->once())->method('getStoreId')->willReturn($storeId); - $productMock->expects($this->any())->method('getSku')->willReturn($productSku); + $this->optionSaveActionMock->expects($this->once())->method('save')->with($productMock, $optionMock) + ->willReturn($optionMock); - $optionCollectionMock = $this->getMockBuilder(\Magento\Bundle\Model\ResourceModel\Option\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - $this->typeMock->expects($this->once()) - ->method('getOptionsCollection') - ->with($productMock) - ->willReturn($optionCollectionMock); - $optionCollectionMock->expects($this->once())->method('setIdFilter')->with($optionId)->willReturnSelf(); + $this->productRepositoryMock + ->expects($this->once()) + ->method('get') + ->with($productSku) + ->willReturn($productMock); - $optionMock = $this->createPartialMock( - \Magento\Bundle\Model\Option::class, - [ - 'setStoreId', - 'setParentId', - 'getProductLinks', - 'getOptionId', - 'setOptionId', - 'setDefaultTitle', - 'getTitle', - 'getResource' - ] - ); - $optionCollectionMock->expects($this->once())->method('getFirstItem')->willReturn($optionMock); - $metadataMock = $this->createMock( - \Magento\Framework\EntityManager\EntityMetadata::class - ); - $metadataMock->expects($this->once())->method('getLinkField')->willReturn('product_option'); - - $this->metadataPoolMock->expects($this->once())->method('getMetadata') - ->with(\Magento\Catalog\Api\Data\ProductInterface::class) - ->willReturn($metadataMock); - $optionMock->method('getOptionId')->willReturn($optionId); - - $productLink1 = $this->createMock(\Magento\Bundle\Api\Data\LinkInterface::class); - $productLink2 = $this->createMock(\Magento\Bundle\Api\Data\LinkInterface::class); - $optionMock->expects($this->exactly(2)) - ->method('getProductLinks') - ->willReturn([$productLink1, $productLink2]); - - $this->optionResourceMock->expects($this->once())->method('save')->with($optionMock) - ->willThrowException($this->createMock(\Exception::class)); - $this->model->save($productMock, $optionMock); + $this->productRepositoryMock + ->expects($this->once()) + ->method('save') + ->with($productMock); + $this->assertEquals($optionId, $this->model->save($productMock, $optionMock)); } public function testGetList() diff --git a/app/code/Magento/Bundle/Test/Unit/Model/ResourceModel/Selection/CollectionTest.php b/app/code/Magento/Bundle/Test/Unit/Model/ResourceModel/Selection/CollectionTest.php index cbe34639e8267..e595f9a47f060 100644 --- a/app/code/Magento/Bundle/Test/Unit/Model/ResourceModel/Selection/CollectionTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Model/ResourceModel/Selection/CollectionTest.php @@ -111,17 +111,46 @@ protected function setUp() public function testAddQuantityFilter() { - $tableName = 'cataloginventory_stock_status'; - $this->entity->expects($this->once()) + $statusTableName = 'cataloginventory_stock_status'; + $itemTableName = 'cataloginventory_stock_item'; + $this->entity->expects($this->exactly(2)) ->method('getTable') - ->willReturn($tableName); - $this->select->expects($this->once()) + ->willReturnMap([ + ['cataloginventory_stock_item', $itemTableName], + ['cataloginventory_stock_status', $statusTableName], + ]); + $this->select->expects($this->exactly(2)) ->method('joinInner') - ->with( - ['stock' => $tableName], - 'selection.product_id = stock.product_id', - [] + ->withConsecutive( + [ + ['stock' => $statusTableName], + 'selection.product_id = stock.product_id', + [], + ], + [ + ['stock_item' => $itemTableName], + 'selection.product_id = stock_item.product_id', + [], + ] )->willReturnSelf(); + $this->select + ->expects($this->exactly(2)) + ->method('where') + ->withConsecutive( + [ + '(' + . 'selection.selection_can_change_qty > 0' + . ' or ' + . 'selection.selection_qty <= stock.qty' + . ' or ' + .'stock_item.manage_stock = 0' + . ')', + ], + [ + 'stock.stock_status = 1', + ] + )->willReturnSelf(); + $this->assertEquals($this->model, $this->model->addQuantityFilter()); } } diff --git a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionPriceTest.php b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionPriceTest.php index b6485d0e441e9..91755cb24178a 100644 --- a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionPriceTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionPriceTest.php @@ -7,96 +7,48 @@ namespace Magento\Bundle\Test\Unit\Pricing\Price; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Framework\Pricing\Amount\AmountInterface; +use Magento\Catalog\Model\Product; +use Magento\Bundle\Pricing\Price\BundleOptions; +use Magento\Bundle\Pricing\Adjustment\Calculator; +use \Magento\Bundle\Model\Selection; -/** - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ class BundleOptionPriceTest extends \PHPUnit\Framework\TestCase { /** * @var \Magento\Bundle\Pricing\Price\BundleOptionPrice */ - protected $bundleOptionPrice; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $baseCalculator; + private $bundleOptionPrice; /** * @var ObjectManagerHelper */ - protected $objectManagerHelper; + private $objectManagerHelper; /** * @var \Magento\Framework\Pricing\SaleableInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $saleableItemMock; + private $saleableItemMock; /** * @var \Magento\Bundle\Pricing\Adjustment\BundleCalculatorInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $bundleCalculatorMock; + private $bundleCalculatorMock; /** - * @var \Magento\Bundle\Pricing\Price\BundleSelectionFactory|\PHPUnit_Framework_MockObject_MockObject + * @var BundleOptions|\PHPUnit_Framework_MockObject_MockObject */ - protected $selectionFactoryMock; + private $bundleOptionsMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @inheritdoc */ - protected $amountFactory; - - /** - * @var \Magento\Framework\Pricing\PriceInfo\Base|\PHPUnit_Framework_MockObject_MockObject - */ - protected $priceInfoMock; - protected function setUp() { - $this->priceInfoMock = $this->createMock(\Magento\Framework\Pricing\PriceInfo\Base::class); - $this->saleableItemMock = $this->createMock(\Magento\Catalog\Model\Product::class); - $priceCurrency = $this->getMockBuilder(\Magento\Framework\Pricing\PriceCurrencyInterface::class)->getMock(); - $this->saleableItemMock->expects($this->once()) - ->method('getPriceInfo') - ->will($this->returnValue($this->priceInfoMock)); + $this->bundleOptionsMock = $this->createMock(BundleOptions::class); + $this->saleableItemMock = $this->createMock(Product::class); + $this->bundleCalculatorMock = $this->createMock(Calculator::class); - $store = $this->getMockBuilder(\Magento\Store\Model\Store::class) - ->disableOriginalConstructor() - ->getMock(); - $priceCurrency->expects($this->any())->method('round')->will($this->returnArgument(0)); - - $this->saleableItemMock->expects($this->once()) - ->method('setQty') - ->will($this->returnSelf()); - - $this->saleableItemMock->expects($this->any()) - ->method('getStore') - ->will($this->returnValue($store)); - - $this->selectionFactoryMock = $this->getMockBuilder(\Magento\Bundle\Pricing\Price\BundleSelectionFactory::class) - ->disableOriginalConstructor() - ->getMock(); - $this->amountFactory = $this->createMock(\Magento\Framework\Pricing\Amount\AmountFactory::class); - $factoryCallback = $this->returnCallback( - function ($fullAmount, $adjustments) { - return $this->createAmountMock(['amount' => $fullAmount, 'adjustmentAmounts' => $adjustments]); - } - ); - $this->amountFactory->expects($this->any())->method('create')->will($factoryCallback); - $this->baseCalculator = $this->createMock(\Magento\Framework\Pricing\Adjustment\Calculator::class); - - $taxData = $this->getMockBuilder(\Magento\Tax\Helper\Data::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->bundleCalculatorMock = $this->getMockBuilder(\Magento\Bundle\Pricing\Adjustment\Calculator::class) - ->setConstructorArgs( - [$this->baseCalculator, $this->amountFactory, $this->selectionFactoryMock, $taxData, $priceCurrency] - ) - ->setMethods(['getOptionsAmount']) - ->getMock(); $this->objectManagerHelper = new ObjectManagerHelper($this); $this->bundleOptionPrice = $this->objectManagerHelper->getObject( \Magento\Bundle\Pricing\Price\BundleOptionPrice::class, @@ -104,107 +56,50 @@ function ($fullAmount, $adjustments) { 'saleableItem' => $this->saleableItemMock, 'quantity' => 1., 'calculator' => $this->bundleCalculatorMock, - 'bundleSelectionFactory' => $this->selectionFactoryMock + 'bundleOptions' => $this->bundleOptionsMock, ] ); } /** - * @dataProvider getOptionsDataProvider - */ - public function testGetOptions($selectionCollection) - { - $this->prepareOptionMocks($selectionCollection); - $this->assertSame($selectionCollection, $this->bundleOptionPrice->getOptions()); - $this->assertSame($selectionCollection, $this->bundleOptionPrice->getOptions()); - } - - /** - * @param array $selectionCollection + * Test method \Magento\Bundle\Pricing\Price\BundleOptionPrice::getOptions + * * @return void */ - protected function prepareOptionMocks($selectionCollection) + public function testGetOptions() { - $this->saleableItemMock->expects($this->atLeastOnce()) - ->method('getStoreId') - ->will($this->returnValue(1)); - - $priceTypeMock = $this->createMock(\Magento\Bundle\Model\Product\Type::class); - $priceTypeMock->expects($this->atLeastOnce()) - ->method('setStoreFilter') - ->with($this->equalTo(1), $this->equalTo($this->saleableItemMock)) - ->will($this->returnSelf()); - - $optionIds = ['41', '55']; - $priceTypeMock->expects($this->atLeastOnce()) - ->method('getOptionsIds') - ->with($this->equalTo($this->saleableItemMock)) - ->will($this->returnValue($optionIds)); - - $priceTypeMock->expects($this->atLeastOnce()) - ->method('getSelectionsCollection') - ->with($this->equalTo($optionIds), $this->equalTo($this->saleableItemMock)) - ->will($this->returnValue($selectionCollection)); - $collection = $this->createMock(\Magento\Bundle\Model\ResourceModel\Option\Collection::class); - $collection->expects($this->atLeastOnce()) - ->method('appendSelections') - ->with($this->equalTo($selectionCollection), $this->equalTo(true), $this->equalTo(false)) - ->will($this->returnValue($selectionCollection)); - - $priceTypeMock->expects($this->atLeastOnce()) - ->method('getOptionsCollection') - ->with($this->equalTo($this->saleableItemMock)) + $this->bundleOptionsMock->expects($this->any()) + ->method('getOptions') ->will($this->returnValue($collection)); - - $this->saleableItemMock->expects($this->atLeastOnce()) - ->method('getTypeInstance') - ->will($this->returnValue($priceTypeMock)); - } - - public function getOptionsDataProvider() - { - return [ - ['1', '2'] - ]; + $this->assertEquals($collection, $this->bundleOptionPrice->getOptions()); } /** - * @param float $selectionQty - * @param float|bool $selectionAmount - * @dataProvider selectionAmountDataProvider + * Test method \Magento\Bundle\Pricing\Price\BundleOptionPrice::getOptionSelectionAmount + * + * @return void */ - public function testGetOptionSelectionAmount($selectionQty, $selectionAmount) + public function testGetOptionSelectionAmount() { - $selection = $this->createPartialMock(\Magento\Catalog\Model\Product::class, ['getSelectionQty', '__wakeup']); - $selection->expects($this->once()) - ->method('getSelectionQty') - ->will($this->returnValue($selectionQty)); - $priceMock = $this->createMock(\Magento\Bundle\Pricing\Price\BundleSelectionPrice::class); - $priceMock->expects($this->once()) - ->method('getAmount') - ->will($this->returnValue($selectionAmount)); - $this->selectionFactoryMock->expects($this->once()) - ->method('create') - ->with($this->equalTo($this->saleableItemMock), $this->equalTo($selection), $this->equalTo($selectionQty)) - ->will($this->returnValue($priceMock)); - $this->assertSame($selectionAmount, $this->bundleOptionPrice->getOptionSelectionAmount($selection)); + $selectionAmount = $this->createMock(AmountInterface::class); + $product = $this->createMock(Product::class); + $selection = $this->createMock(Selection::class); + $this->bundleOptionsMock->expects($this->any()) + ->method('getOptionSelectionAmount') + ->will($this->returnValue($selectionAmount)) + ->with($product, $selection, false); + $this->assertEquals($selectionAmount, $this->bundleOptionPrice->getOptionSelectionAmount($selection)); } /** - * @return array + * Test method \Magento\Bundle\Pricing\Price\BundleOptionPrice::getAmount + * + * @return void */ - public function selectionAmountDataProvider() - { - return [ - [1., 50.5], - [2.2, false] - ]; - } - public function testGetAmount() { - $amountMock = $this->createMock(\Magento\Framework\Pricing\Amount\AmountInterface::class); + $amountMock = $this->createMock(AmountInterface::class); $this->bundleCalculatorMock->expects($this->once()) ->method('getOptionsAmount') ->with($this->equalTo($this->saleableItemMock)) @@ -213,204 +108,14 @@ public function testGetAmount() } /** - * Create amount mock - * - * @param array $amountData - * @return \Magento\Framework\Pricing\Amount\Base|\PHPUnit_Framework_MockObject_MockObject - */ - protected function createAmountMock($amountData) - { - /** @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Framework\Pricing\Amount\Base $amount */ - $amount = $this->createMock(\Magento\Framework\Pricing\Amount\Base::class); - $amount->expects($this->any())->method('getAdjustmentAmounts')->will( - $this->returnValue(isset($amountData['adjustmentAmounts']) ? $amountData['adjustmentAmounts'] : []) - ); - $amount->expects($this->any())->method('getValue')->will($this->returnValue($amountData['amount'])); - return $amount; - } - - /** - * Create option mock + * Test method \Magento\Bundle\Pricing\Price\BundleOptionPrice::getValue * - * @param array $optionData - * @return \Magento\Bundle\Model\Option|\PHPUnit_Framework_MockObject_MockObject - */ - protected function createOptionMock($optionData) - { - /** @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Bundle\Model\Option $option */ - $option = $this->createPartialMock(\Magento\Bundle\Model\Option::class, ['isMultiSelection', '__wakeup']); - $option->expects($this->any())->method('isMultiSelection') - ->will($this->returnValue($optionData['isMultiSelection'])); - $selections = []; - foreach ($optionData['selections'] as $selectionData) { - $selections[] = $this->createSelectionMock($selectionData); - } - foreach ($optionData['data'] as $key => $value) { - $option->setData($key, $value); - } - $option->setData('selections', $selections); - return $option; - } - - /** - * Create selection product mock - * - * @param array $selectionData - * @return \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject - */ - protected function createSelectionMock($selectionData) - { - $selection = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->setMethods(['isSalable', 'getAmount', 'getQuantity', 'getProduct', '__wakeup']) - ->disableOriginalConstructor() - ->getMock(); - - // All items are saleable - $selection->expects($this->any())->method('isSalable')->will($this->returnValue(true)); - foreach ($selectionData['data'] as $key => $value) { - $selection->setData($key, $value); - } - $amountMock = $this->createAmountMock($selectionData['amount']); - $selection->expects($this->any())->method('getAmount')->will($this->returnValue($amountMock)); - $selection->expects($this->any())->method('getQuantity')->will($this->returnValue(1)); - - $innerProduct = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->setMethods(['getSelectionCanChangeQty', '__wakeup']) - ->disableOriginalConstructor() - ->getMock(); - $innerProduct->expects($this->any())->method('getSelectionCanChangeQty')->will($this->returnValue(true)); - $selection->expects($this->any())->method('getProduct')->will($this->returnValue($innerProduct)); - - return $selection; - } - - /** - * @dataProvider getTestDataForCalculation - */ - public function testCalculation($optionList, $expected) - { - $storeId = 1; - $this->saleableItemMock->expects($this->any())->method('getStoreId')->will($this->returnValue($storeId)); - $this->selectionFactoryMock->expects($this->any())->method('create')->will($this->returnArgument(1)); - - $this->baseCalculator->expects($this->atLeastOnce())->method('getAmount') - ->will($this->returnValue($this->createAmountMock(['amount' => 0.]))); - - $options = []; - foreach ($optionList as $optionData) { - $options[] = $this->createOptionMock($optionData); - } - /** @var \PHPUnit_Framework_MockObject_MockObject $optionsCollection */ - $optionsCollection = $this->createMock(\Magento\Bundle\Model\ResourceModel\Option\Collection::class); - $optionsCollection->expects($this->atLeastOnce())->method('appendSelections')->will($this->returnSelf()); - $optionsCollection->expects($this->atLeastOnce())->method('getIterator') - ->will($this->returnValue(new \ArrayIterator($options))); - - /** @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Catalog\Model\Product\Type\AbstractType $typeMock */ - $typeMock = $this->createMock(\Magento\Bundle\Model\Product\Type::class); - $typeMock->expects($this->any())->method('setStoreFilter')->with($storeId, $this->saleableItemMock); - $typeMock->expects($this->any())->method('getOptionsCollection')->with($this->saleableItemMock) - ->will($this->returnValue($optionsCollection)); - $this->saleableItemMock->expects($this->any())->method('getTypeInstance')->will($this->returnValue($typeMock)); - - $this->assertEquals($expected['min'], $this->bundleOptionPrice->getValue()); - $this->assertEquals($expected['max'], $this->bundleOptionPrice->getMaxValue()); - } - - /** - * @return array + * @return void */ - public function getTestDataForCalculation() + public function testGetValue() { - return [ - 'first case' => [ - 'optionList' => [ - // first option with single choice of product - [ - 'isMultiSelection' => false, - 'data' => [ - 'title' => 'test option 1', - 'default_title' => 'test option 1', - 'type' => 'select', - 'option_id' => '1', - 'position' => '0', - 'required' => '1', - ], - 'selections' => [ - [ - 'data' => ['price' => 70.], - 'amount' => ['amount' => 70], - ], - [ - 'data' => ['price' => 80.], - 'amount' => ['amount' => 80] - ], - [ - 'data' => ['price' => 50.], - 'amount' => ['amount' => 50] - ], - ] - ], - // second not required option - [ - 'isMultiSelection' => false, - 'data' => [ - 'title' => 'test option 2', - 'default_title' => 'test option 2', - 'type' => 'select', - 'option_id' => '2', - 'position' => '1', - 'required' => '0', - ], - 'selections' => [ - [ - 'data' => ['value' => 20.], - 'amount' => ['amount' => 20], - ], - ] - ], - // third with multi-selection - [ - 'isMultiSelection' => true, - 'data' => [ - 'title' => 'test option 3', - 'default_title' => 'test option 3', - 'type' => 'select', - 'option_id' => '3', - 'position' => '2', - 'required' => '1', - ], - 'selections' => [ - [ - 'data' => ['price' => 40.], - 'amount' => ['amount' => 40], - ], - [ - 'data' => ['price' => 20.], - 'amount' => ['amount' => 20] - ], - [ - 'data' => ['price' => 60.], - 'amount' => ['amount' => 60] - ], - ] - ], - // fourth without selections - [ - 'isMultiSelection' => true, - 'data' => [ - 'title' => 'test option 3', - 'default_title' => 'test option 3', - 'type' => 'select', - 'option_id' => '4', - 'position' => '3', - 'required' => '1', - ], - 'selections' => [] - ], - ], - 'expected' => ['min' => 70, 'max' => 220], - ] - ]; + $value = 1; + $this->bundleOptionsMock->expects($this->any())->method('calculateOptions')->will($this->returnValue($value)); + $this->assertEquals($value, $this->bundleOptionPrice->getValue()); } } diff --git a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionRegularPriceTest.php b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionRegularPriceTest.php new file mode 100644 index 0000000000000..33ccc2cfb8af4 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionRegularPriceTest.php @@ -0,0 +1,127 @@ +bundleOptionsMock = $this->createMock(BundleOptions::class); + $this->saleableItemMock = $this->createMock(Product::class); + $this->bundleCalculatorMock = $this->createMock(Calculator::class); + + $this->objectManagerHelper = new ObjectManagerHelper($this); + $this->bundleOptionRegularPrice = $this->objectManagerHelper->getObject( + BundleOptionRegularPrice::class, + [ + 'saleableItem' => $this->saleableItemMock, + 'quantity' => 1., + 'calculator' => $this->bundleCalculatorMock, + 'bundleOptions' => $this->bundleOptionsMock, + ] + ); + } + + /** + * Test method \Magento\Bundle\Pricing\Price\BundleOptionRegularPrice::getOptions + * + * @return void + */ + public function testGetOptions() + { + $collection = $this->createMock(Collection::class); + $this->bundleOptionsMock->expects($this->any()) + ->method('getOptions') + ->will($this->returnValue($collection)); + $this->assertEquals($collection, $this->bundleOptionRegularPrice->getOptions()); + } + + /** + * Test method \Magento\Bundle\Pricing\Price\BundleOptionRegularPrice::getOptionSelectionAmount + * + * @return void + */ + public function testGetOptionSelectionAmount() + { + $selectionAmount = $this->createMock(AmountInterface::class); + $product = $this->createMock(Product::class); + $selection = $this->createMock(Selection::class); + $this->bundleOptionsMock->expects($this->any()) + ->method('getOptionSelectionAmount') + ->will($this->returnValue($selectionAmount)) + ->with($product, $selection, true); + $this->assertEquals($selectionAmount, $this->bundleOptionRegularPrice->getOptionSelectionAmount($selection)); + } + + /** + * Test method \Magento\Bundle\Pricing\Price\BundleOptionRegularPrice::getAmount + * + * @return void + */ + public function testGetAmount() + { + $amountMock = $this->createMock(AmountInterface::class); + $this->bundleCalculatorMock->expects($this->once()) + ->method('getOptionsAmount') + ->with($this->equalTo($this->saleableItemMock)) + ->will($this->returnValue($amountMock)); + $this->assertSame($amountMock, $this->bundleOptionRegularPrice->getAmount()); + } + + /** + * Test method \Magento\Bundle\Pricing\Price\BundleOptionRegularPrice::getValue + * + * @return void + */ + public function testGetValue() + { + $value = 1; + $this->bundleOptionsMock->expects($this->any())->method('calculateOptions')->will($this->returnValue($value)); + $this->assertEquals($value, $this->bundleOptionRegularPrice->getValue()); + } +} diff --git a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionsTest.php b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionsTest.php new file mode 100644 index 0000000000000..37973b9b8ae28 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleOptionsTest.php @@ -0,0 +1,449 @@ +priceInfoMock = $this->getMockBuilder(BasePriceInfo::class) + ->disableOriginalConstructor() + ->getMock(); + $this->saleableItemMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + $priceCurrency = $this->getMockBuilder(PriceCurrencyInterface::class)->getMock(); + $priceCurrency->expects($this->any())->method('round')->willReturnArgument(0); + + $this->selectionFactoryMock = $this->getMockBuilder(BundleSelectionFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->amountFactory = $this->getMockBuilder(AmountFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $factoryCallback = $this->returnCallback( + function ($fullAmount, $adjustments) { + return $this->createAmountMock(['amount' => $fullAmount, 'adjustmentAmounts' => $adjustments]); + } + ); + $this->amountFactory->expects($this->any())->method('create')->will($factoryCallback); + $this->baseCalculator = $this->getMockBuilder(AdjustmentCalculator::class) + ->disableOriginalConstructor() + ->getMock(); + + $taxData = $this->getMockBuilder(TaxHelperData::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->bundleCalculatorMock = $this->getMockBuilder(BundleAdjustmentCalculator::class) + ->setConstructorArgs( + [$this->baseCalculator, $this->amountFactory, $this->selectionFactoryMock, $taxData, $priceCurrency] + ) + ->setMethods(['getOptionsAmount']) + ->getMock(); + $this->objectManagerHelper = new ObjectManagerHelper($this); + $this->bundleOptions = $this->objectManagerHelper->getObject( + BundleOptions::class, + [ + 'calculator' => $this->bundleCalculatorMock, + 'bundleSelectionFactory' => $this->selectionFactoryMock, + ] + ); + } + + /** + * @dataProvider getOptionsDataProvider + * @param array $selectionCollection + * + * @return void + */ + public function testGetOptions(array $selectionCollection) + { + $this->prepareOptionMocks($selectionCollection); + $this->bundleOptions->getOptions($this->saleableItemMock); + $this->assertSame($selectionCollection, $this->bundleOptions->getOptions($this->saleableItemMock)); + } + + /** + * @param array $selectionCollection + * + * @return void + */ + private function prepareOptionMocks(array $selectionCollection) + { + $this->saleableItemMock->expects($this->atLeastOnce()) + ->method('getStoreId') + ->willReturn(1); + $priceTypeMock = $this->getMockBuilder(BundleProductType::class) + ->disableOriginalConstructor() + ->getMock(); + $priceTypeMock->expects($this->atLeastOnce()) + ->method('setStoreFilter') + ->with(1, $this->saleableItemMock) + ->willReturnSelf(); + $optionIds = ['41', '55']; + $priceTypeMock->expects($this->atLeastOnce()) + ->method('getOptionsIds') + ->with($this->saleableItemMock) + ->willReturn($optionIds); + $priceTypeMock->expects($this->atLeastOnce()) + ->method('getSelectionsCollection') + ->with($optionIds, $this->saleableItemMock) + ->willReturn($selectionCollection); + $collection = $this->getMockBuilder(BundleOptionCollection::class) + ->disableOriginalConstructor() + ->getMock(); + $collection->expects($this->atLeastOnce()) + ->method('appendSelections') + ->with($selectionCollection, true, false) + ->willReturn($selectionCollection); + $priceTypeMock->expects($this->atLeastOnce()) + ->method('getOptionsCollection') + ->with($this->saleableItemMock) + ->willReturn($collection); + $this->saleableItemMock->expects($this->atLeastOnce()) + ->method('getTypeInstance') + ->willReturn($priceTypeMock); + } + + /** + * @return array + */ + public function getOptionsDataProvider() : array + { + return [ + [ + ['1', '2'], + ], + ]; + } + + /** + * @dataProvider selectionAmountDataProvider + * + * @param float $selectionQty + * @param float|bool $selectionAmount + * @param bool $useRegularPrice + * + * @return void + */ + public function testGetOptionSelectionAmount(float $selectionQty, $selectionAmount, bool $useRegularPrice) + { + $selection = $this->createPartialMock(Product::class, ['getSelectionQty', '__wakeup']); + $amountInterfaceMock = $this->getMockBuilder(AmountInterface::class) + ->getMockForAbstractClass(); + $amountInterfaceMock->expects($this->once()) + ->method('getValue') + ->willReturn($selectionAmount); + $selection->expects($this->once()) + ->method('getSelectionQty') + ->willReturn($selectionQty); + $priceMock = $this->getMockBuilder(BundleSelectionPrice::class) + ->disableOriginalConstructor() + ->getMock(); + $priceMock->expects($this->once()) + ->method('getAmount') + ->willReturn($amountInterfaceMock); + $this->selectionFactoryMock->expects($this->once()) + ->method('create') + ->with($this->saleableItemMock, $selection, $selectionQty) + ->willReturn($priceMock); + $optionSelectionAmount = $this->bundleOptions->getOptionSelectionAmount( + $this->saleableItemMock, + $selection, + $useRegularPrice + ); + $this->assertSame($selectionAmount, $optionSelectionAmount->getValue()); + } + + /** + * @return array + */ + public function selectionAmountDataProvider(): array + { + return [ + [1., 50.5, false], + [2.2, false, true], + ]; + } + + /** + * Create amount mock. + * + * @param array $amountData + * @return BaseAmount|MockObject + */ + private function createAmountMock(array $amountData) + { + /** @var BaseAmount|MockObject $amount */ + $amount = $this->getMockBuilder(BaseAmount::class) + ->disableOriginalConstructor() + ->getMock(); + $amount->expects($this->any())->method('getAdjustmentAmounts') + ->willReturn($amountData['adjustmentAmounts'] ?? []); + $amount->expects($this->any())->method('getValue')->willReturn($amountData['amount']); + + return $amount; + } + + /** + * Create option mock. + * + * @param array $optionData + * @return BundleOption|MockObject + */ + private function createOptionMock(array $optionData) + { + /** @var BundleOption|MockObject $option */ + $option = $this->createPartialMock(BundleOption::class, ['isMultiSelection', '__wakeup']); + $option->expects($this->any())->method('isMultiSelection') + ->willReturn($optionData['isMultiSelection']); + $selections = []; + foreach ($optionData['selections'] as $selectionData) { + $selections[] = $this->createSelectionMock($selectionData); + } + foreach ($optionData['data'] as $key => $value) { + $option->setData($key, $value); + } + $option->setData('selections', $selections); + + return $option; + } + + /** + * Create selection product mock. + * + * @param array $selectionData + * @return Product|MockObject + */ + private function createSelectionMock(array $selectionData) + { + $selection = $this->getMockBuilder(Product::class) + ->setMethods(['isSalable', 'getAmount', 'getQuantity', 'getProduct', '__wakeup']) + ->disableOriginalConstructor() + ->getMock(); + + // All items are saleable + $selection->expects($this->any())->method('isSalable')->willReturn(true); + foreach ($selectionData['data'] as $key => $value) { + $selection->setData($key, $value); + } + $amountMock = $this->createAmountMock($selectionData['amount']); + $selection->expects($this->any())->method('getAmount')->willReturn($amountMock); + $selection->expects($this->any())->method('getQuantity')->willReturn(1); + + $innerProduct = $this->getMockBuilder(Product::class) + ->setMethods(['getSelectionCanChangeQty', '__wakeup']) + ->disableOriginalConstructor() + ->getMock(); + $innerProduct->expects($this->any())->method('getSelectionCanChangeQty')->willReturn(true); + $selection->expects($this->any())->method('getProduct')->willReturn($innerProduct); + + return $selection; + } + + /** + * @dataProvider getTestDataForCalculation + * @param array $optionList + * @param array $expected + * + * @return void + */ + public function testCalculation(array $optionList, array $expected) + { + $storeId = 1; + $this->saleableItemMock->expects($this->any())->method('getStoreId')->willReturn($storeId); + $this->selectionFactoryMock->expects($this->any())->method('create')->willReturnArgument(1); + + $this->baseCalculator->expects($this->atLeastOnce())->method('getAmount') + ->willReturn($this->createAmountMock(['amount' => 0.])); + + $options = []; + foreach ($optionList as $optionData) { + $options[] = $this->createOptionMock($optionData); + } + /** @var BundleOptionCollection|MockObject $optionsCollection */ + $optionsCollection = $this->getMockBuilder(BundleOptionCollection::class) + ->disableOriginalConstructor() + ->getMock(); + $optionsCollection->expects($this->atLeastOnce())->method('appendSelections')->willReturn($options); + + /** @var \Magento\Catalog\Model\Product\Type\AbstractType|MockObject $typeMock */ + $typeMock = $this->getMockBuilder(BundleProductType::class) + ->disableOriginalConstructor() + ->getMock(); + $typeMock->expects($this->any())->method('setStoreFilter') + ->with($storeId, $this->saleableItemMock); + $typeMock->expects($this->any())->method('getOptionsCollection') + ->with($this->saleableItemMock) + ->willReturn($optionsCollection); + $this->saleableItemMock->expects($this->any())->method('getTypeInstance')->willReturn($typeMock); + + $this->assertEquals($expected['min'], $this->bundleOptions->calculateOptions($this->saleableItemMock)); + $this->assertEquals($expected['max'], $this->bundleOptions->calculateOptions($this->saleableItemMock, false)); + } + + /** + * @return array + */ + public function getTestDataForCalculation(): array + { + return [ + 'first case' => [ + 'optionList' => [ + // first option with single choice of product + [ + 'isMultiSelection' => false, + 'data' => [ + 'title' => 'test option 1', + 'default_title' => 'test option 1', + 'type' => 'select', + 'option_id' => '1', + 'position' => '0', + 'required' => '1', + ], + 'selections' => [ + [ + 'data' => ['price' => 70.], + 'amount' => ['amount' => 70], + ], + [ + 'data' => ['price' => 80.], + 'amount' => ['amount' => 80], + ], + [ + 'data' => ['price' => 50.], + 'amount' => ['amount' => 50], + ], + ], + ], + // second not required option + [ + 'isMultiSelection' => false, + 'data' => [ + 'title' => 'test option 2', + 'default_title' => 'test option 2', + 'type' => 'select', + 'option_id' => '2', + 'position' => '1', + 'required' => '0', + ], + 'selections' => [ + [ + 'data' => ['value' => 20.], + 'amount' => ['amount' => 20], + ], + ], + ], + // third with multi-selection + [ + 'isMultiSelection' => true, + 'data' => [ + 'title' => 'test option 3', + 'default_title' => 'test option 3', + 'type' => 'select', + 'option_id' => '3', + 'position' => '2', + 'required' => '1', + ], + 'selections' => [ + [ + 'data' => ['price' => 40.], + 'amount' => ['amount' => 40], + ], + [ + 'data' => ['price' => 20.], + 'amount' => ['amount' => 20], + ], + [ + 'data' => ['price' => 60.], + 'amount' => ['amount' => 60], + ], + ], + ], + // fourth without selections + [ + 'isMultiSelection' => true, + 'data' => [ + 'title' => 'test option 3', + 'default_title' => 'test option 3', + 'type' => 'select', + 'option_id' => '4', + 'position' => '3', + 'required' => '1', + ], + 'selections' => [], + ], + ], + 'expected' => ['min' => 70, 'max' => 220], + ], + ]; + } +} diff --git a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleSelectionPriceTest.php b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleSelectionPriceTest.php index 64140ef920cbe..d43b0575aea91 100644 --- a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleSelectionPriceTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/BundleSelectionPriceTest.php @@ -163,12 +163,14 @@ public function testGetValueTypeDynamic($useRegularPrice) } /** - * test for method getValue with type Fixed and selectionPriceType not null + * Test for method getValue with type Fixed and selectionPriceType not null. * * @param bool $useRegularPrice * @dataProvider useRegularPriceDataProvider + * + * @return void */ - public function testGetValueTypeFixedWithSelectionPriceType($useRegularPrice) + public function testGetValueTypeFixedWithSelectionPriceType(bool $useRegularPrice) { $this->setupSelectionPrice($useRegularPrice); $regularPrice = 100.125; @@ -178,53 +180,49 @@ public function testGetValueTypeFixedWithSelectionPriceType($useRegularPrice) $this->bundleMock->expects($this->once()) ->method('getPriceType') - ->will($this->returnValue(\Magento\Bundle\Model\Product\Price::PRICE_TYPE_FIXED)); + ->willReturn(\Magento\Bundle\Model\Product\Price::PRICE_TYPE_FIXED); $this->bundleMock->expects($this->atLeastOnce()) ->method('getPriceInfo') - ->will($this->returnValue($this->priceInfoMock)); + ->willReturn($this->priceInfoMock); $this->priceInfoMock->expects($this->once()) ->method('getPrice') - ->with($this->equalTo(RegularPrice::PRICE_CODE)) - ->will($this->returnValue($this->regularPriceMock)); + ->with(RegularPrice::PRICE_CODE) + ->willReturn($this->regularPriceMock); $this->regularPriceMock->expects($this->once()) ->method('getValue') - ->will($this->returnValue($actualPrice)); + ->willReturn($actualPrice); $this->bundleMock->expects($this->once()) ->method('setFinalPrice') - ->will($this->returnSelf()); + ->willReturnSelf(); $this->eventManagerMock->expects($this->once()) ->method('dispatch'); $this->bundleMock->expects($this->exactly(2)) ->method('getData') - ->will( - $this->returnValueMap( - [ - ['qty', null, 1], - ['final_price', null, 100], - ] - ) + ->willReturnMap( + [ + ['qty', null, 1], + ['final_price', null, 100], + ['price', null, 100], + ] ); $this->productMock->expects($this->once()) ->method('getSelectionPriceType') - ->will($this->returnValue(true)); + ->willReturn(true); $this->productMock->expects($this->any()) ->method('getSelectionPriceValue') - ->will($this->returnValue($actualPrice)); + ->willReturn($actualPrice); if (!$useRegularPrice) { $this->discountCalculatorMock->expects($this->once()) ->method('calculateDiscount') - ->with( - $this->equalTo($this->bundleMock), - $this->equalTo($actualPrice) - ) - ->will($this->returnValue($discountedPrice)); + ->with($this->bundleMock, $actualPrice) + ->willReturn($discountedPrice); } $this->priceCurrencyMock->expects($this->once()) ->method('round') ->with($actualPrice) - ->will($this->returnValue($expectedPrice)); + ->willReturn($expectedPrice); $this->assertEquals($expectedPrice, $this->selectionPrice->getValue()); } diff --git a/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php b/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php index c0624be8e7a97..98fd96c52ccd9 100644 --- a/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php +++ b/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php @@ -314,7 +314,8 @@ protected function getBundleOptions() 'template' => 'ui/dynamic-rows/templates/collapsible', 'additionalClasses' => 'admin__field-wide', 'dataScope' => 'data.bundle_options', - 'bundleSelectionsName' => 'product_bundle_container.bundle_selections' + 'isDefaultFieldScope' => 'is_default', + 'bundleSelectionsName' => 'product_bundle_container.bundle_selections', ], ], ], @@ -378,7 +379,10 @@ protected function getBundleOptions() 'selection_qty' => '', ], 'links' => ['insertData' => '${ $.provider }:${ $.dataProvider }'], - 'source' => 'product' + 'imports' => [ + 'inputType' => '${$.provider}:${$.dataScope}.type', + ], + 'source' => 'product', ], ], ], @@ -594,11 +598,14 @@ protected function getBundleSelections() 'config' => [ 'componentType' => Container::NAME, 'isTemplate' => true, - 'component' => 'Magento_Bundle/js/components/bundle-record', + 'component' => 'Magento_Ui/js/dynamic-rows/record', 'is_collection' => true, 'imports' => [ - 'onTypeChanged' => '${ $.provider }:${ $.bundleOptionsDataScope }.type' - ] + 'inputType' => '${$.parentName}:inputType', + ], + 'exports' => [ + 'isDefaultValue' => '${$.parentName}:isDefaultValue.${$.index}', + ], ], ], ], @@ -691,11 +698,15 @@ protected function getBundleSelections() 'componentType' => Form\Field::NAME, 'formElement' => Form\Element\Checkbox::NAME, 'dataType' => Form\Element\DataType\Price::NAME, + 'component' => 'Magento_Bundle/js/components/bundle-user-defined-checkbox', 'label' => __('User Defined'), 'dataScope' => 'selection_can_change_qty', 'value' => '1', 'valueMap' => ['true' => '1', 'false' => '0'], 'sortOrder' => 110, + 'imports' => [ + 'inputType' => '${$.parentName}:inputType', + ], ], ], ], diff --git a/app/code/Magento/Bundle/composer.json b/app/code/Magento/Bundle/composer.json index af4062aadf41f..84c9a97698b71 100644 --- a/app/code/Magento/Bundle/composer.json +++ b/app/code/Magento/Bundle/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -25,7 +25,8 @@ }, "suggest": { "magento/module-webapi": "*", - "magento/module-bundle-sample-data": "Sample Data version:100.3.*" + "magento/module-bundle-sample-data": "*", + "magento/module-sales-rule": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Bundle/etc/di.xml b/app/code/Magento/Bundle/etc/di.xml index 287a6c8bfdbc0..733b089dccd4b 100644 --- a/app/code/Magento/Bundle/etc/di.xml +++ b/app/code/Magento/Bundle/etc/di.xml @@ -50,7 +50,9 @@ Magento\Catalog\Pricing\Price\CustomOptionPrice Magento\Catalog\Pricing\Price\BasePrice Magento\Bundle\Pricing\Price\ConfiguredPrice + Magento\Bundle\Pricing\Price\ConfiguredRegularPrice Magento\Bundle\Pricing\Price\BundleOptionPrice + Magento\Bundle\Pricing\Price\BundleOptionRegularPrice Magento\CatalogRule\Pricing\Price\CatalogRulePrice @@ -75,6 +77,11 @@ Magento\Bundle\Pricing\Adjustment\BundleCalculatorInterface + + + Magento\Bundle\Pricing\Adjustment\BundleCalculatorInterface + + @@ -200,4 +207,11 @@ + + + + false + + + diff --git a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js index b608cff85b067..09331d37bb3b6 100644 --- a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js +++ b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js @@ -14,7 +14,10 @@ define([ clearing: false, parentContainer: '', parentSelections: '', - changer: '' + changer: '', + exports: { + value: '${$.parentName}:isDefaultValue' + } }, /** @@ -58,10 +61,6 @@ define([ this.prefer = typeMap[type]; this.elementTmpl(this.templates[typeMap[type]]); - - if (this.prefer === 'radio' && this.checked()) { - this.clearValues(); - } }, /** diff --git a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js index 428361f459544..a6fc84765cc65 100644 --- a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js +++ b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js @@ -14,7 +14,57 @@ define([ label: '', columnsHeader: false, columnsHeaderAfterRender: true, - addButton: false + addButton: false, + isDefaultFieldScope: 'is_default', + defaultRecords: { + use: [], + moreThanOne: false, + state: {} + }, + listens: { + inputType: 'onInputTypeChange', + isDefaultValue: 'onIsDefaultValue' + } + }, + + /** + * Handler for type select. + * + * @param {String} inputType - changed. + */ + onInputTypeChange: function (inputType) { + if (this.defaultRecords.moreThanOne && (inputType === 'radio' || inputType === 'select')) { + _.each(this.defaultRecords.use, function (index, counter) { + this.source.set( + this.dataScope + '.bundle_selections.' + index + '.' + this.isDefaultFieldScope, + counter ? '0' : '1' + ); + }.bind(this)); + } + }, + + /** + * Handler for is_default field. + * + * @param {Object} data - changed data. + */ + onIsDefaultValue: function (data) { + var cb, + use = 0; + + this.defaultRecords.use = []; + + cb = function (elem, key) { + + if (~~elem) { + this.defaultRecords.use.push(key); + use++; + } + + this.defaultRecords.moreThanOne = use > 1; + }.bind(this); + + _.each(data, cb); }, /** @@ -29,7 +79,6 @@ define([ recordIndex; this.parsePagesData(data); - this.templates.record.bundleOptionsDataScope = this.dataScope; if (newData.length) { if (this.insertData().length) { diff --git a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-user-defined-checkbox.js b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-user-defined-checkbox.js new file mode 100644 index 0000000000000..a7ceded02d0c3 --- /dev/null +++ b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-user-defined-checkbox.js @@ -0,0 +1,30 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Ui/js/form/element/single-checkbox' +], function (Checkbox) { + 'use strict'; + + return Checkbox.extend({ + defaults: { + listens: { + inputType: 'onInputTypeChange' + } + }, + + /** + * Handler for "inputType" property + * + * @param {String} data + */ + onInputTypeChange: function (data) { + data === 'checkbox' || data === 'multi' ? + this.clear() + .visible(false) : + this.visible(true); + } + }); +}); diff --git a/app/code/Magento/BundleGraphQl/composer.json b/app/code/Magento/BundleGraphQl/composer.json index 12b7e18f133bd..aea55cb5c644c 100644 --- a/app/code/Magento/BundleGraphQl/composer.json +++ b/app/code/Magento/BundleGraphQl/composer.json @@ -3,7 +3,7 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-catalog": "*", "magento/module-bundle": "*", "magento/module-catalog-graph-ql": "*", diff --git a/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php b/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php index 4004a3e5f1933..e0c94097e4d3f 100644 --- a/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php +++ b/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php @@ -9,9 +9,9 @@ use Magento\CatalogImportExport\Model\Export\RowCustomizerInterface; use Magento\CatalogImportExport\Model\Import\Product as ImportProductModel; use Magento\Bundle\Model\ResourceModel\Selection\Collection as SelectionCollection; -use Magento\ImportExport\Controller\Adminhtml\Import; use Magento\ImportExport\Model\Import as ImportModel; -use \Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Store\Model\StoreManagerInterface; /** * Class RowCustomizer @@ -105,6 +105,34 @@ class RowCustomizer implements RowCustomizerInterface AbstractType::SHIPMENT_SEPARATELY => 'separately', ]; + /** + * @var \Magento\Bundle\Model\ResourceModel\Option\Collection[] + */ + private $optionCollections = []; + + /** + * @var array + */ + private $storeIdToCode = []; + + /** + * @var string + */ + private $optionCollectionCacheKey = '_cache_instance_options_collection'; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @param StoreManagerInterface $storeManager + */ + public function __construct(StoreManagerInterface $storeManager) + { + $this->storeManager = $storeManager; + } + /** * Retrieve list of bundle specific columns * @return array @@ -205,17 +233,15 @@ protected function populateBundleData($collection) * @param \Magento\Catalog\Model\Product $product * @return string */ - protected function getFormattedBundleOptionValues($product) + protected function getFormattedBundleOptionValues(\Magento\Catalog\Model\Product $product): string { - /** @var \Magento\Bundle\Model\ResourceModel\Option\Collection $optionsCollection */ - $optionsCollection = $product->getTypeInstance() - ->getOptionsCollection($product) - ->setOrder('position', Collection::SORT_ORDER_ASC); - + $optionCollections = $this->getProductOptionCollection($product); $bundleData = ''; - foreach ($optionsCollection as $option) { + $optionTitles = $this->getBundleOptionTitles($product); + foreach ($optionCollections->getItems() as $option) { + $optionValues = $this->getFormattedOptionValues($option, $optionTitles); $bundleData .= $this->getFormattedBundleSelections( - $this->getFormattedOptionValues($option), + $optionValues, $product->getTypeInstance() ->getSelectionsCollection([$option->getId()], $product) ->setOrder('position', Collection::SORT_ORDER_ASC) @@ -267,16 +293,25 @@ function ($value, $key) { * Retrieve option value of bundle product * * @param \Magento\Bundle\Model\Option $option + * @param string[] $optionTitles * @return string */ - protected function getFormattedOptionValues($option) - { - return 'name' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR - . $option->getTitle() . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR - . 'type' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR - . $option->getType() . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR - . 'required' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR - . $option->getRequired(); + protected function getFormattedOptionValues( + \Magento\Bundle\Model\Option $option, + array $optionTitles = [] + ): string { + $names = implode(ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR, array_map( + function ($title, $storeName) { + return $storeName . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR . $title; + }, + $optionTitles[$option->getOptionId()], + array_keys($optionTitles[$option->getOptionId()]) + )); + return $names . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR + . 'type' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR + . $option->getType() . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR + . 'required' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR + . $option->getRequired(); } /** @@ -381,4 +416,83 @@ private function parseAdditionalAttributes($additionalAttributes) } return $preparedAttributes; } + + /** + * Get product options titles. + * + * Values for all store views (default) should be specified with 'name' key. + * If user want to specify value or change existing for non default store views it should be specified with + * 'name_' prefix and needed store view suffix. + * + * For example: + * - 'name=All store views name' for all store views + * - 'name_specific_store=Specific store name' for store view with 'specific_store' store code + * + * @param \Magento\Catalog\Model\Product $product + * @return array + */ + private function getBundleOptionTitles(\Magento\Catalog\Model\Product $product): array + { + $optionCollections = $this->getProductOptionCollection($product); + $optionsTitles = []; + /** @var \Magento\Bundle\Model\Option $option */ + foreach ($optionCollections->getItems() as $option) { + $optionsTitles[$option->getId()]['name'] = $option->getTitle(); + } + $storeIds = $product->getStoreIds(); + if (count($storeIds) > 1) { + foreach ($storeIds as $storeId) { + $optionCollections = $this->getProductOptionCollection($product, (int)$storeId); + /** @var \Magento\Bundle\Model\Option $option */ + foreach ($optionCollections->getItems() as $option) { + $optionTitle = $option->getTitle(); + if ($optionsTitles[$option->getId()]['name'] != $optionTitle) { + $optionsTitles[$option->getId()]['name_' . $this->getStoreCodeById((int)$storeId)] = + $optionTitle; + } + } + } + } + return $optionsTitles; + } + + /** + * Get product options collection by provided product model. + * + * Set given store id to the product if it was defined (default store id will be set if was not). + * + * @param \Magento\Catalog\Model\Product $product $product + * @param int $storeId + * @return \Magento\Bundle\Model\ResourceModel\Option\Collection + */ + private function getProductOptionCollection( + \Magento\Catalog\Model\Product $product, + int $storeId = \Magento\Store\Model\Store::DEFAULT_STORE_ID + ): \Magento\Bundle\Model\ResourceModel\Option\Collection { + $productSku = $product->getSku(); + if (!isset($this->optionCollections[$productSku][$storeId])) { + $product->unsetData($this->optionCollectionCacheKey); + $product->setStoreId($storeId); + $this->optionCollections[$productSku][$storeId] = $product->getTypeInstance() + ->getOptionsCollection($product) + ->setOrder('position', Collection::SORT_ORDER_ASC); + } + return $this->optionCollections[$productSku][$storeId]; + } + + /** + * Retrieve store code by it's ID. + * + * Collect store id in $storeIdToCode[] private variable if it was not initialized earlier. + * + * @param int $storeId + * @return string + */ + private function getStoreCodeById(int $storeId): string + { + if (!isset($this->storeIdToCode[$storeId])) { + $this->storeIdToCode[$storeId] = $this->storeManager->getStore($storeId)->getCode(); + } + return $this->storeIdToCode[$storeId]; + } } diff --git a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php index 8e59c866e162a..3ed7e144ddd5a 100644 --- a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php +++ b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php @@ -8,10 +8,15 @@ */ namespace Magento\BundleImportExport\Model\Import\Product\Type; -use \Magento\Framework\App\ObjectManager; -use \Magento\Bundle\Model\Product\Price as BundlePrice; -use \Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory as AttributeCollectionFactory; +use Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory as AttributeSetCollectionFactory; +use Magento\Framework\App\ObjectManager; +use Magento\Bundle\Model\Product\Price as BundlePrice; +use Magento\Catalog\Model\Product\Type\AbstractType; use Magento\CatalogImportExport\Model\Import\Product; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Store\Model\StoreManagerInterface; /** * Class Bundle @@ -137,25 +142,39 @@ class Bundle extends \Magento\CatalogImportExport\Model\Import\Product\Type\Abst private $relationsDataSaver; /** - * @param \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory $attrSetColFac - * @param \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $prodAttrColFac - * @param \Magento\Framework\App\ResourceConnection $resource + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var array + */ + private $storeCodeToId = []; + + /** + * @param AttributeSetCollectionFactory $attrSetColFac + * @param AttributeCollectionFactory $prodAttrColFac + * @param ResourceConnection $resource * @param array $params - * @param \Magento\Framework\EntityManager\MetadataPool|null $metadataPool + * @param MetadataPool|null $metadataPool * @param Bundle\RelationsDataSaver|null $relationsDataSaver + * @param StoreManagerInterface|null $storeManager */ public function __construct( - \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory $attrSetColFac, - \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $prodAttrColFac, - \Magento\Framework\App\ResourceConnection $resource, + AttributeSetCollectionFactory $attrSetColFac, + AttributeCollectionFactory $prodAttrColFac, + ResourceConnection $resource, array $params, - \Magento\Framework\EntityManager\MetadataPool $metadataPool = null, - Bundle\RelationsDataSaver $relationsDataSaver = null + MetadataPool $metadataPool = null, + Bundle\RelationsDataSaver $relationsDataSaver = null, + StoreManagerInterface $storeManager = null ) { parent::__construct($attrSetColFac, $prodAttrColFac, $resource, $params, $metadataPool); $this->relationsDataSaver = $relationsDataSaver ?: ObjectManager::getInstance()->get(Bundle\RelationsDataSaver::class); + $this->storeManager = $storeManager + ?: ObjectManager::getInstance()->get(StoreManagerInterface::class); } /** @@ -262,20 +281,29 @@ protected function populateOptionTemplate($option, $entityId, $index = null) * @param array $option * @param int $optionId * @param int $storeId - * - * @return array|bool + * @return array */ - protected function populateOptionValueTemplate($option, $optionId, $storeId = 0) + protected function populateOptionValueTemplate(array $option, int $optionId, int $storeId = 0): array { - if (!isset($option['name']) || !isset($option['parent_id']) || !$optionId) { - return false; + $optionValues = []; + if (isset($option['name'], $option['parent_id']) && $optionId) { + $pattern = '/^name[_]?(.*)/'; + $keys = array_keys($option); + $optionNames = preg_grep($pattern, $keys); + foreach ($optionNames as $optionName) { + preg_match($pattern, $optionName, $storeCodes); + $storeCode = array_pop($storeCodes); + $storeId = $storeCode ? $this->getStoreIdByCode($storeCode) : $storeId; + $optionValues[] = [ + 'option_id' => $optionId, + 'parent_product_id' => $option['parent_id'], + 'store_id' => $storeId, + 'title' => $option[$optionName], + ]; + } } - return [ - 'option_id' => $optionId, - 'parent_product_id' => $option['parent_id'], - 'store_id' => $storeId, - 'title' => $option['name'], - ]; + + return $optionValues; } /** @@ -285,7 +313,7 @@ protected function populateOptionValueTemplate($option, $optionId, $storeId = 0) * @param int $optionId * @param int $parentId * @param int $index - * @return array + * @return array|bool * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ @@ -383,6 +411,7 @@ public function saveData() $this->populateExistingOptions(); $this->insertOptions(); $this->insertSelections(); + $this->insertParentChildRelations(); $this->clear(); } } @@ -574,23 +603,27 @@ protected function insertOptions() * @param array $optionIds * @return array */ - protected function populateInsertOptionValues($optionIds) + protected function populateInsertOptionValues(array $optionIds): array { - $insertValues = []; + $optionValues = []; foreach ($this->_cachedOptions as $entityId => $options) { foreach ($options as $key => $option) { foreach ($optionIds as $optionId => $assoc) { if ($assoc['position'] == $this->_cachedOptions[$entityId][$key]['index'] && $assoc['parent_id'] == $entityId) { $option['parent_id'] = $entityId; - $insertValues[] = $this->populateOptionValueTemplate($option, $optionId); + $optionValues = array_merge( + $optionValues, + $this->populateOptionValueTemplate($option, $optionId) + ); $this->_cachedOptions[$entityId][$key]['option_id'] = $optionId; break; } } } } - return $insertValues; + + return $optionValues; } /** @@ -627,6 +660,32 @@ protected function insertSelections() return $this; } + /** + * Insert parent/child product relations + * + * @return \Magento\CatalogImportExport\Model\Import\Product\Type\AbstractType + */ + private function insertParentChildRelations() + { + foreach ($this->_cachedOptions as $productId => $options) { + $childIds = []; + foreach ($options as $option) { + foreach ($option['selections'] as $selection) { + if (!isset($selection['parent_product_id'])) { + if (!isset($this->_cachedSkuToProducts[$selection['sku']])) { + continue; + } + $childIds[] = $this->_cachedSkuToProducts[$selection['sku']]; + } + } + + $this->relationsDataSaver->saveProductRelations($productId, $childIds); + } + } + + return $this; + } + /** * Initialize attributes parameters for all attributes' sets. * @@ -711,4 +770,22 @@ protected function clear() $this->_cachedSkuToProducts = []; return $this; } + + /** + * Get store id by store code. + * + * @param string $storeCode + * @return int + */ + private function getStoreIdByCode(string $storeCode): int + { + if (!isset($this->storeIdToCode[$storeCode])) { + /** @var $store \Magento\Store\Model\Store */ + foreach ($this->storeManager->getStores() as $store) { + $this->storeCodeToId[$store->getCode()] = $store->getId(); + } + } + + return $this->storeCodeToId[$storeCode]; + } } diff --git a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php index a58195f823bf1..5409d12ac56d3 100644 --- a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php +++ b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php @@ -5,6 +5,9 @@ */ namespace Magento\BundleImportExport\Model\Import\Product\Type\Bundle; +use Magento\Catalog\Model\ResourceModel\Product\Relation; +use Magento\Framework\App\ObjectManager; + /** * A bundle product relations (options, selections, etc.) data saver. * @@ -17,13 +20,22 @@ class RelationsDataSaver */ private $resource; + /** + * @var Relation + */ + private $productRelation; + /** * @param \Magento\Framework\App\ResourceConnection $resource + * @param Relation $productRelation */ public function __construct( - \Magento\Framework\App\ResourceConnection $resource + \Magento\Framework\App\ResourceConnection $resource, + Relation $productRelation = null ) { - $this->resource = $resource; + $this->resource = $resource; + $this->productRelation = $productRelation + ?: ObjectManager::getInstance()->get(Relation::class); } /** @@ -92,4 +104,17 @@ public function saveSelections(array $selections) ); } } + + /** + * Saves given parent/child relations. + * + * @param int $parentId + * @param array $childIds + * + * @return void + */ + public function saveProductRelations($parentId, $childIds) + { + $this->productRelation->processRelations($parentId, $childIds); + } } diff --git a/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php b/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php index 849158122e8be..10e8a3443f5f9 100644 --- a/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php +++ b/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php @@ -52,14 +52,24 @@ class RowCustomizerTest extends \PHPUnit\Framework\TestCase */ protected $selection; + /** @var \Magento\Framework\App\ScopeResolverInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $scopeResolver; + /** * Set up */ protected function setUp() { $this->objectManagerHelper = new ObjectManagerHelper($this); + $this->scopeResolver = $this->getMockBuilder(\Magento\Framework\App\ScopeResolverInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getScope']) + ->getMockForAbstractClass(); $this->rowCustomizerMock = $this->objectManagerHelper->getObject( - \Magento\BundleImportExport\Model\Export\RowCustomizer::class + \Magento\BundleImportExport\Model\Export\RowCustomizer::class, + [ + 'scopeResolver' => $this->scopeResolver, + ] ); $this->productResourceCollection = $this->createPartialMock( \Magento\Catalog\Model\ResourceModel\Product\Collection::class, @@ -72,6 +82,8 @@ protected function setUp() 'getPriceType', 'getShipmentType', 'getSkuType', + 'getSku', + 'getStoreIds', 'getPriceView', 'getWeightType', 'getTypeInstance', @@ -79,6 +91,7 @@ protected function setUp() 'getSelectionsCollection' ] ); + $this->product->expects($this->any())->method('getStoreIds')->willReturn([1]); $this->product->expects($this->any())->method('getEntityId')->willReturn(1); $this->product->expects($this->any())->method('getPriceType')->willReturn(1); $this->product->expects($this->any())->method('getShipmentType')->willReturn(1); @@ -88,19 +101,20 @@ protected function setUp() $this->product->expects($this->any())->method('getTypeInstance')->willReturnSelf(); $this->optionsCollection = $this->createPartialMock( \Magento\Bundle\Model\ResourceModel\Option\Collection::class, - ['setOrder', 'getIterator'] + ['setOrder', 'getItems'] ); $this->product->expects($this->any())->method('getOptionsCollection')->willReturn($this->optionsCollection); $this->optionsCollection->expects($this->any())->method('setOrder')->willReturnSelf(); $this->option = $this->createPartialMock( \Magento\Bundle\Model\Option::class, - ['getId', 'getTitle', 'getType', 'getRequired'] + ['getId', 'getOptionId', 'getTitle', 'getType', 'getRequired'] ); $this->option->expects($this->any())->method('getId')->willReturn(1); + $this->option->expects($this->any())->method('getOptionId')->willReturn(1); $this->option->expects($this->any())->method('getTitle')->willReturn('title'); $this->option->expects($this->any())->method('getType')->willReturn(1); $this->option->expects($this->any())->method('getRequired')->willReturn(1); - $this->optionsCollection->expects($this->any())->method('getIterator')->will( + $this->optionsCollection->expects($this->any())->method('getItems')->will( $this->returnValue(new \ArrayIterator([$this->option])) ); $this->selection = $this->createPartialMock( @@ -130,6 +144,7 @@ protected function setUp() $this->product->expects($this->any())->method('getSelectionsCollection')->willReturn( $this->selectionsCollection ); + $this->product->expects($this->any())->method('getSku')->willReturn(1); $this->productResourceCollection->expects($this->any())->method('addAttributeToFilter')->willReturnSelf(); $this->productResourceCollection->expects($this->any())->method('getIterator')->will( $this->returnValue(new \ArrayIterator([$this->product])) @@ -141,6 +156,8 @@ protected function setUp() */ public function testPrepareData() { + $scope = $this->getMockBuilder(\Magento\Framework\App\ScopeInterface::class)->getMockForAbstractClass(); + $this->scopeResolver->expects($this->any())->method('getScope')->willReturn($scope); $result = $this->rowCustomizerMock->prepareData($this->productResourceCollection, [1]); $this->assertNotNull($result); } @@ -168,6 +185,8 @@ public function testAddHeaderColumns() */ public function testAddData() { + $scope = $this->getMockBuilder(\Magento\Framework\App\ScopeInterface::class)->getMockForAbstractClass(); + $this->scopeResolver->expects($this->any())->method('getScope')->willReturn($scope); $preparedData = $this->rowCustomizerMock->prepareData($this->productResourceCollection, [1]); $attributes = 'attribute=1,sku_type=1,attribute2="Text",price_type=1,price_view=1,weight_type=1,' . 'values=values,shipment_type=1,attribute3=One,Two,Three'; diff --git a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php index 42d508cdfb195..d50243b3656f3 100644 --- a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php +++ b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php @@ -7,6 +7,7 @@ namespace Magento\BundleImportExport\Test\Unit\Model\Import\Product\Type\Bundle; use Magento\BundleImportExport\Model\Import\Product\Type\Bundle\RelationsDataSaver; +use Magento\Catalog\Model\ResourceModel\Product\Relation; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Adapter\AdapterInterface; @@ -30,6 +31,11 @@ class RelationsDataSaverTest extends \PHPUnit\Framework\TestCase */ private $connectionMock; + /** + * @var Relation|\PHPUnit_Framework_MockObject_MockObject + */ + private $productRelationMock; + protected function setUp() { $helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -39,12 +45,16 @@ protected function setUp() $this->connectionMock = $this->getMockBuilder(AdapterInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); + + $this->productRelationMock = $this->getMockBuilder(Relation::class) + ->disableOriginalConstructor() + ->getMock(); $this->relationsDataSaver = $helper->getObject( RelationsDataSaver::class, [ - 'resource' => $this->resourceMock + 'resource' => $this->resourceMock, + 'productRelation' => $this->productRelationMock ] ); } @@ -53,7 +63,7 @@ public function testSaveOptions() { $options = [1, 2]; $table_name= 'catalog_product_bundle_option'; - + $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); $this->resourceMock->expects($this->once()) ->method('getTableName') ->with('catalog_product_bundle_option') @@ -78,6 +88,7 @@ public function testSaveOptionValues() $optionsValues = [1, 2]; $table_name= 'catalog_product_bundle_option_value'; + $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); $this->resourceMock->expects($this->once()) ->method('getTableName') ->with('catalog_product_bundle_option_value') @@ -98,6 +109,7 @@ public function testSaveSelections() $selections = [1, 2]; $table_name= 'catalog_product_bundle_selection'; + $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); $this->resourceMock->expects($this->once()) ->method('getTableName') ->with('catalog_product_bundle_selection') @@ -121,4 +133,16 @@ public function testSaveSelections() $this->relationsDataSaver->saveSelections($selections); } + + public function testSaveProductRelations() + { + $parentId = 1; + $children = [2, 3]; + + $this->productRelationMock->expects($this->once()) + ->method('processRelations') + ->with($parentId, $children); + + $this->relationsDataSaver->saveProductRelations($parentId, $children); + } } diff --git a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php index 8e1243b5eb3af..b0794f4564645 100644 --- a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php +++ b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php @@ -58,6 +58,9 @@ class BundleTest extends \Magento\ImportExport\Test\Unit\Model\Import\AbstractIm */ protected $setCollection; + /** @var \Magento\Framework\App\ScopeResolverInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $scopeResolver; + /** * * @return void @@ -170,14 +173,18 @@ protected function setUp() 0 => $this->entityModel, 1 => 'bundle' ]; - + $this->scopeResolver = $this->getMockBuilder(\Magento\Framework\App\ScopeResolverInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getScope']) + ->getMockForAbstractClass(); $this->bundle = $this->objectManagerHelper->getObject( \Magento\BundleImportExport\Model\Import\Product\Type\Bundle::class, [ 'attrSetColFac' => $this->attrSetColFac, 'prodAttrColFac' => $this->prodAttrColFac, 'resource' => $this->resource, - 'params' => $this->params + 'params' => $this->params, + 'scopeResolver' => $this->scopeResolver, ] ); @@ -214,7 +221,8 @@ public function testSaveData($skus, $bunch, $allowImport) $this->entityModel->expects($this->any())->method('isRowAllowedToImport')->will($this->returnValue( $allowImport )); - + $scope = $this->getMockBuilder(\Magento\Framework\App\ScopeInterface::class)->getMockForAbstractClass(); + $this->scopeResolver->expects($this->any())->method('getScope')->willReturn($scope); $this->connection->expects($this->any())->method('fetchAssoc')->with($this->select)->will($this->returnValue([ '1' => [ 'option_id' => '1', diff --git a/app/code/Magento/BundleImportExport/composer.json b/app/code/Magento/BundleImportExport/composer.json index 3458c54e7184f..42d9dc558d31e 100644 --- a/app/code/Magento/BundleImportExport/composer.json +++ b/app/code/Magento/BundleImportExport/composer.json @@ -5,9 +5,10 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-bundle": "*", + "magento/module-store": "*", "magento/module-catalog": "*", "magento/module-catalog-import-export": "*", "magento/module-eav": "*", diff --git a/app/code/Magento/CacheInvalidate/composer.json b/app/code/Magento/CacheInvalidate/composer.json index 18776dbf4a646..01ac4d30d844d 100644 --- a/app/code/Magento/CacheInvalidate/composer.json +++ b/app/code/Magento/CacheInvalidate/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-page-cache": "*" }, diff --git a/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php b/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php index bc2f8d02e2544..2890f10a2ae12 100644 --- a/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php @@ -54,12 +54,9 @@ public function testCreateNegative() $this->returnValue($defaultCaptchaMock) ); - $this->expectException( - 'InvalidArgumentException', - 'Magento\Captcha\Model\\' . ucfirst( - $captchaType - ) . ' does not implement \Magento\Captcha\Model\CaptchaInterface' - ); + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Magento\Captcha\Model\\' . ucfirst($captchaType) . + ' does not implement \Magento\Captcha\Model\CaptchaInterface'); $this->assertEquals($defaultCaptchaMock, $this->_model->create($captchaType, 'form_id')); } diff --git a/app/code/Magento/Captcha/Test/Unit/Observer/CaptchaStringResolverTest.php b/app/code/Magento/Captcha/Test/Unit/Observer/CaptchaStringResolverTest.php new file mode 100644 index 0000000000000..2bd8ac6f16986 --- /dev/null +++ b/app/code/Magento/Captcha/Test/Unit/Observer/CaptchaStringResolverTest.php @@ -0,0 +1,69 @@ +objectManagerHelper = new ObjectManager($this); + $this->requestMock = $this->createMock(HttpRequest::class); + $this->captchaStringResolver = $this->objectManagerHelper->getObject(CaptchaStringResolver::class); + } + + public function testResolveWithFormIdSet() + { + $formId = 'contact_us'; + $captchaValue = 'some-value'; + + $this->requestMock->expects($this->once()) + ->method('getPost') + ->with(CaptchaDataHelper::INPUT_NAME_FIELD_VALUE) + ->willReturn([$formId => $captchaValue]); + + self::assertEquals( + $this->captchaStringResolver->resolve($this->requestMock, $formId), + $captchaValue + ); + } + + public function testResolveWithNoFormIdInRequest() + { + $formId = 'contact_us'; + + $this->requestMock->expects($this->once()) + ->method('getPost') + ->with(CaptchaDataHelper::INPUT_NAME_FIELD_VALUE) + ->willReturn([]); + + self::assertEquals( + $this->captchaStringResolver->resolve($this->requestMock, $formId), + '' + ); + } +} diff --git a/app/code/Magento/Captcha/Test/Unit/Observer/CheckRegisterCheckoutObserverTest.php b/app/code/Magento/Captcha/Test/Unit/Observer/CheckRegisterCheckoutObserverTest.php new file mode 100644 index 0000000000000..89012ef653838 --- /dev/null +++ b/app/code/Magento/Captcha/Test/Unit/Observer/CheckRegisterCheckoutObserverTest.php @@ -0,0 +1,211 @@ +createMock(Onepage::class); + $captchaHelperMock = $this->createMock(CaptchaDataHelper::class); + $this->objectManager = new ObjectManager($this); + $this->actionFlagMock = $this->createMock(ActionFlag::class); + $this->captchaStringResolverMock = $this->createMock(CaptchaStringResolver::class); + $this->captchaModelMock = $this->createMock(CaptchaModel::class); + $this->quoteModelMock = $this->createMock(Quote::class); + $this->controllerMock = $this->createMock(Action::class); + $this->requestMock = $this->createMock(Http::class); + $this->responseMock = $this->createMock(HttpResponse::class); + $this->observer = new Observer(['controller_action' => $this->controllerMock]); + $this->jsonHelperMock = $this->createMock(JsonHelper::class); + + $this->checkRegisterCheckoutObserver = $this->objectManager->getObject( + CheckRegisterCheckoutObserver::class, + [ + 'helper' => $captchaHelperMock, + 'actionFlag' => $this->actionFlagMock, + 'captchaStringResolver' => $this->captchaStringResolverMock, + 'typeOnepage' => $onepageModelTypeMock, + 'jsonHelper' => $this->jsonHelperMock + ] + ); + + $captchaHelperMock->expects($this->once()) + ->method('getCaptcha') + ->with(self::FORM_ID) + ->willReturn($this->captchaModelMock); + $onepageModelTypeMock->expects($this->once()) + ->method('getQuote') + ->willReturn($this->quoteModelMock); + } + + public function testCheckRegisterCheckoutForGuest() + { + $this->quoteModelMock->expects($this->once()) + ->method('getCheckoutMethod') + ->willReturn(Onepage::METHOD_GUEST); + $this->captchaModelMock->expects($this->never()) + ->method('isRequired'); + + $this->checkRegisterCheckoutObserver->execute($this->observer); + } + + public function testCheckRegisterCheckoutWithNoCaptchaRequired() + { + $this->quoteModelMock->expects($this->once()) + ->method('getCheckoutMethod') + ->willReturn(Onepage::METHOD_REGISTER); + $this->captchaModelMock->expects($this->once()) + ->method('isRequired') + ->willReturn(false); + $this->captchaModelMock->expects($this->never()) + ->method('isCorrect'); + + $this->checkRegisterCheckoutObserver->execute($this->observer); + } + + public function testCheckRegisterCheckoutWithIncorrectCaptcha() + { + $captchaValue = 'some_word'; + $encodedJsonValue = '{}'; + + $this->quoteModelMock->expects($this->once()) + ->method('getCheckoutMethod') + ->willReturn(Onepage::METHOD_REGISTER); + $this->captchaModelMock->expects($this->once()) + ->method('isRequired') + ->willReturn(true); + $this->controllerMock->expects($this->once()) + ->method('getRequest') + ->willReturn($this->requestMock); + $this->controllerMock->expects($this->once()) + ->method('getResponse') + ->willReturn($this->responseMock); + $this->controllerMock->expects($this->once()) + ->method('getResponse') + ->willReturn($this->responseMock); + $this->captchaStringResolverMock->expects($this->once()) + ->method('resolve') + ->with($this->requestMock, self::FORM_ID) + ->willReturn($captchaValue); + $this->captchaModelMock->expects($this->once()) + ->method('isCorrect') + ->with($captchaValue) + ->willReturn(false); + $this->actionFlagMock->expects($this->once()) + ->method('set') + ->with('', Action::FLAG_NO_DISPATCH, true); + $this->jsonHelperMock->expects($this->once()) + ->method('jsonEncode') + ->willReturn($encodedJsonValue); + $this->responseMock->expects($this->once()) + ->method('representJson') + ->with($encodedJsonValue); + + $this->checkRegisterCheckoutObserver->execute($this->observer); + } + + public function testCheckRegisterCheckoutWithCorrectCaptcha() + { + $this->quoteModelMock->expects($this->once()) + ->method('getCheckoutMethod') + ->willReturn(Onepage::METHOD_REGISTER); + $this->captchaModelMock->expects($this->once()) + ->method('isRequired') + ->willReturn(true); + $this->controllerMock->expects($this->once()) + ->method('getRequest') + ->willReturn($this->requestMock); + $this->captchaStringResolverMock->expects($this->once()) + ->method('resolve') + ->with($this->requestMock, self::FORM_ID) + ->willReturn('some_word'); + $this->captchaModelMock->expects($this->once()) + ->method('isCorrect') + ->with('some_word') + ->willReturn(true); + $this->actionFlagMock->expects($this->never()) + ->method('set'); + + $this->checkRegisterCheckoutObserver->execute($this->observer); + } +} diff --git a/app/code/Magento/Captcha/composer.json b/app/code/Magento/Captcha/composer.json index a8513062147de..62f586ba82ae0 100644 --- a/app/code/Magento/Captcha/composer.json +++ b/app/code/Magento/Captcha/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-checkout": "*", diff --git a/app/code/Magento/Captcha/i18n/en_US.csv b/app/code/Magento/Captcha/i18n/en_US.csv index 3c56d3f0d393d..480107df8adfe 100644 --- a/app/code/Magento/Captcha/i18n/en_US.csv +++ b/app/code/Magento/Captcha/i18n/en_US.csv @@ -20,11 +20,7 @@ Forms,Forms "Number of Symbols","Number of Symbols" "Please specify 8 symbols at the most. Range allowed (e.g. 3-5)","Please specify 8 symbols at the most. Range allowed (e.g. 3-5)" "Symbols Used in CAPTCHA","Symbols Used in CAPTCHA" -" - Please use only letters (a-z or A-Z) or numbers (0-9) in this field. No spaces or other characters are allowed.
Similar looking characters (e.g. ""i"", ""l"", ""1"") decrease chance of correct recognition by customer. - "," - Please use only letters (a-z or A-Z) or numbers (0-9) in this field. No spaces or other characters are allowed.
Similar looking characters (e.g. ""i"", ""l"", ""1"") decrease chance of correct recognition by customer. - " +"Please use only letters (a-z or A-Z) or numbers (0-9) in this field. No spaces or other characters are allowed.
Similar looking characters (e.g. ""i"", ""l"", ""1"") decrease chance of correct recognition by customer.","Please use only letters (a-z or A-Z) or numbers (0-9) in this field. No spaces or other characters are allowed.
Similar looking characters (e.g. ""i"", ""l"", ""1"") decrease chance of correct recognition by customer." "Case Sensitive","Case Sensitive" "Enable CAPTCHA on Storefront","Enable CAPTCHA on Storefront" "CAPTCHA for ""Create user"" and ""Forgot password"" forms is always enabled if chosen.","CAPTCHA for ""Create user"" and ""Forgot password"" forms is always enabled if chosen." diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Category/Tree.php b/app/code/Magento/Catalog/Block/Adminhtml/Category/Tree.php index 4b0d233d3e77b..34da5bb1d4ca1 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Category/Tree.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Category/Tree.php @@ -325,7 +325,7 @@ public function getBreadcrumbsJavascript($path, $javascriptVarName) * * @param Node|array $node * @param int $level - * @return string + * @return array * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Category/Widget/Chooser.php b/app/code/Magento/Catalog/Block/Adminhtml/Category/Widget/Chooser.php index 5e98313f95f0f..b5330ab66af71 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Category/Widget/Chooser.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Category/Widget/Chooser.php @@ -144,7 +144,7 @@ function (node, e) { * * @param \Magento\Framework\Data\Tree\Node|array $node * @param int $level - * @return string + * @return array */ protected function _getNodeJson($node, $level = 0) { diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Form/Renderer/Fieldset/Element.php b/app/code/Magento/Catalog/Block/Adminhtml/Form/Renderer/Fieldset/Element.php index 10214fc1d16fd..ad6df27b89334 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Form/Renderer/Fieldset/Element.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Form/Renderer/Fieldset/Element.php @@ -21,7 +21,7 @@ class Element extends \Magento\Backend\Block\Widget\Form\Renderer\Fieldset\Eleme /** * Retrieve data object related with form * - * @return \Magento\Catalog\Model\Product || \Magento\Catalog\Model\Category + * @return \Magento\Catalog\Model\Product|\Magento\Catalog\Model\Category */ public function getDataObject() { diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php index cc50dbde69ee3..a0ca53dce4f50 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php @@ -184,33 +184,19 @@ protected function _prepareForm() 'form_after', $this->getLayout()->createBlock( \Magento\Backend\Block\Widget\Form\Element\Dependence::class - )->addFieldMap( - "is_wysiwyg_enabled", - 'wysiwyg_enabled' )->addFieldMap( "is_html_allowed_on_front", 'html_allowed_on_front' )->addFieldMap( "frontend_input", 'frontend_input_type' - )->addFieldDependence( - 'wysiwyg_enabled', - 'frontend_input_type', - 'textarea' - )->addFieldDependence( - 'html_allowed_on_front', - 'wysiwyg_enabled', - '0' - ) - ->addFieldMap( + )->addFieldMap( "is_searchable", 'searchable' - ) - ->addFieldMap( + )->addFieldMap( "is_visible_in_advanced_search", 'advanced_search' - ) - ->addFieldDependence( + )->addFieldDependence( 'advanced_search', 'searchable', '1' diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Grid.php b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Grid.php index ab5026b1e69b9..66e04ef03f771 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Grid.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Grid.php @@ -101,8 +101,7 @@ protected function _prepareColumns() 'type' => 'options', 'options' => ['1' => __('Yes'), '0' => __('No')], 'align' => 'center' - ], - 'is_user_defined' + ] ); $this->_eventManager->dispatch('product_attribute_grid_build', ['grid' => $this]); diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Tab/Attributes/Search.php b/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Tab/Attributes/Search.php index d5f66231f1d82..e1b97f996c769 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Tab/Attributes/Search.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Tab/Attributes/Search.php @@ -81,7 +81,7 @@ public function getSelectorOptions() * * @param string $labelPart * @param int $templateId - * @return \Magento\Catalog\Model\ResourceModel\Product\Attribute\Collection + * @return array */ public function getSuggestedAttributes($labelPart, $templateId = null) { diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Rss/Grid/Link.php b/app/code/Magento/Catalog/Block/Adminhtml/Rss/Grid/Link.php index dbeff93683bc0..9d13d89d54b80 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Rss/Grid/Link.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Rss/Grid/Link.php @@ -69,7 +69,7 @@ public function isRssAllowed() } /** - * @return string + * @return array */ protected function getLinkParams() { diff --git a/app/code/Magento/Catalog/Block/Category/Plugin/PriceBoxTags.php b/app/code/Magento/Catalog/Block/Category/Plugin/PriceBoxTags.php index 03932151358ab..99399110505b7 100644 --- a/app/code/Magento/Catalog/Block/Category/Plugin/PriceBoxTags.php +++ b/app/code/Magento/Catalog/Block/Category/Plugin/PriceBoxTags.php @@ -71,7 +71,7 @@ public function afterGetCacheKey(PriceBox $subject, $result) '-', [ $result, - $this->priceCurrency->getCurrencySymbol(), + $this->priceCurrency->getCurrency()->getCode(), $this->dateTime->scopeDate($this->scopeResolver->getScope()->getId())->format('Ymd'), $this->scopeResolver->getScope()->getId(), $this->customerSession->getCustomerGroupId(), diff --git a/app/code/Magento/Catalog/Block/Category/Rss/Link.php b/app/code/Magento/Catalog/Block/Category/Rss/Link.php index 0599d5f4b989c..e40b81200574c 100644 --- a/app/code/Magento/Catalog/Block/Category/Rss/Link.php +++ b/app/code/Magento/Catalog/Block/Category/Rss/Link.php @@ -62,7 +62,7 @@ public function getLabel() } /** - * @return string + * @return array */ protected function getLinkParams() { diff --git a/app/code/Magento/Catalog/Block/Product/AbstractProduct.php b/app/code/Magento/Catalog/Block/Product/AbstractProduct.php index d4af775ad20da..4102c82a0a316 100644 --- a/app/code/Magento/Catalog/Block/Product/AbstractProduct.php +++ b/app/code/Magento/Catalog/Block/Product/AbstractProduct.php @@ -195,7 +195,7 @@ public function getAddToCompareUrl() * Gets minimal sales quantity * * @param \Magento\Catalog\Model\Product $product - * @return int|null + * @return float|null */ public function getMinimalQty($product) { @@ -510,9 +510,6 @@ protected function getDetailsRendererList() */ public function getImage($product, $imageId, $attributes = []) { - return $this->imageBuilder->setProduct($product) - ->setImageId($imageId) - ->setAttributes($attributes) - ->create(); + return $this->imageBuilder->create($product, $imageId, $attributes); } } diff --git a/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php b/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php index c561913dee4d3..6c54aa4e171ef 100644 --- a/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php +++ b/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php @@ -213,6 +213,22 @@ public function getProductAttributeValue($product, $attribute) return (string)$value == '' ? __('No') : $value; } + /** + * Check if any of the products has a value set for the attribute + * + * @param \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute + * @return bool + */ + public function hasAttributeValueForProducts($attribute) + { + foreach ($this->getItems() as $item) { + if ($item->hasData($attribute->getAttributeCode())) { + return true; + } + } + return false; + } + /** * Retrieve Print URL * diff --git a/app/code/Magento/Catalog/Block/Product/Image.php b/app/code/Magento/Catalog/Block/Product/Image.php index 3ce97bd53f8d7..20a556ab41451 100644 --- a/app/code/Magento/Catalog/Block/Product/Image.php +++ b/app/code/Magento/Catalog/Block/Product/Image.php @@ -11,8 +11,6 @@ * @method string getWidth() * @method string getHeight() * @method string getLabel() - * @method mixed getResizedImageWidth() - * @method mixed getResizedImageHeight() * @method float getRatio() * @method string getCustomAttributes() * @since 100.0.2 diff --git a/app/code/Magento/Catalog/Block/Product/ImageBuilder.php b/app/code/Magento/Catalog/Block/Product/ImageBuilder.php index f1149f15c41d3..06d4fb39109d8 100644 --- a/app/code/Magento/Catalog/Block/Product/ImageBuilder.php +++ b/app/code/Magento/Catalog/Block/Product/ImageBuilder.php @@ -3,12 +3,17 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Block\Product; use Magento\Catalog\Helper\ImageFactory as HelperFactory; use Magento\Catalog\Model\Product; -use Magento\Catalog\Model\Product\Image\NotLoadInfoImageException; +/** + * @deprecated + * @see ImageFactory + */ class ImageBuilder { /** @@ -117,39 +122,16 @@ protected function getRatio(\Magento\Catalog\Helper\Image $helper) /** * Create image block * - * @return \Magento\Catalog\Block\Product\Image + * @param Product|null $product + * @param string|null $imageId + * @param array|null $attributes + * @return Image */ - public function create() + public function create(Product $product = null, string $imageId = null, array $attributes = null) { - /** @var \Magento\Catalog\Helper\Image $helper */ - $helper = $this->helperFactory->create() - ->init($this->product, $this->imageId); - - $template = $helper->getFrame() - ? 'Magento_Catalog::product/image.phtml' - : 'Magento_Catalog::product/image_with_borders.phtml'; - - try { - $imagesize = $helper->getResizedImageInfo(); - } catch (NotLoadInfoImageException $exception) { - $imagesize = [$helper->getWidth(), $helper->getHeight()]; - } - - $data = [ - 'data' => [ - 'template' => $template, - 'image_url' => $helper->getUrl(), - 'width' => $helper->getWidth(), - 'height' => $helper->getHeight(), - 'label' => $helper->getLabel(), - 'ratio' => $this->getRatio($helper), - 'custom_attributes' => $this->getCustomAttributes(), - 'resized_image_width' => $imagesize[0], - 'resized_image_height' => $imagesize[1], - 'product_id' => $this->product->getId() - ], - ]; - - return $this->imageFactory->create($data); + $product = $product ?? $this->product; + $imageId = $imageId ?? $this->imageId; + $attributes = $attributes ?? $this->attributes; + return $this->imageFactory->create($product, $imageId, $attributes); } } diff --git a/app/code/Magento/Catalog/Block/Product/ImageFactory.php b/app/code/Magento/Catalog/Block/Product/ImageFactory.php new file mode 100644 index 0000000000000..f9a576367ddeb --- /dev/null +++ b/app/code/Magento/Catalog/Block/Product/ImageFactory.php @@ -0,0 +1,163 @@ +objectManager = $objectManager; + $this->presentationConfig = $presentationConfig; + $this->viewAssetPlaceholderFactory = $viewAssetPlaceholderFactory; + $this->viewAssetImageFactory = $viewAssetImageFactory; + $this->imageParamsBuilder = $imageParamsBuilder; + } + + /** + * Retrieve image custom attributes for HTML element + * + * @param array $attributes + * @return string + */ + private function getStringCustomAttributes(array $attributes): string + { + $result = []; + foreach ($attributes as $name => $value) { + $result[] = $name . '="' . $value . '"'; + } + return !empty($result) ? implode(' ', $result) : ''; + } + + /** + * Calculate image ratio + * + * @param $width + * @param $height + * @return float + */ + private function getRatio(int $width, int $height): float + { + if ($width && $height) { + return $height / $width; + } + return 1.0; + } + + /** + * @param Product $product + * + * @param string $imageType + * @return string + */ + private function getLabel(Product $product, string $imageType): string + { + $label = $product->getData($imageType . '_' . 'label'); + if (empty($label)) { + $label = $product->getName(); + } + return (string) $label; + } + + /** + * Create image block from product + * @param Product $product + * @param string $imageId + * @param array|null $attributes + * @return ImageBlock + */ + public function create(Product $product, string $imageId, array $attributes = null): ImageBlock + { + $viewImageConfig = $this->presentationConfig->getViewConfig()->getMediaAttributes( + 'Magento_Catalog', + ImageHelper::MEDIA_TYPE_CONFIG_NODE, + $imageId + ); + + $imageMiscParams = $this->imageParamsBuilder->build($viewImageConfig); + $originalFilePath = $product->getData($imageMiscParams['image_type']); + + if ($originalFilePath === null || $originalFilePath === 'no_selection') { + $imageAsset = $this->viewAssetPlaceholderFactory->create( + [ + 'type' => $imageMiscParams['image_type'] + ] + ); + } else { + $imageAsset = $this->viewAssetImageFactory->create( + [ + 'miscParams' => $imageMiscParams, + 'filePath' => $originalFilePath, + ] + ); + } + + $data = [ + 'data' => [ + 'template' => 'Magento_Catalog::product/image_with_borders.phtml', + 'image_url' => $imageAsset->getUrl(), + 'width' => $imageMiscParams['image_width'], + 'height' => $imageMiscParams['image_height'], + 'label' => $this->getLabel($product, $imageMiscParams['image_type']), + 'ratio' => $this->getRatio($imageMiscParams['image_width'], $imageMiscParams['image_height']), + 'custom_attributes' => $this->getStringCustomAttributes($attributes), + 'product_id' => $product->getId() + ], + ]; + + return $this->objectManager->create(ImageBlock::class, $data); + } +} diff --git a/app/code/Magento/Catalog/Block/Product/ListProduct.php b/app/code/Magento/Catalog/Block/Product/ListProduct.php index ee63d3400ade5..c1b255c762dbb 100644 --- a/app/code/Magento/Catalog/Block/Product/ListProduct.php +++ b/app/code/Magento/Catalog/Block/Product/ListProduct.php @@ -356,7 +356,7 @@ public function getIdentities() * Get post parameters * * @param Product $product - * @return string + * @return array */ public function getAddToCartPostParams(Product $product) { diff --git a/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php b/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php index 46080ab5c3330..39dec984a19cc 100644 --- a/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php +++ b/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php @@ -196,7 +196,7 @@ public function setCollection($collection) $this->_collection->addAttributeToSort( $this->getCurrentOrder(), $this->getCurrentDirection() - )->addAttributeToSort('entity_id', $this->getCurrentDirection()); + ); } else { $this->_collection->setOrder($this->getCurrentOrder(), $this->getCurrentDirection()); } @@ -689,7 +689,7 @@ public function getWidgetOptionsJson(array $customOptions = []) 'limit' => ToolbarModel::LIMIT_PARAM_NAME, 'modeDefault' => $defaultMode, 'directionDefault' => $this->_direction ?: ProductList::DEFAULT_SORT_DIRECTION, - 'orderDefault' => $this->_productListHelper->getDefaultSortField(), + 'orderDefault' => $this->getOrderField(), 'limitDefault' => $this->_productListHelper->getDefaultLimitPerPageValue($defaultMode), 'url' => $this->getPagerUrl(), ]; diff --git a/app/code/Magento/Catalog/Block/Product/View/Gallery.php b/app/code/Magento/Catalog/Block/Product/View/Gallery.php index 90e89acfba77a..ab01fc6d134e9 100644 --- a/app/code/Magento/Catalog/Block/Product/View/Gallery.php +++ b/app/code/Magento/Catalog/Block/Product/View/Gallery.php @@ -15,6 +15,7 @@ use Magento\Catalog\Helper\Image; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface; +use Magento\Catalog\Model\Product\Image\UrlBuilder; use Magento\Framework\Data\Collection; use Magento\Framework\DataObject; use Magento\Framework\App\ObjectManager; @@ -47,13 +48,19 @@ class Gallery extends AbstractView */ private $galleryImagesConfigFactory; + /** + * @var UrlBuilder + */ + private $imageUrlBuilder; + /** * @param Context $context * @param ArrayUtils $arrayUtils * @param EncoderInterface $jsonEncoder * @param array $data - * @param ImagesConfigFactoryInterface $imagesConfigFactory + * @param ImagesConfigFactoryInterface|null $imagesConfigFactory * @param array $galleryImagesConfig + * @param UrlBuilder|null $urlBuilder */ public function __construct( Context $context, @@ -61,13 +68,15 @@ public function __construct( EncoderInterface $jsonEncoder, array $data = [], ImagesConfigFactoryInterface $imagesConfigFactory = null, - array $galleryImagesConfig = [] + array $galleryImagesConfig = [], + UrlBuilder $urlBuilder = null ) { parent::__construct($context, $arrayUtils, $data); $this->jsonEncoder = $jsonEncoder; $this->galleryImagesConfigFactory = $imagesConfigFactory ?: ObjectManager::getInstance() ->get(ImagesConfigFactoryInterface::class); $this->galleryImagesConfig = $galleryImagesConfig; + $this->imageUrlBuilder = $urlBuilder ?? ObjectManager::getInstance()->get(UrlBuilder::class); } /** @@ -88,9 +97,7 @@ public function getGalleryImages() foreach ($galleryImagesConfig as $imageConfig) { $image->setData( $imageConfig->getData('data_object_key'), - $this->_imageHelper->init($product, $imageConfig['image_id']) - ->setImageFile($image->getData('file')) - ->getUrl() + $this->imageUrlBuilder->getUrl($image->getFile(), $imageConfig['image_id']) ); } } diff --git a/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php b/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php index 66bf5eafb156e..d582005f653ef 100644 --- a/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php +++ b/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php @@ -178,7 +178,7 @@ public function getPrice($price, $includingTax = null) * Returns price converted to current currency rate * * @param float $price - * @return float + * @return float|string */ public function getCurrencyPrice($price) { diff --git a/app/code/Magento/Catalog/Block/Rss/Product/Special.php b/app/code/Magento/Catalog/Block/Rss/Product/Special.php index c61bee4417cbc..a9107f14cc5e4 100644 --- a/app/code/Magento/Catalog/Block/Rss/Product/Special.php +++ b/app/code/Magento/Catalog/Block/Rss/Product/Special.php @@ -107,7 +107,7 @@ protected function _construct() } /** - * @return string + * @return array */ public function getRssData() { diff --git a/app/code/Magento/Catalog/Console/Command/ImagesResizeCommand.php b/app/code/Magento/Catalog/Console/Command/ImagesResizeCommand.php deleted file mode 100644 index 7136c60edc18a..0000000000000 --- a/app/code/Magento/Catalog/Console/Command/ImagesResizeCommand.php +++ /dev/null @@ -1,99 +0,0 @@ -appState = $appState; - $this->productCollectionFactory = $productCollectionFactory; - $this->productRepository = $productRepository; - $this->imageCacheFactory = $imageCacheFactory; - parent::__construct(); - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this->setName('catalog:images:resize') - ->setDescription('Creates resized product images'); - } - - /** - * {@inheritdoc} - */ - protected function execute( - \Symfony\Component\Console\Input\InputInterface $input, - \Symfony\Component\Console\Output\OutputInterface $output - ) { - $this->appState->setAreaCode(\Magento\Framework\App\Area::AREA_GLOBAL); - - /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $productCollection */ - $productCollection = $this->productCollectionFactory->create(); - $productIds = $productCollection->getAllIds(); - if (!count($productIds)) { - $output->writeln("No product images to resize"); - return \Magento\Framework\Console\Cli::RETURN_SUCCESS; - } - - try { - foreach ($productIds as $productId) { - try { - /** @var \Magento\Catalog\Model\Product $product */ - $product = $this->productRepository->getById($productId); - } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { - continue; - } - - /** @var \Magento\Catalog\Model\Product\Image\Cache $imageCache */ - $imageCache = $this->imageCacheFactory->create(); - $imageCache->generate($product); - - $output->write("."); - } - } catch (\Exception $e) { - $output->writeln("{$e->getMessage()}"); - // we must have an exit code higher than zero to indicate something was wrong - return \Magento\Framework\Console\Cli::RETURN_FAILURE; - } - - $output->write("\n"); - $output->writeln("Product images resized successfully"); - } -} diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Category.php b/app/code/Magento/Catalog/Controller/Adminhtml/Category.php index 13c4353e65204..1e0cb9f197a51 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Category.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Category.php @@ -3,8 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Controller\Adminhtml; +use Magento\Store\Model\Store; + /** * Catalog category controller */ @@ -44,7 +48,7 @@ public function __construct( protected function _initCategory($getRootInstead = false) { $categoryId = $this->resolveCategoryId(); - $storeId = (int)$this->getRequest()->getParam('store'); + $storeId = $this->resolveStoreId(); $category = $this->_objectManager->create(\Magento\Catalog\Model\Category::class); $category->setStoreId($storeId); @@ -70,7 +74,7 @@ protected function _initCategory($getRootInstead = false) $this->_objectManager->get(\Magento\Framework\Registry::class)->register('category', $category); $this->_objectManager->get(\Magento\Framework\Registry::class)->register('current_category', $category); $this->_objectManager->get(\Magento\Cms\Model\Wysiwyg\Config::class) - ->setStoreId($this->getRequest()->getParam('store')); + ->setStoreId($storeId); return $category; } @@ -79,13 +83,28 @@ protected function _initCategory($getRootInstead = false) * * @return int */ - private function resolveCategoryId() + private function resolveCategoryId() : int { $categoryId = (int)$this->getRequest()->getParam('id', false); return $categoryId ?: (int)$this->getRequest()->getParam('entity_id', false); } + /** + * Resolve store id + * + * Tries to take store id from store HTTP parameter + * @see Store + * + * @return int + */ + private function resolveStoreId() : int + { + $storeId = (int)$this->getRequest()->getParam('store', false); + + return $storeId ?: (int)$this->getRequest()->getParam('store_id', Store::DEFAULT_STORE_ID); + } + /** * Build response for ajax request * diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php index 6a9abe0a4c64e..817de6828e48d 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php @@ -69,6 +69,7 @@ class Save extends Attribute * @var LayoutFactory */ private $layoutFactory; + /** * @var Presentation */ @@ -124,6 +125,7 @@ public function execute() { $data = $this->getRequest()->getPostValue(); if ($data) { + $this->preprocessOptionsData($data); $setId = $this->getRequest()->getParam('set'); $attributeSet = null; @@ -139,9 +141,7 @@ public function execute() ->setName($name) ->getAttributeSet(); } catch (AlreadyExistsException $alreadyExists) { - $this->messageManager->addErrorMessage( - __('A "%1" attribute set name already exists. Create a new name and try again.', $name) - ); + $this->messageManager->addErrorMessage(__('An attribute set named \'%1\' already exists.', $name)); $this->_session->setAttributeData($data); return $this->returnResult('catalog/*/edit', ['_current' => true], ['error' => true]); } catch (LocalizedException $e) { @@ -202,6 +202,8 @@ public function execute() } } + $data = $this->presentation->convertPresentationDataToInputType($data); + if ($attributeId) { if (!$model->getId()) { $this->messageManager->addErrorMessage(__('This attribute no longer exists.')); @@ -216,7 +218,7 @@ public function execute() $data['attribute_code'] = $model->getAttributeCode(); $data['is_user_defined'] = $model->getIsUserDefined(); - $data['frontend_input'] = $model->getFrontendInput(); + $data['frontend_input'] = $data['frontend_input'] ?? $model->getFrontendInput(); } else { /** * @todo add to helper and specify all relations for properties @@ -229,8 +231,6 @@ public function execute() ); } - $data = $this->presentation->convertPresentationDataToInputType($data); - $data += ['is_filterable' => 0, 'is_filterable_in_search' => 0]; if ($model->getIsUserDefined() === null || $model->getIsUserDefined() != 0) { @@ -315,6 +315,28 @@ public function execute() return $this->returnResult('catalog/*/', [], ['error' => true]); } + /** + * Extract options data from serialized options field and append to data array. + * + * This logic is required to overcome max_input_vars php limit + * that may vary and/or be inaccessible to change on different instances. + * + * @param array $data + * @return void + */ + private function preprocessOptionsData(&$data) + { + if (isset($data['serialized_options'])) { + $serializedOptions = json_decode($data['serialized_options'], JSON_OBJECT_AS_ARRAY); + foreach ($serializedOptions as $serializedOption) { + $option = []; + parse_str($serializedOption, $option); + $data = array_replace_recursive($data, $option); + } + } + unset($data['serialized_options']); + } + /** * @param string $path * @param array $params diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php index beb6f2b13bcfe..95339870b4d61 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php @@ -3,14 +3,18 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Catalog\Controller\Adminhtml\Product\Initialization; use Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory as CustomOptionFactory; use Magento\Catalog\Api\Data\ProductLinkInterfaceFactory as ProductLinkFactory; +use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Api\ProductRepositoryInterface\Proxy as ProductRepository; +use Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper\AttributeDefaultValueFilter; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Initialization\Helper\ProductLinks; use Magento\Catalog\Model\Product\Link\Resolver as LinkResolver; +use Magento\Catalog\Model\Product\LinkTypeProvider; use Magento\Framework\App\ObjectManager; use Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper\AttributeFilter; @@ -81,7 +85,7 @@ class Helper private $dateTimeFilter; /** - * @var \Magento\Catalog\Model\Product\LinkTypeProvider + * @var LinkTypeProvider */ private $linkTypeProvider; @@ -99,10 +103,10 @@ class Helper * @param ProductLinks $productLinks * @param \Magento\Backend\Helper\Js $jsHelper * @param \Magento\Framework\Stdlib\DateTime\Filter\Date $dateFilter - * @param \Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory|null $customOptionFactory - * @param \Magento\Catalog\Api\Data\ProductLinkInterfaceFactory|null $productLinkFactory - * @param \Magento\Catalog\Api\ProductRepositoryInterface|null $productRepository - * @param \Magento\Catalog\Model\Product\LinkTypeProvider|null $linkTypeProvider + * @param CustomOptionFactory|null $customOptionFactory + * @param ProductLinkFactory |null $productLinkFactory + * @param ProductRepositoryInterface|null $productRepository + * @param LinkTypeProvider|null $linkTypeProvider * @param AttributeFilter|null $attributeFilter * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -113,10 +117,10 @@ public function __construct( \Magento\Catalog\Model\Product\Initialization\Helper\ProductLinks $productLinks, \Magento\Backend\Helper\Js $jsHelper, \Magento\Framework\Stdlib\DateTime\Filter\Date $dateFilter, - \Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory $customOptionFactory = null, - \Magento\Catalog\Api\Data\ProductLinkInterfaceFactory $productLinkFactory = null, - \Magento\Catalog\Api\ProductRepositoryInterface $productRepository = null, - \Magento\Catalog\Model\Product\LinkTypeProvider $linkTypeProvider = null, + CustomOptionFactory $customOptionFactory = null, + ProductLinkFactory $productLinkFactory = null, + ProductRepositoryInterface $productRepository = null, + LinkTypeProvider $linkTypeProvider = null, AttributeFilter $attributeFilter = null ) { $this->request = $request; @@ -125,16 +129,13 @@ public function __construct( $this->productLinks = $productLinks; $this->jsHelper = $jsHelper; $this->dateFilter = $dateFilter; - $this->customOptionFactory = $customOptionFactory ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory::class); - $this->productLinkFactory = $productLinkFactory ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Catalog\Api\Data\ProductLinkInterfaceFactory::class); - $this->productRepository = $productRepository ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); - $this->linkTypeProvider = $linkTypeProvider ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Catalog\Model\Product\LinkTypeProvider::class); - $this->attributeFilter = $attributeFilter ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(AttributeFilter::class); + + $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); + $this->customOptionFactory = $customOptionFactory ?: $objectManager->get(CustomOptionFactory::class); + $this->productLinkFactory = $productLinkFactory ?: $objectManager->get(ProductLinkFactory::class); + $this->productRepository = $productRepository ?: $objectManager->get(ProductRepositoryInterface::class); + $this->linkTypeProvider = $linkTypeProvider ?: $objectManager->get(LinkTypeProvider::class); + $this->attributeFilter = $attributeFilter ?: $objectManager->get(AttributeFilter::class); } /** @@ -150,8 +151,7 @@ public function __construct( */ public function initializeFromData(\Magento\Catalog\Model\Product $product, array $productData) { - unset($productData['custom_attributes']); - unset($productData['extension_attributes']); + unset($productData['custom_attributes'], $productData['extension_attributes']); if ($productData) { $stockData = isset($productData['stock_data']) ? $productData['stock_data'] : []; @@ -199,28 +199,13 @@ public function initializeFromData(\Magento\Catalog\Model\Product $product, arra $productData['tier_price'] = isset($productData['tier_price']) ? $productData['tier_price'] : []; $useDefaults = (array)$this->request->getPost('use_default', []); - $productData = $this->attributeFilter->prepareProductAttributes($product, $productData, $useDefaults); - $product->addData($productData); if ($wasLockedMedia) { $product->lockAttribute('media'); } - /** - * Check "Use Default Value" checkboxes values - */ - foreach ($useDefaults as $attributeCode => $useDefaultState) { - if ($useDefaultState) { - $product->setData($attributeCode, null); - // UI component sends value even if field is disabled, so 'Use Config Settings' must be reset to false - if ($product->hasData('use_config_' . $attributeCode)) { - $product->setData('use_config_' . $attributeCode, false); - } - } - } - $product = $this->setProductLinks($product); $product = $this->fillProductOptions($product, $productOptions); diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php index 237168282afae..188b0b22f33bf 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php @@ -3,6 +3,9 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper; use \Magento\Catalog\Model\Product; @@ -25,17 +28,74 @@ class AttributeFilter * @param array $useDefaults * @return array */ - public function prepareProductAttributes(Product $product, array $productData, array $useDefaults) + public function prepareProductAttributes(Product $product, array $productData, array $useDefaults): array { - foreach ($productData as $attribute => $value) { - $considerUseDefaultsAttribute = !isset($useDefaults[$attribute]) || $useDefaults[$attribute] === "1"; - if ($value === '' && $considerUseDefaultsAttribute) { - /** @var $product Product */ - if ((bool)$product->getData($attribute) === (bool)$value) { - unset($productData[$attribute]); - } + $attributeList = $product->getAttributes(); + foreach ($productData as $attributeCode => $attributeValue) { + if ($this->isAttributeShouldNotBeUpdated($product, $useDefaults, $attributeCode, $attributeValue)) { + unset($productData[$attributeCode]); } + + if (isset($useDefaults[$attributeCode]) && $useDefaults[$attributeCode] === '1') { + $productData = $this->prepareDefaultData($attributeList, $attributeCode, $productData); + $productData = $this->prepareConfigData($product, $attributeCode, $productData); + } + } + + return $productData; + } + + /** + * @param Product $product + * @param string $attributeCode + * @param array $productData + * @return array + */ + private function prepareConfigData(Product $product, string $attributeCode, array $productData): array + { + // UI component sends value even if field is disabled, so 'Use Config Settings' must be reset to false + if ($product->hasData('use_config_' . $attributeCode)) { + $productData['use_config_' . $attributeCode] = false; } + return $productData; } + + /** + * @param array $attributeList + * @param string $attributeCode + * @param array $productData + * @return array + */ + private function prepareDefaultData(array $attributeList, string $attributeCode, array $productData): array + { + if (isset($attributeList[$attributeCode])) { + /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute */ + $attribute = $attributeList[$attributeCode]; + $attributeType = $attribute->getBackendType(); + // For non-numberic types set the attributeValue to 'false' to trigger their removal from the db + if ($attributeType === 'varchar' || $attributeType === 'text' || $attributeType === 'datetime') { + $attribute->setIsRequired(false); + $productData[$attributeCode] = false; + } else { + $productData[$attributeCode] = null; + } + } + + return $productData; + } + + /** + * @param Product $product + * @param array $useDefaults + * @param string $attribute + * @param mixed $value + * @return bool + */ + private function isAttributeShouldNotBeUpdated(Product $product, array $useDefaults, $attribute, $value): bool + { + $considerUseDefaultsAttribute = !isset($useDefaults[$attribute]) || $useDefaults[$attribute] === '1'; + + return ($value === '' && $considerUseDefaultsAttribute && !$product->getData($attribute)); + } } diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php index 1481687205ddb..84837262a8154 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php @@ -216,6 +216,9 @@ private function handleImageRemoveError($postData, $productId) /** * Do copying data to stores * + * If the 'copy_from' field is not specified in the input data, + * the store fallback mechanism will automatically take the admin store's default value. + * * @param array $data * @param int $productId * @return void @@ -227,15 +230,17 @@ protected function copyToStores($data, $productId) if (isset($data['product']['website_ids'][$websiteId]) && (bool)$data['product']['website_ids'][$websiteId]) { foreach ($group as $store) { - $copyFrom = (isset($store['copy_from'])) ? $store['copy_from'] : 0; - $copyTo = (isset($store['copy_to'])) ? $store['copy_to'] : 0; - if ($copyTo) { - $this->_objectManager->create(\Magento\Catalog\Model\Product::class) - ->setStoreId($copyFrom) - ->load($productId) - ->setStoreId($copyTo) - ->setCopyFromView(true) - ->save(); + if (isset($store['copy_from'])) { + $copyFrom = $store['copy_from']; + $copyTo = (isset($store['copy_to'])) ? $store['copy_to'] : 0; + if ($copyTo) { + $this->_objectManager->create(\Magento\Catalog\Model\Product::class) + ->setStoreId($copyFrom) + ->load($productId) + ->setStoreId($copyTo) + ->setCopyFromView(true) + ->save(); + } } } } diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Set/Save.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Set/Save.php index 00a836309e58e..dfddcf7e92b97 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Set/Save.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Set/Save.php @@ -6,6 +6,11 @@ */ namespace Magento\Catalog\Controller\Adminhtml\Product\Set; +use Magento\Framework\App\ObjectManager; + +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class Save extends \Magento\Catalog\Controller\Adminhtml\Product\Set { /** @@ -17,22 +22,49 @@ class Save extends \Magento\Catalog\Controller\Adminhtml\Product\Set * @var \Magento\Framework\Controller\Result\JsonFactory */ protected $resultJsonFactory; - + + /* + * @var \Magento\Eav\Model\Entity\Attribute\SetFactory + */ + private $attributeSetFactory; + + /* + * @var \Magento\Framework\Filter\FilterManager + */ + private $filterManager; + + /* + * @var \Magento\Framework\Json\Helper\Data + */ + private $jsonHelper; + /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\View\LayoutFactory $layoutFactory * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + * @param \Magento\Eav\Model\Entity\Attribute\SetFactory $attributeSetFactory + * @param \Magento\Framework\Filter\FilterManager $filterManager + * @param \Magento\Framework\Json\Helper\Data $jsonHelper */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, \Magento\Framework\View\LayoutFactory $layoutFactory, - \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, + \Magento\Eav\Model\Entity\Attribute\SetFactory $attributeSetFactory = null, + \Magento\Framework\Filter\FilterManager $filterManager = null, + \Magento\Framework\Json\Helper\Data $jsonHelper = null ) { parent::__construct($context, $coreRegistry); $this->layoutFactory = $layoutFactory; $this->resultJsonFactory = $resultJsonFactory; + $this->attributeSetFactory = $attributeSetFactory ?: ObjectManager::getInstance() + ->get(\Magento\Eav\Model\Entity\Attribute\SetFactory::class); + $this->filterManager = $filterManager ?: ObjectManager::getInstance() + ->get(\Magento\Framework\Filter\FilterManager::class); + $this->jsonHelper = $jsonHelper ?: ObjectManager::getInstance() + ->get(\Magento\Framework\Json\Helper\Data::class); } /** @@ -65,16 +97,12 @@ public function execute() $isNewSet = $this->getRequest()->getParam('gotoEdit', false) == '1'; /* @var $model \Magento\Eav\Model\Entity\Attribute\Set */ - $model = $this->_objectManager->create(\Magento\Eav\Model\Entity\Attribute\Set::class) - ->setEntityTypeId($entityTypeId); - - /** @var $filterManager \Magento\Framework\Filter\FilterManager */ - $filterManager = $this->_objectManager->get(\Magento\Framework\Filter\FilterManager::class); + $model = $this->attributeSetFactory->create()->setEntityTypeId($entityTypeId); try { if ($isNewSet) { //filter html tags - $name = $filterManager->stripTags($this->getRequest()->getParam('attribute_set_name')); + $name = $this->filterManager->stripTags($this->getRequest()->getParam('attribute_set_name')); $model->setAttributeSetName(trim($name)); } else { if ($attributeSetId) { @@ -85,11 +113,10 @@ public function execute() __('This attribute set no longer exists.') ); } - $data = $this->_objectManager->get(\Magento\Framework\Json\Helper\Data::class) - ->jsonDecode($this->getRequest()->getPost('data')); + $data = $this->jsonHelper->jsonDecode($this->getRequest()->getPost('data')); //filter html tags - $data['attribute_set_name'] = $filterManager->stripTags($data['attribute_set_name']); + $data['attribute_set_name'] = $this->filterManager->stripTags($data['attribute_set_name']); $model->organizeData($data); } diff --git a/app/code/Magento/Catalog/Helper/Image.php b/app/code/Magento/Catalog/Helper/Image.php index 380fd0298c2d7..4f128d639b2bb 100644 --- a/app/code/Magento/Catalog/Helper/Image.php +++ b/app/code/Magento/Catalog/Helper/Image.php @@ -845,10 +845,10 @@ public function getHeight() public function getFrame() { $frame = $this->getAttribute('frame'); - if (empty($frame)) { + if ($frame === null) { $frame = $this->getConfigView()->getVarValue('Magento_Catalog', 'product_image_white_borders'); } - return $frame; + return (bool)$frame; } /** diff --git a/app/code/Magento/Catalog/Helper/Product/View.php b/app/code/Magento/Catalog/Helper/Product/View.php index d1e8c10ecc7fc..5753910c125d2 100644 --- a/app/code/Magento/Catalog/Helper/Product/View.php +++ b/app/code/Magento/Catalog/Helper/Product/View.php @@ -110,13 +110,13 @@ public function __construct( private function preparePageMetadata(ResultPage $resultPage, $product) { $pageLayout = $resultPage->getLayout(); - $pageLayout->createBlock(\Magento\Catalog\Block\Breadcrumbs::class); - $pageConfig = $resultPage->getConfig(); $title = $product->getMetaTitle(); if ($title) { $pageConfig->getTitle()->set($title); + } else { + $pageConfig->getTitle()->set($product->getName()); } $keyword = $product->getMetaKeyword(); diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php new file mode 100644 index 0000000000000..d3c84e69c9540 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php @@ -0,0 +1,131 @@ +eavConfig = $eavConfig; + $this->resourceConnection = $resourceConnection; + } + + /** + * Build condition to filter product collection by EAV attribute + * + * @param Filter $filter + * @return string + * @throws \DomainException + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function build(Filter $filter): string + { + $attribute = $this->getAttributeByCode($filter->getField()); + $tableAlias = 'ca_' . $attribute->getAttributeCode(); + + $conditionType = $this->mapConditionType($filter->getConditionType()); + $conditionValue = $this->mapConditionValue($conditionType, $filter->getValue()); + + // NOTE: store scope was ignored intentionally to perform search across all stores + $attributeSelect = $this->resourceConnection->getConnection() + ->select() + ->from( + [$tableAlias => $attribute->getBackendTable()], + $tableAlias . '.' . $attribute->getEntityIdField() + )->where( + $this->resourceConnection->getConnection()->prepareSqlCondition( + $tableAlias . '.' . $attribute->getIdFieldName(), + ['eq' => $attribute->getAttributeId()] + ) + )->where( + $this->resourceConnection->getConnection()->prepareSqlCondition( + $tableAlias . '.value', + [$conditionType => $conditionValue] + ) + ); + + return $this->resourceConnection + ->getConnection() + ->prepareSqlCondition( + Collection::MAIN_TABLE_ALIAS . '.' . $attribute->getEntityIdField(), + [ + 'in' => $attributeSelect + ] + ); + } + + /** + * @param string $field + * @return Attribute + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getAttributeByCode(string $field): Attribute + { + return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $field); + } + + /** + * Map equal and not equal conditions to in and not in + * + * @param string $conditionType + * @return mixed + */ + private function mapConditionType(string $conditionType): string + { + $conditionsMap = [ + 'eq' => 'in', + 'neq' => 'nin' + ]; + + return isset($conditionsMap[$conditionType]) ? $conditionsMap[$conditionType] : $conditionType; + } + + /** + * Wraps value with '%' if condition type is 'like' or 'not like' + * + * @param string $conditionType + * @param string $conditionValue + * @return string + */ + private function mapConditionValue(string $conditionType, string $conditionValue): string + { + $conditionsMap = ['like', 'nlike']; + + if (in_array($conditionType, $conditionsMap)) { + $conditionValue = '%' . $conditionValue . '%'; + } + + return $conditionValue; + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/Factory.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/Factory.php new file mode 100644 index 0000000000000..66ddd0b8c0ad8 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/Factory.php @@ -0,0 +1,88 @@ +eavConfig = $eavConfig; + $this->productResource = $productResource; + $this->eavAttributeConditionBuilder = $eavAttributeConditionBuilder; + $this->nativeAttributeConditionBuilder = $nativeAttributeConditionBuilder; + } + + /** + * Decides which condition builder should be used for passed filter + * can be either EAV attribute builder or native attribute builder + * "native" attribute means attribute that is in catalog_product_entity table + * + * @param Filter $filter + * @return CustomConditionInterface + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function createByFilter(Filter $filter): CustomConditionInterface + { + $attribute = $this->getAttributeByCode($filter->getField()); + + if ($attribute->getBackendTable() === $this->productResource->getEntityTable()) { + return $this->nativeAttributeConditionBuilder; + } + + return $this->eavAttributeConditionBuilder; + } + + /** + * @param string $field + * @return \Magento\Catalog\Model\ResourceModel\Eav\Attribute + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getAttributeByCode(string $field): Attribute + { + return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $field); + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/NativeAttributeCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/NativeAttributeCondition.php new file mode 100644 index 0000000000000..d072acf4c719c --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/NativeAttributeCondition.php @@ -0,0 +1,100 @@ +resourceConnection = $resourceConnection; + } + + /** + * Build condition to filter product collection by product native attribute + * "native" attribute means attribute that is in catalog_product_entity table + * + * @param Filter $filter + * @return string + * @throws \DomainException + */ + public function build(Filter $filter): string + { + $conditionType = $this->mapConditionType($filter->getConditionType(), $filter->getField()); + $conditionValue = $this->mapConditionValue($conditionType, $filter->getValue()); + + return $this->resourceConnection + ->getConnection() + ->prepareSqlCondition( + Collection::MAIN_TABLE_ALIAS . '.' . $filter->getField(), + [ + $conditionType => $conditionValue + ] + ); + } + + /** + * Map equal and not equal conditions to in and not in + * + * @param string $conditionType + * @param string $field + * @return mixed + */ + private function mapConditionType(string $conditionType, string $field): string + { + if (strtolower($field) === ProductInterface::SKU) { + $conditionsMap = [ + 'eq' => 'like', + 'neq' => 'nlike' + ]; + } else { + $conditionsMap = [ + 'eq' => 'in', + 'neq' => 'nin' + ]; + } + + return isset($conditionsMap[$conditionType]) ? $conditionsMap[$conditionType] : $conditionType; + } + + /** + * Wraps value with '%' if condition type is 'like' or 'not like' + * + * @param string $conditionType + * @param string $conditionValue + * @return string + */ + private function mapConditionValue(string $conditionType, string $conditionValue): string + { + $conditionsMap = ['like', 'nlike']; + + if (in_array($conditionType, $conditionsMap)) { + $conditionValue = '%' . $conditionValue . '%'; + } + + return $conditionValue; + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/DefaultCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/DefaultCondition.php new file mode 100644 index 0000000000000..14685d87762c2 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/DefaultCondition.php @@ -0,0 +1,45 @@ +conditionBuilderFactory = $conditionBuilderFactory; + } + + /** + * Builds condition to filter product collection either by EAV or by native attribute + * + * @param Filter $filter + * @return string + */ + public function build(Filter $filter): string + { + $filterBuilder = $this->conditionBuilderFactory->createByFilter($filter); + + return $filterBuilder->build($filter); + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php new file mode 100644 index 0000000000000..f70bab73d0830 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php @@ -0,0 +1,126 @@ +resourceConnection = $resourceConnection; + $this->categoryRepository = $categoryRepository; + } + + /** + * Builds condition to filter product collection by categories + * + * @param Filter $filter + * @return string + */ + public function build(Filter $filter): string + { + $categorySelect = $this->resourceConnection->getConnection()->select() + ->from( + ['cat' => $this->resourceConnection->getTableName('catalog_category_product')], + 'cat.product_id' + )->where( + $this->resourceConnection->getConnection()->prepareSqlCondition( + 'cat.category_id', + [$this->mapConditionType($filter->getConditionType()) => $this->getCategoryIds($filter)] + ) + ); + + $selectCondition = [ + 'in' => $categorySelect + ]; + + return $this->resourceConnection->getConnection() + ->prepareSqlCondition(Collection::MAIN_TABLE_ALIAS . '.entity_id', $selectCondition); + } + + /** + * Extracts required category ids from Filter + * If category is anchor all children categories will be included too + * If category is root all children categories will be included too + * + * @param Filter $filter + * @return array + */ + private function getCategoryIds(Filter $filter): array + { + $categoryIds = explode(',', $filter->getValue()); + $childCategoryIds = []; + + foreach ($categoryIds as $categoryId) { + try { + $category = $this->categoryRepository->get($categoryId); + } catch (CategoryDoesNotExistException $exception) { + continue; + } + + if ($category->getIsAnchor()) { + $childCategoryIds[] = $category->getAllChildren(true); + } + + // This is the simplest way to check if category is root + if ((int)$category->getLevel() === $this->rootCategoryLevel) { + $childCategoryIds[] = $category->getAllChildren(true); + } + } + + return array_unique(array_merge($categoryIds, ...$childCategoryIds)); + } + + /** + * Map equal and not equal conditions to in and not in + * + * @param string $conditionType + * @return string + */ + private function mapConditionType(string $conditionType): string + { + $conditionsMap = [ + 'eq' => 'in', + 'neq' => 'nin', + 'like' => 'in', + 'nlike' => 'nin', + ]; + return $conditionsMap[$conditionType] ?? $conditionType; + } +} diff --git a/app/code/Magento/Catalog/Model/Category.php b/app/code/Magento/Catalog/Model/Category.php index 7a481439e5586..4f605d0206264 100644 --- a/app/code/Magento/Catalog/Model/Category.php +++ b/app/code/Magento/Catalog/Model/Category.php @@ -954,8 +954,11 @@ public function getAnchorsAbove() */ public function getProductCount() { - $count = $this->_getResource()->getProductCount($this); - $this->setData(self::KEY_PRODUCT_COUNT, $count); + if (!$this->hasData(self::KEY_PRODUCT_COUNT)) { + $count = $this->_getResource()->getProductCount($this); + $this->setData(self::KEY_PRODUCT_COUNT, $count); + } + return $this->getData(self::KEY_PRODUCT_COUNT); } diff --git a/app/code/Magento/Catalog/Model/ImageExtractor.php b/app/code/Magento/Catalog/Model/ImageExtractor.php index 7888d8de1c2ff..d2c11a3762961 100644 --- a/app/code/Magento/Catalog/Model/ImageExtractor.php +++ b/app/code/Magento/Catalog/Model/ImageExtractor.php @@ -37,7 +37,7 @@ public function process(\DOMElement $mediaNode, $mediaParentTag) } elseif ($attributeTagName === 'width' || $attributeTagName === 'height') { $nodeValue = intval($attribute->nodeValue); } else { - $nodeValue = $attribute->nodeValue; + $nodeValue = !in_array($attribute->nodeValue, ['false', '0']); } $result[$mediaParentTag][$moduleNameImage][Image::MEDIA_TYPE_CONFIG_NODE][$imageId][$attribute->tagName] = $nodeValue; diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php index 9f074c013480c..6a499883ac723 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php @@ -6,9 +6,14 @@ namespace Magento\Catalog\Model\Indexer\Category\Product; -use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Framework\DB\Select; use Magento\Framework\EntityManager\MetadataPool; +use Magento\Store\Model\Store; /** * Class AbstractAction @@ -37,27 +42,28 @@ abstract class AbstractAction /** * Suffix for table to show it is temporary + * @deprecated */ const TEMPORARY_TABLE_SUFFIX = '_tmp'; /** * Cached non anchor categories select by store id * - * @var \Magento\Framework\DB\Select[] + * @var Select[] */ protected $nonAnchorSelects = []; /** * Cached anchor categories select by store id * - * @var \Magento\Framework\DB\Select[] + * @var Select[] */ protected $anchorSelects = []; /** * Cached all product select by store id * - * @var \Magento\Framework\DB\Select[] + * @var Select[] */ protected $productsSelects = []; @@ -101,6 +107,11 @@ abstract class AbstractAction */ protected $metadataPool; + /** + * @var TableMaintainer + */ + protected $tableMaintainer; + /** * @var string * @since 101.0.0 @@ -117,19 +128,24 @@ abstract class AbstractAction * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Catalog\Model\Config $config * @param QueryGenerator $queryGenerator + * @param MetadataPool|null $metadataPool + * @param TableMaintainer|null $tableMaintainer */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Catalog\Model\Config $config, - QueryGenerator $queryGenerator = null + QueryGenerator $queryGenerator = null, + MetadataPool $metadataPool = null, + TableMaintainer $tableMaintainer = null ) { $this->resource = $resource; $this->connection = $resource->getConnection(); $this->storeManager = $storeManager; $this->config = $config; - $this->queryGenerator = $queryGenerator ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(QueryGenerator::class); + $this->queryGenerator = $queryGenerator ?: ObjectManager::getInstance()->get(QueryGenerator::class); + $this->metadataPool = $metadataPool ?: ObjectManager::getInstance()->get(MetadataPool::class); + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -173,6 +189,7 @@ protected function getTable($table) * The name is switched between 'catalog_category_product_index' and 'catalog_category_product_index_replica' * * @return string + * @deprecated */ protected function getMainTable() { @@ -183,12 +200,26 @@ protected function getMainTable() * Return temporary index table name * * @return string + * @deprecated */ protected function getMainTmpTable() { - return $this->useTempTable ? $this->getTable( - self::MAIN_INDEX_TABLE . self::TEMPORARY_TABLE_SUFFIX - ) : $this->getMainTable(); + return $this->useTempTable + ? $this->getTable(self::MAIN_INDEX_TABLE . self::TEMPORARY_TABLE_SUFFIX) + : $this->getMainTable(); + } + + /** + * Return index table name + * + * @param int $storeId + * @return string + */ + protected function getIndexTable($storeId) + { + return $this->useTempTable + ? $this->tableMaintainer->getMainReplicaTable($storeId) + : $this->tableMaintainer->getMainTable($storeId); } /** @@ -216,24 +247,25 @@ protected function getPathFromCategoryId($categoryId) /** * Retrieve select for reindex products of non anchor categories * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select + * @throws \Exception when metadata not found for ProductInterface */ - protected function getNonAnchorCategoriesSelect(\Magento\Store\Model\Store $store) + protected function getNonAnchorCategoriesSelect(Store $store) { if (!isset($this->nonAnchorSelects[$store->getId()])) { $statusAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, + Product::ENTITY, 'status' )->getId(); $visibilityAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, + Product::ENTITY, 'visibility' )->getId(); $rootPath = $this->getPathFromCategoryId($store->getRootCategoryId()); - $metadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); $linkField = $metadata->getLinkField(); $select = $this->connection->select()->from( ['cc' => $this->getTable('catalog_category_entity')], @@ -302,12 +334,65 @@ protected function getNonAnchorCategoriesSelect(\Magento\Store\Model\Store $stor ] ); + $this->addFilteringByChildProductsToSelect($select, $store); + $this->nonAnchorSelects[$store->getId()] = $select; } return $this->nonAnchorSelects[$store->getId()]; } + /** + * Add filtering by child products to select + * + * It's used for correct handling of composite products. + * This method makes assumption that select already joins `catalog_product_entity` as `cpe`. + * + * @param Select $select + * @param Store $store + * @return void + * @throws \Exception when metadata not found for ProductInterface + */ + private function addFilteringByChildProductsToSelect(Select $select, Store $store) + { + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); + $linkField = $metadata->getLinkField(); + + $statusAttributeId = $this->config->getAttribute(Product::ENTITY, 'status')->getId(); + + $select->joinLeft( + ['relation' => $this->getTable('catalog_product_relation')], + 'cpe.' . $linkField . ' = relation.parent_id', + [] + )->joinLeft( + ['relation_product_entity' => $this->getTable('catalog_product_entity')], + 'relation.child_id = relation_product_entity.entity_id', + [] + )->joinLeft( + ['child_cpsd' => $this->getTable('catalog_product_entity_int')], + 'child_cpsd.' . $linkField . ' = '. 'relation_product_entity.' . $linkField + . ' AND child_cpsd.store_id = 0' + . ' AND child_cpsd.attribute_id = ' . $statusAttributeId, + [] + )->joinLeft( + ['child_cpss' => $this->getTable('catalog_product_entity_int')], + 'child_cpss.' . $linkField . ' = '. 'relation_product_entity.' . $linkField . '' + . ' AND child_cpss.attribute_id = child_cpsd.attribute_id' + . ' AND child_cpss.store_id = ' . $store->getId(), + [] + )->where( + 'relation.child_id IS NULL OR ' + . $this->connection->getIfNullSql('child_cpss.value', 'child_cpsd.value') . ' = ?', + \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED + )->group( + [ + 'cc.entity_id', + 'ccp.product_id', + 'visibility', + ] + ); + } + /** * Check whether select ranging is needed * @@ -321,15 +406,15 @@ protected function isRangingNeeded() /** * Return selects cut by min and max * - * @param \Magento\Framework\DB\Select $select + * @param Select $select * @param string $field * @param int $range - * @return \Magento\Framework\DB\Select[] + * @return Select[] */ protected function prepareSelectsByRange( - \Magento\Framework\DB\Select $select, - $field, - $range = self::RANGE_CATEGORY_STEP + Select $select, + string $field, + int $range = self::RANGE_CATEGORY_STEP ) { if ($this->isRangingNeeded()) { $iterator = $this->queryGenerator->generate( @@ -351,17 +436,17 @@ protected function prepareSelectsByRange( /** * Reindex products of non anchor categories * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return void */ - protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) + protected function reindexNonAnchorCategories(Store $store) { $selects = $this->prepareSelectsByRange($this->getNonAnchorCategoriesSelect($store), 'entity_id'); foreach ($selects as $select) { $this->connection->query( $this->connection->insertFromSelect( $select, - $this->getMainTmpTable(), + $this->getIndexTable($store->getId()), ['category_id', 'product_id', 'position', 'is_parent', 'store_id', 'visibility'], \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) @@ -372,10 +457,10 @@ protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) /** * Check if anchor select isset * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return bool */ - protected function hasAnchorSelect(\Magento\Store\Model\Store $store) + protected function hasAnchorSelect(Store $store) { return isset($this->anchorSelects[$store->getId()]); } @@ -383,32 +468,30 @@ protected function hasAnchorSelect(\Magento\Store\Model\Store $store) /** * Create anchor select * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select + * @throws \Exception when metadata not found for ProductInterface or CategoryInterface * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function createAnchorSelect(\Magento\Store\Model\Store $store) + protected function createAnchorSelect(Store $store) { $isAnchorAttributeId = $this->config->getAttribute( \Magento\Catalog\Model\Category::ENTITY, 'is_anchor' )->getId(); - $statusAttributeId = $this->config->getAttribute(\Magento\Catalog\Model\Product::ENTITY, 'status')->getId(); - $visibilityAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, - 'visibility' - )->getId(); + $statusAttributeId = $this->config->getAttribute(Product::ENTITY, 'status')->getId(); + $visibilityAttributeId = $this->config->getAttribute(Product::ENTITY, 'visibility')->getId(); $rootCatIds = explode('/', $this->getPathFromCategoryId($store->getRootCategoryId())); array_pop($rootCatIds); $temporaryTreeTable = $this->makeTempCategoryTreeIndex(); - $productMetadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - $categoryMetadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\CategoryInterface::class); + $productMetadata = $this->metadataPool->getMetadata(ProductInterface::class); + $categoryMetadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\CategoryInterface::class); $productLinkField = $productMetadata->getLinkField(); $categoryLinkField = $categoryMetadata->getLinkField(); - return $this->connection->select()->from( + $select = $this->connection->select()->from( ['cc' => $this->getTable('catalog_category_entity')], [] )->joinInner( @@ -496,6 +579,10 @@ protected function createAnchorSelect(\Magento\Store\Model\Store $store) 'visibility' => new \Zend_Db_Expr($this->connection->getIfNullSql('cpvs.value', 'cpvd.value')), ] ); + + $this->addFilteringByChildProductsToSelect($select, $store); + + return $select; } /** @@ -510,7 +597,7 @@ protected function getTemporaryTreeIndexTableName() if (empty($this->tempTreeIndexTableName)) { $this->tempTreeIndexTableName = $this->connection->getTableName('temp_catalog_category_tree_index') . '_' - . substr(md5(time() . rand(0, 999999999)), 0, 8); + . substr(md5(time() . random_int(0, 999999999)), 0, 8); } return $this->tempTreeIndexTableName; @@ -549,6 +636,12 @@ protected function makeTempCategoryTreeIndex() ['type' => \Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_PRIMARY] ); + $temporaryTable->addIndex( + 'child_id', + ['child_id'], + ['type' => \Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_INDEX] + ); + // Drop the temporary table in case it already exists on this (persistent?) connection. $this->connection->dropTemporaryTable($temporaryName); $this->connection->createTemporaryTable($temporaryTable); @@ -566,34 +659,39 @@ protected function makeTempCategoryTreeIndex() */ protected function fillTempCategoryTreeIndex($temporaryName) { - // This finds all children (cc2) that descend from a parent (cc) by path. - // For example, cc.path may be '1/2', and cc2.path may be '1/2/3/4/5'. - $temporarySelect = $this->connection->select()->from( - ['cc' => $this->getTable('catalog_category_entity')], - ['parent_id' => 'entity_id'] - )->joinInner( - ['cc2' => $this->getTable('catalog_category_entity')], - 'cc2.path LIKE ' . $this->connection->getConcatSql( - [$this->connection->quoteIdentifier('cc.path'), $this->connection->quote('/%')] - ), - ['child_id' => 'entity_id'] + $selects = $this->prepareSelectsByRange( + $this->connection->select() + ->from( + ['c' => $this->getTable('catalog_category_entity')], + ['entity_id', 'path'] + ), + 'entity_id' ); - $this->connection->query( - $temporarySelect->insertFromSelect( - $temporaryName, - ['parent_id', 'child_id'] - ) - ); + foreach ($selects as $select) { + $values = []; + + foreach ($this->connection->fetchAll($select) as $category) { + foreach (explode('/', $category['path']) as $parentId) { + if ($parentId !== $category['entity_id']) { + $values[] = [$parentId, $category['entity_id']]; + } + } + } + + if (count($values) > 0) { + $this->connection->insertArray($temporaryName, ['parent_id', 'child_id'], $values); + } + } } /** * Retrieve select for reindex products of non anchor categories * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select */ - protected function getAnchorCategoriesSelect(\Magento\Store\Model\Store $store) + protected function getAnchorCategoriesSelect(Store $store) { if (!$this->hasAnchorSelect($store)) { $this->anchorSelects[$store->getId()] = $this->createAnchorSelect($store); @@ -604,10 +702,10 @@ protected function getAnchorCategoriesSelect(\Magento\Store\Model\Store $store) /** * Reindex products of anchor categories * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return void */ - protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) + protected function reindexAnchorCategories(Store $store) { $selects = $this->prepareSelectsByRange($this->getAnchorCategoriesSelect($store), 'entity_id'); @@ -615,7 +713,7 @@ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) $this->connection->query( $this->connection->insertFromSelect( $select, - $this->getMainTmpTable(), + $this->getIndexTable($store->getId()), ['category_id', 'product_id', 'position', 'is_parent', 'store_id', 'visibility'], \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) @@ -626,22 +724,17 @@ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) /** * Get select for all products * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select + * @throws \Exception when metadata not found for ProductInterface */ - protected function getAllProducts(\Magento\Store\Model\Store $store) + protected function getAllProducts(Store $store) { if (!isset($this->productsSelects[$store->getId()])) { - $statusAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, - 'status' - )->getId(); - $visibilityAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, - 'visibility' - )->getId(); + $statusAttributeId = $this->config->getAttribute(Product::ENTITY, 'status')->getId(); + $visibilityAttributeId = $this->config->getAttribute(Product::ENTITY, 'visibility')->getId(); - $metadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); $linkField = $metadata->getLinkField(); $select = $this->connection->select()->from( @@ -730,10 +823,10 @@ protected function isIndexRootCategoryNeeded() /** * Reindex all products to root category * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return void */ - protected function reindexRootCategory(\Magento\Store\Model\Store $store) + protected function reindexRootCategory(Store $store) { if ($this->isIndexRootCategoryNeeded()) { $selects = $this->prepareSelectsByRange( @@ -746,7 +839,7 @@ protected function reindexRootCategory(\Magento\Store\Model\Store $store) $this->connection->query( $this->connection->insertFromSelect( $select, - $this->getMainTmpTable(), + $this->getIndexTable($store->getId()), ['category_id', 'product_id', 'position', 'is_parent', 'store_id', 'visibility'], \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) @@ -754,16 +847,4 @@ protected function reindexRootCategory(\Magento\Store\Model\Store $store) } } } - - /** - * @return \Magento\Framework\EntityManager\MetadataPool - */ - private function getMetadataPool() - { - if (null === $this->metadataPool) { - $this->metadataPool = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Framework\EntityManager\MetadataPool::class); - } - return $this->metadataPool; - } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php index cde186a7e44af..09dbed350c5e4 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php @@ -88,18 +88,35 @@ public function __construct( } /** - * - * Clear the table we'll be writing de-normalized data into - * to prevent archived data getting in the way of actual data. - * * @return void */ - private function clearCurrentTable() + private function createTables() { - $this->connection->delete( - $this->activeTableSwitcher - ->getAdditionalTableName($this->getMainTable()) - ); + foreach ($this->storeManager->getStores() as $store) { + $this->tableMaintainer->createTablesForStore($store->getId()); + } + } + + /** + * @return void + */ + private function clearReplicaTables() + { + foreach ($this->storeManager->getStores() as $store) { + $this->connection->truncateTable($this->tableMaintainer->getMainReplicaTable($store->getId())); + } + } + + /** + * @return void + */ + private function switchTables() + { + $tablesToSwitch = []; + foreach ($this->storeManager->getStores() as $store) { + $tablesToSwitch[] = $this->tableMaintainer->getMainTable($store->getId()); + } + $this->activeTableSwitcher->switchTable($this->connection, $tablesToSwitch); } /** @@ -109,22 +126,26 @@ private function clearCurrentTable() */ public function execute() { - $this->clearCurrentTable(); + $this->createTables(); + $this->clearReplicaTables(); $this->reindex(); - $this->activeTableSwitcher->switchTable($this->connection, [$this->getMainTable()]); + $this->switchTables(); return $this; } /** - * Publish data from tmp to index + * Publish data from tmp to replica table * + * @param \Magento\Store\Model\Store $store * @return void */ - protected function publishData() + private function publishData($store) { - $select = $this->connection->select()->from($this->getMainTmpTable()); - $columns = array_keys($this->connection->describeTable($this->getMainTable())); - $tableName = $this->activeTableSwitcher->getAdditionalTableName($this->getMainTable()); + $select = $this->connection->select()->from($this->tableMaintainer->getMainTmpTable($store->getId())); + $columns = array_keys( + $this->connection->describeTable($this->tableMaintainer->getMainReplicaTable($store->getId())) + ); + $tableName = $this->tableMaintainer->getMainReplicaTable($store->getId()); $this->connection->query( $this->connection->insertFromSelect( @@ -136,23 +157,13 @@ protected function publishData() ); } - /** - * Clear all index data - * - * @return void - */ - protected function clearTmpData() - { - $this->connection->delete($this->getMainTmpTable()); - } - /** * {@inheritdoc} */ protected function reindexRootCategory(\Magento\Store\Model\Store $store) { if ($this->isIndexRootCategoryNeeded()) { - $this->reindexCategoriesBySelect($this->getAllProducts($store), 'cp.entity_id IN (?)'); + $this->reindexCategoriesBySelect($this->getAllProducts($store), 'cp.entity_id IN (?)', $store); } } @@ -164,7 +175,7 @@ protected function reindexRootCategory(\Magento\Store\Model\Store $store) */ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) { - $this->reindexCategoriesBySelect($this->getAnchorCategoriesSelect($store), 'ccp.product_id IN (?)'); + $this->reindexCategoriesBySelect($this->getAnchorCategoriesSelect($store), 'ccp.product_id IN (?)', $store); } /** @@ -175,7 +186,7 @@ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) */ protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) { - $this->reindexCategoriesBySelect($this->getNonAnchorCategoriesSelect($store), 'ccp.product_id IN (?)'); + $this->reindexCategoriesBySelect($this->getNonAnchorCategoriesSelect($store), 'ccp.product_id IN (?)', $store); } /** @@ -183,12 +194,17 @@ protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) * * @param \Magento\Framework\DB\Select $basicSelect * @param string $whereCondition + * @param \Magento\Store\Model\Store $store * @return void */ - private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSelect, $whereCondition) + private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSelect, $whereCondition, $store) { + $this->tableMaintainer->createMainTmpTable($store->getId()); + $entityMetadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - $columns = array_keys($this->connection->describeTable($this->getMainTmpTable())); + $columns = array_keys( + $this->connection->describeTable($this->tableMaintainer->getMainTmpTable($store->getId())) + ); $this->batchSizeManagement->ensureBatchSize($this->connection, $this->batchRowsCount); $batches = $this->batchProvider->getBatches( $this->connection, @@ -197,7 +213,7 @@ private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSe $this->batchRowsCount ); foreach ($batches as $batch) { - $this->clearTmpData(); + $this->connection->delete($this->tableMaintainer->getMainTmpTable($store->getId())); $resultSelect = clone $basicSelect; $select = $this->connection->select(); $select->distinct(true); @@ -207,12 +223,12 @@ private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSe $this->connection->query( $this->connection->insertFromSelect( $resultSelect, - $this->getMainTmpTable(), + $this->tableMaintainer->getMainTmpTable($store->getId()), $columns, \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) ); - $this->publishData(); + $this->publishData($store); } } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php index 248ec970d2250..3bd4910767587 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php @@ -36,17 +36,16 @@ public function execute(array $entityIds = [], $useTempTable = false) /** * Return array of all category root IDs + tree root ID * - * @return int[] + * @param \Magento\Store\Model\Store $store + * @return int */ - protected function getRootCategoryIds() + private function getRootCategoryId($store) { - $rootIds = [\Magento\Catalog\Model\Category::TREE_ROOT_ID]; - foreach ($this->storeManager->getStores() as $store) { - if ($this->getPathFromCategoryId($store->getRootCategoryId())) { - $rootIds[] = $store->getRootCategoryId(); - } + $rootId = \Magento\Catalog\Model\Category::TREE_ROOT_ID; + if ($this->getPathFromCategoryId($store->getRootCategoryId())) { + $rootId = $store->getRootCategoryId(); } - return $rootIds; + return $rootId; } /** @@ -54,10 +53,15 @@ protected function getRootCategoryIds() * * @return void */ - protected function removeEntries() + private function removeEntries() { - $removalCategoryIds = array_diff($this->limitationByCategories, $this->getRootCategoryIds()); - $this->connection->delete($this->getMainTable(), ['category_id IN (?)' => $removalCategoryIds]); + foreach ($this->storeManager->getStores() as $store) { + $removalCategoryIds = array_diff($this->limitationByCategories, [$this->getRootCategoryId($store)]); + $this->connection->delete( + $this->getIndexTable($store->getId()), + ['category_id IN (?)' => $removalCategoryIds] + ); + } } /** diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php index 2ee46b3a6096b..9f4e19bf95a8d 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php @@ -9,6 +9,7 @@ use Magento\Framework\Model\ResourceModel\Db\AbstractDb; use Magento\Framework\Model\AbstractModel; use Magento\Catalog\Model\Indexer\Category\Product; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; class StoreGroup { @@ -22,12 +23,21 @@ class StoreGroup */ protected $indexerRegistry; + /** + * @var TableMaintainer + */ + protected $tableMaintainer; + /** * @param IndexerRegistry $indexerRegistry + * @param TableMaintainer $tableMaintainer */ - public function __construct(IndexerRegistry $indexerRegistry) - { + public function __construct( + IndexerRegistry $indexerRegistry, + TableMaintainer $tableMaintainer + ) { $this->indexerRegistry = $indexerRegistry; + $this->tableMaintainer = $tableMaintainer; } /** @@ -73,4 +83,22 @@ protected function validate(AbstractModel $group) return ($group->dataHasChangedFor('website_id') || $group->dataHasChangedFor('root_category_id')) && !$group->isObjectNew(); } + + /** + * Delete catalog_category_product indexer tables for deleted store group + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $storeGroup + * + * @return AbstractDb + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDelete(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $storeGroup) + { + foreach ($storeGroup->getStores() as $store) { + $this->tableMaintainer->dropTablesForStore($store->getId()); + } + return $objectResource; + } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php index f49b685ba6f7f..114d2a94f5b35 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php @@ -5,6 +5,9 @@ */ namespace Magento\Catalog\Model\Indexer\Category\Product\Plugin; +use Magento\Framework\Model\ResourceModel\Db\AbstractDb; +use Magento\Framework\Model\AbstractModel; + class StoreView extends StoreGroup { /** @@ -17,4 +20,38 @@ protected function validate(\Magento\Framework\Model\AbstractModel $store) { return $store->isObjectNew() || $store->dataHasChangedFor('group_id'); } + + /** + * Invalidate catalog_category_product indexer + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $store + * + * @return AbstractDb + */ + public function afterSave(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $store = null) + { + if ($store->isObjectNew()) { + $this->tableMaintainer->createTablesForStore($store->getId()); + } + + return parent::afterSave($subject, $objectResource); + } + + /** + * Delete catalog_category_product indexer table for deleted store + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $store + * + * @return AbstractDb + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDelete(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $store) + { + $this->tableMaintainer->dropTablesForStore($store->getId()); + return $objectResource; + } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php new file mode 100644 index 0000000000000..936e6163cbcc5 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php @@ -0,0 +1,74 @@ +storeManager = $storeManager; + $this->tableResolver = $tableResolver; + } + + /** + * Replacing catalog_category_product_index table name on the table name segmented per store + * + * @param ResourceConnection $subject + * @param string $result + * @param string|string[] $modelEntity + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @return string + */ + public function afterGetTableName( + \Magento\Framework\App\ResourceConnection $subject, + string $result, + $modelEntity + ) { + if (!is_array($modelEntity) && $modelEntity === AbstractAction::MAIN_INDEX_TABLE) { + $catalogCategoryProductDimension = new Dimension( + \Magento\Store\Model\Store::ENTITY, + $this->storeManager->getStore()->getId() + ); + + $tableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); + return $tableName; + } + return $result; + } +} diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/Website.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/Website.php new file mode 100644 index 0000000000000..387a8085310e4 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/Website.php @@ -0,0 +1,46 @@ +tableMaintainer = $tableMaintainer; + } + + /** + * Delete catalog_category_product indexer tables for deleted website + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $website + * + * @return AbstractDb + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDelete(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $website) + { + foreach ($website->getStoreIds() as $storeId) { + $this->tableMaintainer->dropTablesForStore($storeId); + } + return $objectResource; + } +} diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/TableMaintainer.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/TableMaintainer.php new file mode 100644 index 0000000000000..1278434fcad43 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/TableMaintainer.php @@ -0,0 +1,218 @@ +resource = $resource; + $this->tableResolver = $tableResolver; + } + + /** + * Get connection + * + * @return AdapterInterface + */ + private function getConnection() + { + if (!isset($this->connection)) { + $this->connection = $this->resource->getConnection(); + } + return $this->connection; + } + + /** + * Return validated table name + * + * @param string|string[] $table + * @return string + */ + private function getTable($table) + { + return $this->resource->getTableName($table); + } + + /** + * Create table based on main table + * + * @param string $mainTableName + * @param string $newTableName + * + * @return void + */ + private function createTable($mainTableName, $newTableName) + { + if (!$this->getConnection()->isTableExists($newTableName)) { + $this->getConnection()->createTable( + $this->getConnection()->createTableByDdl($mainTableName, $newTableName) + ); + } + } + + /** + * Drop table + * + * @param string $tableName + * + * @return void + */ + private function dropTable($tableName) + { + if ($this->getConnection()->isTableExists($tableName)) { + $this->getConnection()->dropTable($tableName); + } + } + + /** + * Return main index table name + * + * @param $storeId + * + * @return string + */ + public function getMainTable(int $storeId) + { + $catalogCategoryProductDimension = new Dimension(\Magento\Store\Model\Store::ENTITY, $storeId); + + return $this->tableResolver->resolve(AbstractAction::MAIN_INDEX_TABLE, [$catalogCategoryProductDimension]); + } + + /** + * Create main and replica index tables for store + * + * @param $storeId + * + * @return void + */ + public function createTablesForStore(int $storeId) + { + $mainTableName = $this->getMainTable($storeId); + //Create index table for store based on on main replica table + //Using main replica table is necessary for backward capability and TableResolver plugin work + $this->createTable( + $this->getTable(AbstractAction::MAIN_INDEX_TABLE . $this->additionalTableSuffix), + $mainTableName + ); + + $mainReplicaTableName = $this->getMainTable($storeId) . $this->additionalTableSuffix; + //Create replica table for store based on main replica table + $this->createTable( + $this->getTable(AbstractAction::MAIN_INDEX_TABLE . $this->additionalTableSuffix), + $mainReplicaTableName + ); + } + + /** + * Drop main and replica index tables for store + * + * @param $storeId + * + * @return void + */ + public function dropTablesForStore(int $storeId) + { + $mainTableName = $this->getMainTable($storeId); + $this->dropTable($mainTableName); + + $mainReplicaTableName = $this->getMainTable($storeId) . $this->additionalTableSuffix; + $this->dropTable($mainReplicaTableName); + } + + /** + * Return replica index table name + * + * @param $storeId + * + * @return string + */ + public function getMainReplicaTable(int $storeId) + { + return $this->getMainTable($storeId) . $this->additionalTableSuffix; + } + + /** + * Create temporary index table for store + * + * @param $storeId + * + * @return void + */ + public function createMainTmpTable(int $storeId) + { + if (!isset($this->mainTmpTable[$storeId])) { + $originTableName = $this->getMainTable($storeId); + $temporaryTableName = $this->getMainTable($storeId) . $this->tmpTableSuffix; + $this->getConnection()->createTemporaryTableLike($temporaryTableName, $originTableName, true); + $this->mainTmpTable[$storeId] = $temporaryTableName; + } + } + + /** + * Return temporary index table name + * + * @param $storeId + * + * @return string + * + * @throws \Exception + */ + public function getMainTmpTable(int $storeId) + { + if (!isset($this->mainTmpTable[$storeId])) { + throw new \Exception('Temporary table does not exist'); + } + return $this->mainTmpTable[$storeId]; + } +} diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php index 1b988534328e9..182f04de4ab0e 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php @@ -6,9 +6,19 @@ namespace Magento\Catalog\Model\Indexer\Product\Category\Action; use Magento\Catalog\Model\Category; +use Magento\Catalog\Model\Config; use Magento\Catalog\Model\Product; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Event\ManagerInterface as EventManagerInterface; use Magento\Framework\Indexer\CacheContext; +use Magento\Store\Model\StoreManagerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractAction { /** @@ -19,32 +29,102 @@ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractActio protected $limitationByProducts; /** - * @var \Magento\Framework\Indexer\CacheContext + * @var CacheContext */ private $cacheContext; + /** + * @var EventManagerInterface|null + */ + private $eventManager; + + /** + * @param ResourceConnection $resource + * @param StoreManagerInterface $storeManager + * @param Config $config + * @param QueryGenerator|null $queryGenerator + * @param MetadataPool|null $metadataPool + * @param CacheContext|null $cacheContext + * @param EventManagerInterface|null $eventManager + */ + public function __construct( + ResourceConnection $resource, + StoreManagerInterface $storeManager, + Config $config, + QueryGenerator $queryGenerator = null, + MetadataPool $metadataPool = null, + CacheContext $cacheContext = null, + EventManagerInterface $eventManager = null + ) { + parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool); + $this->cacheContext = $cacheContext ?: ObjectManager::getInstance()->get(CacheContext::class); + $this->eventManager = $eventManager ?: ObjectManager::getInstance()->get(EventManagerInterface::class); + } + /** * Refresh entities index * * @param int[] $entityIds * @param bool $useTempTable * @return $this + * @throws \Exception if metadataPool doesn't contain metadata for ProductInterface + * @throws \DomainException */ public function execute(array $entityIds = [], $useTempTable = false) { - $this->limitationByProducts = $entityIds; + $idsToBeReIndexed = $this->getProductIdsWithParents($entityIds); + + $this->limitationByProducts = $idsToBeReIndexed; $this->useTempTable = $useTempTable; + $affectedCategories = $this->getCategoryIdsFromIndex($idsToBeReIndexed); + $this->removeEntries(); $this->reindex(); - $this->registerProducts($entityIds); - $this->registerCategories($entityIds); + $affectedCategories = array_merge($affectedCategories, $this->getCategoryIdsFromIndex($idsToBeReIndexed)); + + $this->registerProducts($idsToBeReIndexed); + $this->registerCategories($affectedCategories); + $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]); return $this; } + /** + * Get IDs of parent products by their child IDs. + * + * Returns identifiers of parent product from the catalog_product_relation. + * Please note that returned ids don't contain ids of passed child products. + * + * @param int[] $childProductIds + * @return int[] + * @throws \Exception if metadataPool doesn't contain metadata for ProductInterface + * @throws \DomainException + */ + private function getProductIdsWithParents(array $childProductIds) + { + /** @var \Magento\Framework\EntityManager\EntityMetadataInterface $metadata */ + $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + $fieldForParent = $metadata->getLinkField(); + + $select = $this->connection + ->select() + ->from(['relation' => $this->getTable('catalog_product_relation')], []) + ->distinct(true) + ->where('child_id IN (?)', $childProductIds) + ->join( + ['cpe' => $this->getTable('catalog_product_entity')], + 'relation.parent_id = cpe.' . $fieldForParent, + ['cpe.entity_id'] + ); + + $parentProductIds = $this->connection->fetchCol($select); + + return array_unique(array_merge($childProductIds, $parentProductIds)); + } + /** * Register affected products * @@ -53,26 +133,19 @@ public function execute(array $entityIds = [], $useTempTable = false) */ private function registerProducts($entityIds) { - $this->getCacheContext()->registerEntities(Product::CACHE_TAG, $entityIds); + $this->cacheContext->registerEntities(Product::CACHE_TAG, $entityIds); } /** * Register categories assigned to products * - * @param array $entityIds + * @param array $categoryIds * @return void */ - private function registerCategories($entityIds) + private function registerCategories(array $categoryIds) { - $categories = $this->connection->fetchCol( - $this->connection->select() - ->from($this->getMainTable(), ['category_id']) - ->where('product_id IN (?)', $entityIds) - ->distinct() - ); - - if ($categories) { - $this->getCacheContext()->registerEntities(Category::CACHE_TAG, $categories); + if ($categoryIds) { + $this->cacheContext->registerEntities(Category::CACHE_TAG, $categoryIds); } } @@ -83,10 +156,12 @@ private function registerCategories($entityIds) */ protected function removeEntries() { - $this->connection->delete( - $this->getMainTable(), - ['product_id IN (?)' => $this->limitationByProducts] - ); + foreach ($this->storeManager->getStores() as $store) { + $this->connection->delete( + $this->getIndexTable($store->getId()), + ['product_id IN (?)' => $this->limitationByProducts] + ); + }; } /** @@ -98,7 +173,7 @@ protected function removeEntries() protected function getNonAnchorCategoriesSelect(\Magento\Store\Model\Store $store) { $select = parent::getNonAnchorCategoriesSelect($store); - return $select->where('ccp.product_id IN (?)', $this->limitationByProducts); + return $select->where('ccp.product_id IN (?) OR relation.child_id IN (?)', $this->limitationByProducts); } /** @@ -136,16 +211,31 @@ protected function isRangingNeeded() } /** - * Get cache context + * Returns a list of category ids which are assigned to product ids in the index * * @return \Magento\Framework\Indexer\CacheContext - * @deprecated 101.0.0 */ - private function getCacheContext() + private function getCategoryIdsFromIndex(array $productIds) { - if ($this->cacheContext === null) { - $this->cacheContext = \Magento\Framework\App\ObjectManager::getInstance()->get(CacheContext::class); + $categoryIds = []; + foreach ($this->storeManager->getStores() as $store) { + $categoryIds = array_merge( + $categoryIds, + $this->connection->fetchCol( + $this->connection->select() + ->from($this->getIndexTable($store->getId()), ['category_id']) + ->where('product_id IN (?)', $productIds) + ->distinct() + ) + ); + }; + $parentCategories = $categoryIds; + foreach ($categoryIds as $categoryId) { + $parentIds = explode('/', $this->getPathFromCategoryId($categoryId)); + $parentCategories = array_merge($parentCategories, $parentIds); } - return $this->cacheContext; + $categoryIds = array_unique($parentCategories); + + return $categoryIds; } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php b/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php index 6a2642a8568f4..b6206f96b91e0 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php @@ -5,6 +5,8 @@ */ namespace Magento\Catalog\Model\Indexer\Product\Eav; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav; + /** * Abstract action reindex class */ @@ -51,7 +53,7 @@ abstract public function execute($ids); /** * Retrieve array of EAV type indexers * - * @return \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav[] + * @return AbstractEav[] */ public function getIndexers() { @@ -69,7 +71,7 @@ public function getIndexers() * Retrieve indexer instance by type * * @param string $type - * @return \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav + * @return AbstractEav * @throws \Magento\Framework\Exception\LocalizedException */ public function getIndexer($type) @@ -108,7 +110,7 @@ public function reindex($ids = null) /** * Synchronize data between index storage and original storage * - * @param \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav $indexer + * @param AbstractEav $indexer * @param string $destinationTable * @param array $ids * @throws \Exception @@ -134,16 +136,17 @@ protected function syncData($indexer, $destinationTable, $ids) /** * Retrieve product relations by children and parent * - * @param \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav $indexer + * @param AbstractEav $indexer * @param array $ids - * * @param bool $onlyParents * @return array $ids */ - protected function processRelations($indexer, $ids, $onlyParents = false) + protected function processRelations(AbstractEav $indexer, array $ids, bool $onlyParents = false) { $parentIds = $indexer->getRelationsByChild($ids); + $parentIds = array_unique(array_merge($parentIds, $ids)); $childIds = $onlyParents ? [] : $indexer->getRelationsByParent($parentIds); + return array_unique(array_merge($ids, $childIds, $parentIds)); } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php b/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php index cfa5ec91a2e1b..7aed842713f5d 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php @@ -336,9 +336,10 @@ protected function _reindexRows($changedIds = []) if (!empty($notCompositeIds)) { $parentProductsTypes = $this->getParentProductsTypes($notCompositeIds); $productsTypes = array_merge_recursive($productsTypes, $parentProductsTypes); - $parentProductsIds = array_keys($parentProductsTypes); - $compositeIds = $compositeIds + array_combine($parentProductsIds, $parentProductsIds); - $changedIds = array_merge($changedIds, $parentProductsIds); + foreach ($parentProductsTypes as $parentProductsIds) { + $compositeIds = $compositeIds + $parentProductsIds; + $changedIds = array_merge($changedIds, $parentProductsIds); + } } if (!empty($compositeIds)) { @@ -370,7 +371,8 @@ protected function _copyRelationIndexData($parentIds, $excludeIds = null) ['child_id'] )->join( ['e' => $this->_defaultIndexerResource->getTable('catalog_product_entity')], - 'e.' . $linkField . ' = parent_id' + 'e.' . $linkField . ' = parent_id', + [] )->where( 'e.entity_id IN(?)', $parentIds diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Full.php b/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Full.php index eb15833a7d0b2..ba04af8ec1f41 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Full.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Full.php @@ -109,7 +109,7 @@ public function execute($ids = null) // Prepare replica table for indexation. $this->_defaultIndexerResource->getConnection()->truncateTable($replicaTable); - /** @var \Magento\Catalog\Model\ResourceModel\Product\Indexer\AbstractIndexer $indexer */ + /** @var \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\DefaultPrice $indexer */ foreach ($this->getTypeIndexers() as $indexer) { $indexer->getTableStrategy()->setUseIdxTable(false); $connection = $indexer->getConnection(); diff --git a/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php b/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php index 36caa148b2e4e..bb1fce309f4f4 100644 --- a/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php +++ b/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php @@ -56,7 +56,7 @@ public function getRange(FilterInterface $filter) $index = 1; do { $range = pow(10, strlen(floor($maxValue)) - $index); - $items = $this->getRangeItemCounts($range, $filter); + $items = $this->getRangeItemCounts($range, $filter) ?: []; $index++; } while ($range > self::MIN_RANGE_POWER && count($items) < 2); $this->range = $range; @@ -109,7 +109,7 @@ public function getMinValue(FilterInterface $filter) * * @param int $range * @param FilterInterface $filter - * @return mixed + * @return array */ public function getRangeItemCounts($range, FilterInterface $filter) { diff --git a/app/code/Magento/Catalog/Model/Product.php b/app/code/Magento/Catalog/Model/Product.php index f8be5ea644b0c..f514e5c68769e 100644 --- a/app/code/Magento/Catalog/Model/Product.php +++ b/app/code/Magento/Catalog/Model/Product.php @@ -513,19 +513,6 @@ public function getStoreId() return $this->_storeManager->getStore()->getId(); } - /** - * Get collection instance - * - * @return object - * @deprecated 101.1.0 because collections should be used directly via factory - */ - public function getResourceCollection() - { - $collection = parent::getResourceCollection(); - $collection->setStoreId($this->getStoreId()); - return $collection; - } - /** * Get product url model * @@ -625,6 +612,7 @@ public function getUpdatedAt() * * @param bool $calculate * @return void + * @deprecated */ public function setPriceCalculation($calculate = true) { @@ -945,13 +933,6 @@ public function afterSave() $this->_getResource()->addCommitCallback([$this, 'reindex']); $this->reloadPriceInfo(); - // Resize images for catalog product and save results to image cache - /** @var Product\Image\Cache $imageCache */ - if (!$this->_appState->isAreaCodeEmulated()) { - $imageCache = $this->imageCacheFactory->create(); - $imageCache->generate($this); - } - return $result; } @@ -1174,10 +1155,11 @@ public function setFinalPrice($price) */ public function getFinalPrice($qty = null) { - if ($this->_getData('final_price') === null) { - $this->setFinalPrice($this->getPriceModel()->getFinalPrice($qty, $this)); + if ($this->_calculatePrice || $this->_getData('final_price') === null) { + return $this->getPriceModel()->getFinalPrice($qty, $this); + } else { + return $this->_getData('final_price'); } - return $this->_getData('final_price'); } /** @@ -1486,10 +1468,14 @@ public function getMediaAttributeValues() public function getMediaGalleryImages() { $directory = $this->_filesystem->getDirectoryRead(DirectoryList::MEDIA); - if (!$this->hasData('media_gallery_images') && is_array($this->getMediaGallery('images'))) { - $images = $this->_collectionFactory->create(); + if (!$this->hasData('media_gallery_images')) { + $this->setData('media_gallery_images', $this->_collectionFactory->create()); + } + if (!$this->getData('media_gallery_images')->count() && is_array($this->getMediaGallery('images'))) { + $images = $this->getData('media_gallery_images'); foreach ($this->getMediaGallery('images') as $image) { - if ((isset($image['disabled']) && $image['disabled']) + if (!empty($image['disabled']) + || !empty($image['removed']) || empty($image['value_id']) || $images->getItemById($image['value_id']) != null ) { @@ -2109,6 +2095,8 @@ public function reset() /** * Get cache tags associated with object id * + * @deprecated + * @see \Magento\Catalog\Model\Product::getIdentities * @return string[] */ public function getCacheIdTags() @@ -2534,13 +2522,7 @@ public function setTypeId($typeId) */ public function getExtensionAttributes() { - $extensionAttributes = $this->_getExtensionAttributes(); - if (null === $extensionAttributes) { - /** @var \Magento\Catalog\Api\Data\ProductExtensionInterface $extensionAttributes */ - $extensionAttributes = $this->extensionAttributesFactory->create(ProductInterface::class); - $this->setExtensionAttributes($extensionAttributes); - } - return $extensionAttributes; + return $this->_getExtensionAttributes(); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php b/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php index 28e0f22fc6ec9..03b8c7aa1cadc 100644 --- a/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php +++ b/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Model\Product\Attribute\Frontend\Inputtype; use Magento\Catalog\Model\ResourceModel\Eav\Attribute; @@ -19,9 +21,9 @@ class Presentation * Get input type for presentation layer from stored input type. * * @param Attribute $attribute - * @return string + * @return string|null */ - public function getPresentationInputType(Attribute $attribute) + public function getPresentationInputType(Attribute $attribute) :?string { $inputType = $attribute->getFrontendInput(); if ($inputType == 'textarea' && $attribute->getIsWysiwygEnabled()) { @@ -37,12 +39,12 @@ public function getPresentationInputType(Attribute $attribute) * * @return array */ - public function convertPresentationDataToInputType(array $data) + public function convertPresentationDataToInputType(array $data) : array { - if ($data['frontend_input'] === 'texteditor') { + if (isset($data['frontend_input']) && $data['frontend_input'] === 'texteditor') { $data['is_wysiwyg_enabled'] = 1; $data['frontend_input'] = 'textarea'; - } elseif ($data['frontend_input'] === 'textarea') { + } elseif (isset($data['frontend_input']) && $data['frontend_input'] === 'textarea') { $data['is_wysiwyg_enabled'] = 0; } return $data; diff --git a/app/code/Magento/Catalog/Model/Product/Image.php b/app/code/Magento/Catalog/Model/Product/Image.php index 971f34e02f9e5..09ba68ddbe2b2 100644 --- a/app/code/Magento/Catalog/Model/Product/Image.php +++ b/app/code/Magento/Catalog/Model/Product/Image.php @@ -6,10 +6,13 @@ namespace Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Image\NotLoadInfoImageException; +use Magento\Catalog\Model\View\Asset\ImageFactory; +use Magento\Catalog\Model\View\Asset\PlaceholderFactory; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\ObjectManager; use Magento\Framework\Image as MagentoImage; use Magento\Framework\Serialize\SerializerInterface; +use Magento\Catalog\Model\Product\Image\ParamsBuilder; /** * @method string getFile() @@ -159,12 +162,12 @@ class Image extends \Magento\Framework\Model\AbstractModel protected $_storeManager; /** - * @var \Magento\Catalog\Model\View\Asset\ImageFactory + * @var ImageFactory */ private $viewAssetImageFactory; /** - * @var \Magento\Catalog\Model\View\Asset\PlaceholderFactory + * @var PlaceholderFactory */ private $viewAssetPlaceholderFactory; @@ -173,6 +176,11 @@ class Image extends \Magento\Framework\Model\AbstractModel */ private $imageAsset; + /** + * @var ParamsBuilder + */ + private $paramsBuilder; + /** * @var string */ @@ -199,9 +207,10 @@ class Image extends \Magento\Framework\Model\AbstractModel * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data - * @param \Magento\Catalog\Model\View\Asset\ImageFactory|null $viewAssetImageFactory - * @param \Magento\Catalog\Model\View\Asset\PlaceholderFactory|null $viewAssetPlaceholderFactory + * @param ImageFactory|null $viewAssetImageFactory + * @param PlaceholderFactory|null $viewAssetPlaceholderFactory * @param SerializerInterface|null $serializer + * @param ParamsBuilder $paramsBuilder * @SuppressWarnings(PHPMD.ExcessiveParameterList) * @SuppressWarnings(PHPMD.UnusedLocalVariable) */ @@ -215,13 +224,14 @@ public function __construct( \Magento\Framework\Image\Factory $imageFactory, \Magento\Framework\View\Asset\Repository $assetRepo, \Magento\Framework\View\FileSystem $viewFileSystem, + ImageFactory $viewAssetImageFactory, + PlaceholderFactory $viewAssetPlaceholderFactory, \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, array $data = [], - \Magento\Catalog\Model\View\Asset\ImageFactory $viewAssetImageFactory = null, - \Magento\Catalog\Model\View\Asset\PlaceholderFactory $viewAssetPlaceholderFactory = null, - SerializerInterface $serializer = null + SerializerInterface $serializer = null, + ParamsBuilder $paramsBuilder = null ) { $this->_storeManager = $storeManager; $this->_catalogProductMediaConfig = $catalogProductMediaConfig; @@ -232,11 +242,10 @@ public function __construct( $this->_assetRepo = $assetRepo; $this->_viewFileSystem = $viewFileSystem; $this->_scopeConfig = $scopeConfig; - $this->viewAssetImageFactory = $viewAssetImageFactory ?: ObjectManager::getInstance() - ->get(\Magento\Catalog\Model\View\Asset\ImageFactory::class); - $this->viewAssetPlaceholderFactory = $viewAssetPlaceholderFactory ?: ObjectManager::getInstance() - ->get(\Magento\Catalog\Model\View\Asset\PlaceholderFactory::class); + $this->viewAssetImageFactory = $viewAssetImageFactory; + $this->viewAssetPlaceholderFactory = $viewAssetPlaceholderFactory; $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); + $this->paramsBuilder = $paramsBuilder ?: ObjectManager::getInstance()->get(ParamsBuilder::class); } /** @@ -370,25 +379,6 @@ public function setSize($size) return $this; } - /** - * Convert array of 3 items (decimal r, g, b) to string of their hex values - * - * @param int[] $rgbArray - * @return string - */ - protected function _rgbToString($rgbArray) - { - $result = []; - foreach ($rgbArray as $value) { - if (null === $value) { - $result[] = 'null'; - } else { - $result[] = sprintf('%02s', dechex($value)); - } - } - return implode($result); - } - /** * Set filenames for base file and new file * @@ -618,10 +608,8 @@ public function getDestinationSubdir() */ public function isCached() { - return ( - is_array($this->loadImageInfoFromCache($this->imageAsset->getPath())) || - file_exists($this->imageAsset->getPath()) - ); + $path = $this->imageAsset->getPath(); + return is_array($this->loadImageInfoFromCache($path)) || file_exists($path); } /** @@ -826,7 +814,7 @@ public function getResizedImageInfo() $image = $this->imageAsset->getPath(); } - $imageProperties = $this->getimagesize($image); + $imageProperties = $this->getImageSize($image); return $imageProperties; } finally { @@ -844,29 +832,20 @@ public function getResizedImageInfo() */ private function getMiscParams() { - $miscParams = [ - 'image_type' => $this->getDestinationSubdir(), - 'image_height' => $this->getHeight(), - 'image_width' => $this->getWidth(), - 'keep_aspect_ratio' => ($this->_keepAspectRatio ? '' : 'non') . 'proportional', - 'keep_frame' => ($this->_keepFrame ? '' : 'no') . 'frame', - 'keep_transparency' => ($this->_keepTransparency ? '' : 'no') . 'transparency', - 'constrain_only' => ($this->_constrainOnly ? 'do' : 'not') . 'constrainonly', - 'background' => $this->_rgbToString($this->_backgroundColor), - 'angle' => $this->_angle, - 'quality' => $this->_quality, - ]; - - // if has watermark add watermark params to hash - if ($this->getWatermarkFile()) { - $miscParams['watermark_file'] = $this->getWatermarkFile(); - $miscParams['watermark_image_opacity'] = $this->getWatermarkImageOpacity(); - $miscParams['watermark_position'] = $this->getWatermarkPosition(); - $miscParams['watermark_width'] = $this->getWatermarkWidth(); - $miscParams['watermark_height'] = $this->getWatermarkHeight(); - } - - return $miscParams; + return $this->paramsBuilder->build( + [ + 'type' => $this->getDestinationSubdir(), + 'width' => $this->getWidth(), + 'height' => $this->getHeight(), + 'frame' => $this->_keepFrame, + 'constrain' => $this->_constrainOnly, + 'aspect_ratio' => $this->_keepAspectRatio, + 'transparency' => $this->_keepTransparency, + 'background' => $this->_backgroundColor, + 'angle' => $this->_angle, + 'quality' => $this->_quality + ] + ); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Image/ParamsBuilder.php b/app/code/Magento/Catalog/Model/Product/Image/ParamsBuilder.php new file mode 100644 index 0000000000000..dd8d352fecebc --- /dev/null +++ b/app/code/Magento/Catalog/Model/Product/Image/ParamsBuilder.php @@ -0,0 +1,165 @@ +scopeConfig = $scopeConfig; + $this->viewConfig = $viewConfig; + } + + /** + * @param array $imageArguments + * @return array + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function build(array $imageArguments): array + { + $miscParams = [ + 'image_type' => $imageArguments['type'] ?? null, + 'image_height' => $imageArguments['height'] ?? null, + 'image_width' => $imageArguments['width'] ?? null, + ]; + + $overwritten = $this->overwriteDefaultValues($imageArguments); + $watermark = isset($miscParams['image_type']) ? $this->getWatermark($miscParams['image_type']) : []; + + return array_merge($miscParams, $overwritten, $watermark); + } + + /** + * @param array $imageArguments + * @return array + */ + private function overwriteDefaultValues(array $imageArguments): array + { + $frame = $imageArguments['frame'] ?? $this->hasDefaultFrame(); + $constrain = $imageArguments['constrain'] ?? $this->defaultConstrainOnly; + $aspectRatio = $imageArguments['aspect_ratio'] ?? $this->defaultKeepAspectRatio; + $transparency = $imageArguments['transparency'] ?? $this->defaultKeepTransparency; + $background = $imageArguments['background'] ?? $this->defaultBackground; + $angle = $imageArguments['angle'] ?? $this->defaultAngle; + + return [ + 'background' => (array) $background, + 'angle' => $angle, + 'quality' => $this->defaultQuality, + 'keep_aspect_ratio' => (bool) $aspectRatio, + 'keep_frame' => (bool) $frame, + 'keep_transparency' => (bool) $transparency, + 'constrain_only' => (bool) $constrain, + ]; + } + + /** + * @param string $type + * @return array + */ + private function getWatermark(string $type): array + { + $file = $this->scopeConfig->getValue( + "design/watermark/{$type}_image", + ScopeInterface::SCOPE_STORE + ); + + if ($file) { + $size = $this->scopeConfig->getValue( + "design/watermark/{$type}_size", + ScopeInterface::SCOPE_STORE + ); + $opacity = $this->scopeConfig->getValue( + "design/watermark/{$type}_imageOpacity", + ScopeInterface::SCOPE_STORE + ); + $position = $this->scopeConfig->getValue( + "design/watermark/{$type}_position", + ScopeInterface::SCOPE_STORE + ); + $width = !empty($size['width']) ? $size['width'] : null; + $height = !empty($size['height']) ? $size['height'] : null; + + return [ + 'watermark_file' => $file, + 'watermark_image_opacity' => $opacity, + 'watermark_position' => $position, + 'watermark_width' => $width, + 'watermark_height' => $height + ]; + } + + return []; + } + + /** + * Get frame from product_image_white_borders + * @return bool + */ + private function hasDefaultFrame(): bool + { + return (bool) $this->viewConfig->getViewConfig()->getVarValue( + 'Magento_Catalog', + 'product_image_white_borders' + ); + } +} diff --git a/app/code/Magento/Catalog/Model/Product/Image/UrlBuilder.php b/app/code/Magento/Catalog/Model/Product/Image/UrlBuilder.php new file mode 100644 index 0000000000000..a5fdc3b0a6eaf --- /dev/null +++ b/app/code/Magento/Catalog/Model/Product/Image/UrlBuilder.php @@ -0,0 +1,92 @@ +presentationConfig = $presentationConfig; + $this->imageParamsBuilder = $imageParamsBuilder; + $this->viewAssetImageFactory = $viewAssetImageFactory; + $this->placeholderFactory = $placeholderFactory; + } + + /** + * Build image url using base path and params + * + * @param string $baseFilePath + * @param string $imageDisplayArea + * @return string + */ + public function getUrl(string $baseFilePath, string $imageDisplayArea): string + { + $imageArguments = $this->presentationConfig->getViewConfig()->getMediaAttributes( + 'Magento_Catalog', + Image::MEDIA_TYPE_CONFIG_NODE, + $imageDisplayArea + ); + + $imageMiscParams = $this->imageParamsBuilder->build($imageArguments); + + if ($baseFilePath === null || $baseFilePath === 'no_selection') { + $asset = $this->placeholderFactory->create( + [ + 'type' => $imageMiscParams['image_type'] + ] + ); + } else { + $asset = $this->viewAssetImageFactory->create( + [ + 'miscParams' => $imageMiscParams, + 'filePath' => $baseFilePath, + ] + ); + } + + return $asset->getUrl(); + } +} diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php b/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php index 51480e849d9f3..2390a049fbeb6 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php @@ -341,7 +341,7 @@ public function getOptionPrice($optionValue, $basePrice) { $option = $this->getOption(); - return $this->_getChargableOptionPrice($option->getPrice(), $option->getPriceType() == 'percent', $basePrice); + return $this->_getChargeableOptionPrice($option->getPrice(), $option->getPriceType() == 'percent', $basePrice); } /** @@ -395,14 +395,27 @@ public function getProductOptions() } /** - * Return final chargable price for option - * * @param float $price Price of option * @param boolean $isPercent Price type - percent or fixed * @param float $basePrice For percent price type * @return float + * @deprecated 102.0.4 typo in method name + * @see _getChargeableOptionPrice */ protected function _getChargableOptionPrice($price, $isPercent, $basePrice) + { + return $this->_getChargeableOptionPrice($price, $isPercent, $basePrice); + } + + /** + * Return final chargeable price for option + * + * @param float $price Price of option + * @param boolean $isPercent Price type - percent or fixed + * @param float $basePrice For percent price type + * @return float + */ + protected function _getChargeableOptionPrice($price, $isPercent, $basePrice) { if ($isPercent) { return $basePrice * $price / 100; diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php b/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php index 08e1a3e327d2f..4a257a4781063 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php @@ -71,7 +71,8 @@ public function validateUserValue($values) } if (!$this->_isSingleSelection()) { $valuesCollection = $option->getOptionValuesByOptionId($value, $this->getProduct()->getStoreId())->load(); - if ($valuesCollection->count() != count($value)) { + $valueCount = is_array($value) ? count($value) : 1; + if ($valuesCollection->count() != $valueCount) { $this->setIsValid(false); throw new LocalizedException( __( @@ -230,7 +231,7 @@ public function getOptionPrice($optionValue, $basePrice) foreach (explode(',', $optionValue) as $value) { $_result = $option->getValueById($value); if ($_result) { - $result += $this->_getChargableOptionPrice( + $result += $this->_getChargeableOptionPrice( $_result->getPrice(), $_result->getPriceType() == 'percent', $basePrice @@ -245,7 +246,7 @@ public function getOptionPrice($optionValue, $basePrice) } elseif ($this->_isSingleSelection()) { $_result = $option->getValueById($optionValue); if ($_result) { - $result = $this->_getChargableOptionPrice( + $result = $this->_getChargeableOptionPrice( $_result->getPrice(), $_result->getPriceType() == 'percent', $basePrice diff --git a/app/code/Magento/Catalog/Model/Product/Option/Value.php b/app/code/Magento/Catalog/Model/Product/Option/Value.php index ff21cd296ec90..fb7759b210bd9 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Value.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Value.php @@ -8,8 +8,10 @@ use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Option; -use Magento\Catalog\Pricing\Price\BasePrice; use Magento\Framework\Model\AbstractModel; +use Magento\Catalog\Pricing\Price\BasePrice; +use Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator; +use Magento\Catalog\Pricing\Price\RegularPrice; /** * Catalog product option select type model @@ -19,6 +21,9 @@ * @method \Magento\Catalog\Model\Product\Option\Value setOptionId(int $value) * * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - added use of constants instead of string literals: + * BasePrice::PRICE_CODE - instead of 'base_price' + * RegularPrice::PRICE_CODE - instead of 'regular_price' * @since 100.0.2 */ class Value extends AbstractModel implements \Magento\Catalog\Api\Data\ProductCustomOptionValuesInterface @@ -59,6 +64,11 @@ class Value extends AbstractModel implements \Magento\Catalog\Api\Data\ProductCu */ protected $_valueCollectionFactory; + /** + * @var CustomOptionPriceCalculator + */ + private $customOptionPriceCalculator; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -73,9 +83,12 @@ public function __construct( \Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory $valueCollectionFactory, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + CustomOptionPriceCalculator $customOptionPriceCalculator = null ) { $this->_valueCollectionFactory = $valueCollectionFactory; + $this->customOptionPriceCalculator = $customOptionPriceCalculator + ?? \Magento\Framework\App\ObjectManager::getInstance()->get(CustomOptionPriceCalculator::class); parent::__construct( $context, $registry, @@ -222,10 +235,8 @@ public function saveValues() */ public function getPrice($flag = false) { - if ($flag && $this->getPriceType() == self::TYPE_PERCENT) { - $basePrice = $this->getOption()->getProduct()->getPriceInfo()->getPrice(BasePrice::PRICE_CODE)->getValue(); - $price = $basePrice * ($this->_getData(self::KEY_PRICE) / 100); - return $price; + if ($flag) { + return $this->customOptionPriceCalculator->getOptionPriceByPriceCode($this, BasePrice::PRICE_CODE); } return $this->_getData(self::KEY_PRICE); } @@ -237,13 +248,7 @@ public function getPrice($flag = false) */ public function getRegularPrice() { - if ($this->getPriceType() == self::TYPE_PERCENT) { - $basePrice = $this->getOption()->getProduct()->getPriceInfo() - ->getPrice('regular_price')->getAmount()->getValue(); - $price = $basePrice * ($this->_getData(self::KEY_PRICE) / 100); - return $price; - } - return $this->_getData(self::KEY_PRICE); + return $this->customOptionPriceCalculator->getOptionPriceByPriceCode($this, RegularPrice::PRICE_CODE); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php b/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php index 3bb6bba69bfb4..1bddd2d07cd81 100644 --- a/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php +++ b/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php @@ -172,8 +172,10 @@ private function getExistingPrices(array $skus, $groupBySku = false) $rawPrices = $this->tierPricePersistence->get($ids); $prices = []; + $linkField = $this->tierPricePersistence->getEntityLinkField(); + $skuByIdLookup = $this->buildSkuByIdLookup($skus); foreach ($rawPrices as $rawPrice) { - $sku = $this->retrieveSkuById($rawPrice[$this->tierPricePersistence->getEntityLinkField()], $skus); + $sku = $skuByIdLookup[$rawPrice[$linkField]]; $price = $this->tierPriceFactory->create($rawPrice, $sku); if ($groupBySku) { $prices[$sku][] = $price; @@ -300,21 +302,21 @@ private function isCorrectPriceValue(array $existingPrice, array $price) } /** - * Retrieve SKU by product ID. + * Generate lookup to retrieve SKU by product ID. * - * @param int $id * @param array $skus - * @return string|null + * @return array */ - private function retrieveSkuById($id, $skus) + private function buildSkuByIdLookup($skus) { + $lookup = []; foreach ($this->productIdLocator->retrieveProductIdsBySkus($skus) as $sku => $ids) { - if (isset($ids[$id])) { - return $sku; + foreach (array_keys($ids) as $id) { + $lookup[$id] = $sku; } } - return null; + return $lookup; } /** diff --git a/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php b/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php index 0ab1fbab471e6..d3f0c8be6f649 100644 --- a/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php +++ b/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php @@ -252,7 +252,7 @@ public function getChildrenIds($parentId, $required = true) } /** - * Retrieve parent ids array by requered child + * Retrieve parent ids array by required child * * @param int|array $childId * @return array diff --git a/app/code/Magento/Catalog/Model/ProductCategoryList.php b/app/code/Magento/Catalog/Model/ProductCategoryList.php index ae875453be938..5bbae772d5c2b 100644 --- a/app/code/Magento/Catalog/Model/ProductCategoryList.php +++ b/app/code/Magento/Catalog/Model/ProductCategoryList.php @@ -5,9 +5,11 @@ */ namespace Magento\Catalog\Model; -use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; use Magento\Framework\DB\Select; use Magento\Framework\DB\Sql\UnionExpression; +use Magento\Framework\App\ObjectManager; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; /** * Provides info about product categories. @@ -29,16 +31,32 @@ class ProductCategoryList */ private $category; + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var TableMaintainer + */ + private $tableMaintainer; + /** * @param ResourceModel\Product $productResource * @param ResourceModel\Category $category + * @param StoreManagerInterface $storeManager + * @param TableMaintainer|null $tableMaintainer */ public function __construct( ResourceModel\Product $productResource, - ResourceModel\Category $category + ResourceModel\Category $category, + StoreManagerInterface $storeManager = null, + TableMaintainer $tableMaintainer = null ) { $this->productResource = $productResource; $this->category = $category; + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); + $this->storeManager = $storeManager ?: ObjectManager::getInstance()->get(StoreManagerInterface::class); } /** @@ -50,14 +68,15 @@ public function __construct( public function getCategoryIds($productId) { if (!isset($this->categoryIdList[$productId])) { + $unionTables[] = $this->getCategorySelect($productId, $this->category->getCategoryProductTable()); + foreach ($this->storeManager->getStores() as $store) { + $unionTables[] = $this->getCategorySelect( + $productId, + $this->tableMaintainer->getMainTable($store->getId()) + ); + } $unionSelect = new UnionExpression( - [ - $this->getCategorySelect($productId, $this->category->getCategoryProductTable()), - $this->getCategorySelect( - $productId, - $this->productResource->getTable(AbstractAction::MAIN_INDEX_TABLE) - ) - ], + $unionTables, Select::SQL_UNION_ALL ); diff --git a/app/code/Magento/Catalog/Model/ProductRepository.php b/app/code/Magento/Catalog/Model/ProductRepository.php index 6a82658342824..4c0122694285d 100644 --- a/app/code/Magento/Catalog/Model/ProductRepository.php +++ b/app/code/Magento/Catalog/Model/ProductRepository.php @@ -233,7 +233,8 @@ public function __construct( public function get($sku, $editMode = false, $storeId = null, $forceReload = false) { $cacheKey = $this->getCacheKey([$editMode, $storeId]); - if (!isset($this->instances[$sku][$cacheKey]) || $forceReload) { + $cachedProduct = $this->getProductFromLocalCache($sku, $cacheKey); + if ($cachedProduct === null || $forceReload) { $product = $this->productFactory->create(); $productId = $this->resourceModel->getIdBySku($sku); @@ -250,11 +251,10 @@ public function get($sku, $editMode = false, $storeId = null, $forceReload = fal } $product->load($productId); $this->cacheProduct($cacheKey, $product); + $cachedProduct = $product; } - if (!isset($this->instances[$sku])) { - $sku = trim($sku); - } - return $this->instances[$sku][$cacheKey]; + + return $cachedProduct; } /** @@ -312,7 +312,7 @@ protected function getCacheKey($data) private function cacheProduct($cacheKey, \Magento\Catalog\Api\Data\ProductInterface $product) { $this->instancesById[$product->getId()][$cacheKey] = $product; - $this->instances[$product->getSku()][$cacheKey] = $product; + $this->saveProductInLocalCache($product, $cacheKey); if ($this->cacheLimit && count($this->instances) > $this->cacheLimit) { $offset = round($this->cacheLimit / -2); @@ -334,42 +334,29 @@ protected function initializeProductData(array $productData, $createNew) unset($productData['media_gallery']); if ($createNew) { $product = $this->productFactory->create(); - if ($this->storeManager->hasSingleStore()) { - $product->setWebsiteIds([$this->storeManager->getStore(true)->getWebsiteId()]); - } + $this->assignProductToWebsites($product); } else { - unset($this->instances[$productData['sku']]); + $this->removeProductFromLocalCache($productData['sku']); $product = $this->get($productData['sku']); } foreach ($productData as $key => $value) { $product->setData($key, $value); } - $this->assignProductToWebsites($product, $createNew); return $product; } /** * @param \Magento\Catalog\Model\Product $product - * @param bool $createNew * @return void */ - private function assignProductToWebsites(\Magento\Catalog\Model\Product $product, $createNew) + private function assignProductToWebsites(\Magento\Catalog\Model\Product $product) { - $websiteIds = $product->getWebsiteIds(); - - if (!$this->storeManager->hasSingleStore()) { - $websiteIds = array_unique( - array_merge( - $websiteIds, - [$this->storeManager->getStore()->getWebsiteId()] - ) - ); - } - - if ($createNew && $this->storeManager->getStore(true)->getCode() == \Magento\Store\Model\Store::ADMIN_CODE) { + if ($this->storeManager->getStore(true)->getCode() == \Magento\Store\Model\Store::ADMIN_CODE) { $websiteIds = array_keys($this->storeManager->getWebsites()); + } else { + $websiteIds = [$this->storeManager->getStore()->getWebsiteId()]; } $product->setWebsiteIds($websiteIds); @@ -613,7 +600,7 @@ public function save(\Magento\Catalog\Api\Data\ProductInterface $product, $saveO if ($tierPrices !== null) { $product->setData('tier_price', $tierPrices); } - unset($this->instances[$product->getSku()]); + $this->removeProductFromLocalCache($product->getSku()); unset($this->instancesById[$product->getId()]); $this->resourceModel->save($product); } catch (ConnectionException $exception) { @@ -650,8 +637,9 @@ public function save(\Magento\Catalog\Api\Data\ProductInterface $product, $saveO $e ); } - unset($this->instances[$product->getSku()]); + $this->removeProductFromLocalCache($product->getSku()); unset($this->instancesById[$product->getId()]); + return $this->get($product->getSku(), false, $product->getStoreId()); } @@ -663,7 +651,7 @@ public function delete(\Magento\Catalog\Api\Data\ProductInterface $product) $sku = $product->getSku(); $productId = $product->getId(); try { - unset($this->instances[$product->getSku()]); + $this->removeProductFromLocalCache($product->getSku()); unset($this->instancesById[$product->getId()]); $this->resourceModel->delete($product); } catch (ValidatorException $e) { @@ -673,8 +661,9 @@ public function delete(\Magento\Catalog\Api\Data\ProductInterface $product) __('The "%1" product couldn\'t be removed.', $sku) ); } - unset($this->instances[$sku]); + $this->removeProductFromLocalCache($sku); unset($this->instancesById[$productId]); + return true; } @@ -796,4 +785,54 @@ private function getCollectionProcessor() } return $this->collectionProcessor; } + + /** + * Gets product from the local cache by SKU. + * + * @param string $sku + * @param string $cacheKey + * @return Product|null + */ + private function getProductFromLocalCache(string $sku, string $cacheKey) + { + $preparedSku = $this->prepareSku($sku); + + return $this->instances[$preparedSku][$cacheKey] ?? null; + } + + /** + * Removes product in the local cache. + * + * @param string $sku + * @return void + */ + private function removeProductFromLocalCache(string $sku) :void + { + $preparedSku = $this->prepareSku($sku); + unset($this->instances[$preparedSku]); + } + + /** + * Saves product in the local cache. + * + * @param Product $product + * @param string $cacheKey + * @return void + */ + private function saveProductInLocalCache(Product $product, string $cacheKey) : void + { + $preparedSku = $this->prepareSku($product->getSku()); + $this->instances[$preparedSku][$cacheKey] = $product; + } + + /** + * Converts SKU to lower case and trims. + * + * @param string $sku + * @return string + */ + private function prepareSku(string $sku): string + { + return mb_strtolower(trim($sku)); + } } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php b/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php index 2e5b4ca538ffe..b9e629912a5b3 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php @@ -78,7 +78,7 @@ public function getDefaultStoreId() */ protected function _isApplicableAttribute($object, $attribute) { - $applyTo = $attribute->getApplyTo(); + $applyTo = $attribute->getApplyTo() ?: []; return (count($applyTo) == 0 || in_array($object->getTypeId(), $applyTo)) && $attribute->isInSet($object->getAttributeSetId()); } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php b/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php index bdb3cdab617ac..8457e5d0eaa5c 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php @@ -141,19 +141,17 @@ public function deleteEntity(\Magento\Framework\Model\AbstractModel $object) ->getMetadata(ProductInterface::class) ->getLinkField(); - $select = $this->getConnection()->select()->from( - $attribute->getEntity()->getEntityTable(), - $linkField - )->where( - 'attribute_set_id = ?', - $result['attribute_set_id'] - ); + $backendLinkField = $attribute->getBackend()->getEntityIdField(); - $clearCondition = [ - 'attribute_id =?' => $attribute->getId(), - $linkField . ' IN (?)' => $select, - ]; - $this->getConnection()->delete($backendTable, $clearCondition); + $select = $this->getConnection()->select() + ->from(['b' => $backendTable]) + ->join( + ['e' => $attribute->getEntity()->getEntityTable()], + "b.$backendLinkField = e.$linkField" + )->where('b.attribute_id = ?', $attribute->getId()) + ->where('e.attribute_set_id = ?', $result['attribute_set_id']); + + $this->getConnection()->query($select->deleteFromSelect('b')); } } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category.php b/app/code/Magento/Catalog/Model/ResourceModel/Category.php index 6c9867359d40b..fa68ae3f865ef 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category.php @@ -11,6 +11,8 @@ */ namespace Magento\Catalog\Model\ResourceModel; +use Magento\Catalog\Model\Indexer\Category\Product\Processor; +use Magento\Framework\DataObject; use Magento\Framework\EntityManager\EntityManager; /** @@ -82,6 +84,11 @@ class Category extends AbstractResource */ protected $aggregateCount; + /** + * @var Processor + */ + private $indexerProcessor; + /** * Category constructor. * @param \Magento\Eav\Model\Entity\Context $context @@ -90,6 +97,7 @@ class Category extends AbstractResource * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param Category\TreeFactory $categoryTreeFactory * @param Category\CollectionFactory $categoryCollectionFactory + * @param Processor $indexerProcessor * @param array $data * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer */ @@ -100,6 +108,7 @@ public function __construct( \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Catalog\Model\ResourceModel\Category\TreeFactory $categoryTreeFactory, \Magento\Catalog\Model\ResourceModel\Category\CollectionFactory $categoryCollectionFactory, + Processor $indexerProcessor, $data = [], \Magento\Framework\Serialize\Serializer\Json $serializer = null ) { @@ -113,6 +122,7 @@ public function __construct( $this->_categoryCollectionFactory = $categoryCollectionFactory; $this->_eventManager = $eventManager; $this->connectionName = 'catalog'; + $this->indexerProcessor = $indexerProcessor; $this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance() ->get(\Magento\Framework\Serialize\Serializer\Json::class); } @@ -197,6 +207,18 @@ protected function _beforeDelete(\Magento\Framework\DataObject $object) $this->deleteChildren($object); } + /** + * Mark Category indexer as invalid to be picked up by cron. + * + * @param DataObject $object + * @return $this + */ + protected function _afterDelete(DataObject $object) + { + $this->indexerProcessor->markIndexerAsInvalid(); + return parent::_afterDelete($object); + } + /** * Delete children categories of specific category * diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php index 573914a13f6e5..46bb74513b59c 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php @@ -371,7 +371,7 @@ public function joinUrlRewrite() ['request_path'], sprintf( '{{table}}.is_autogenerated = 1 AND {{table}}.store_id = %d AND {{table}}.entity_type = \'%s\'', - $this->_storeManager->getStore()->getId(), + $this->getStoreId(), CategoryUrlRewriteGenerator::ENTITY_TYPE ), 'left' diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php index d882ad078b97f..8f8e9f6bfedfa 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php @@ -895,18 +895,4 @@ public function setIsFilterableInGrid($isFilterableInGrid) $this->setData(self::IS_FILTERABLE_IN_GRID, $isFilterableInGrid); return $this; } - - /** - * @return \Magento\Eav\Api\Data\AttributeExtensionInterface - */ - public function getExtensionAttributes() - { - $extensionAttributes = $this->_getExtensionAttributes(); - if (null === $extensionAttributes) { - /** @var \Magento\Eav\Api\Data\AttributeExtensionInterface $extensionAttributes */ - $extensionAttributes = $this->eavAttributeFactory->create(); - $this->setExtensionAttributes($extensionAttributes); - } - return $extensionAttributes; - } } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product.php b/app/code/Magento/Catalog/Model/ResourceModel/Product.php index 81e9473e053b6..d71ec23881982 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product.php @@ -7,6 +7,7 @@ use Magento\Catalog\Model\ResourceModel\Product\Website\Link as ProductWebsiteLink; use Magento\Framework\App\ObjectManager; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; /** * Product entity resource model @@ -83,6 +84,11 @@ class Product extends AbstractResource */ private $productCategoryLink; + /** + * @var TableMaintainer + */ + private $tableMaintainer; + /** * @param \Magento\Eav\Model\Entity\Context $context * @param \Magento\Store\Model\StoreManagerInterface $storeManager @@ -94,6 +100,7 @@ class Product extends AbstractResource * @param \Magento\Eav\Model\Entity\TypeFactory $typeFactory * @param \Magento\Catalog\Model\Product\Attribute\DefaultAttributes $defaultAttributes * @param array $data + * @param TableMaintainer|null $tableMaintainer * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -107,7 +114,8 @@ public function __construct( \Magento\Eav\Model\Entity\Attribute\SetFactory $setFactory, \Magento\Eav\Model\Entity\TypeFactory $typeFactory, \Magento\Catalog\Model\Product\Attribute\DefaultAttributes $defaultAttributes, - $data = [] + $data = [], + TableMaintainer $tableMaintainer = null ) { $this->_categoryCollectionFactory = $categoryCollectionFactory; $this->_catalogCategory = $catalogCategory; @@ -122,6 +130,7 @@ public function __construct( $data ); $this->connectionName = 'catalog'; + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -366,22 +375,42 @@ public function getAvailableInCategories($object) // fetching all parent IDs, including those are higher on the tree $entityId = (int)$object->getEntityId(); if (!isset($this->availableCategoryIdsCache[$entityId])) { - $this->availableCategoryIdsCache[$entityId] = $this->getConnection()->fetchCol( - $this->getConnection()->select()->distinct()->from( - $this->getTable('catalog_category_product_index'), - ['category_id'] - )->where( - 'product_id = ? AND is_parent = 1', - $entityId - )->where( - 'visibility != ?', - \Magento\Catalog\Model\Product\Visibility::VISIBILITY_NOT_VISIBLE - ) + foreach ($this->_storeManager->getStores() as $store) { + $unionTables[] = $this->getAvailableInCategoriesSelect( + $entityId, + $this->tableMaintainer->getMainTable($store->getId()) + ); + } + $unionSelect = new \Magento\Framework\DB\Sql\UnionExpression( + $unionTables, + \Magento\Framework\DB\Select::SQL_UNION_ALL ); + $this->availableCategoryIdsCache[$entityId] = array_unique($this->getConnection()->fetchCol($unionSelect)); } return $this->availableCategoryIdsCache[$entityId]; } + /** + * Returns DB select for available categories. + * + * @param int $entityId + * @param string $tableName + * @return \Magento\Framework\DB\Select + */ + private function getAvailableInCategoriesSelect($entityId, $tableName) + { + return $this->getConnection()->select()->distinct()->from( + $tableName, + ['category_id'] + )->where( + 'product_id = ? AND is_parent = 1', + $entityId + )->where( + 'visibility != ?', + \Magento\Catalog\Model\Product\Visibility::VISIBILITY_NOT_VISIBLE + ); + } + /** * Get default attribute source model * @@ -402,7 +431,7 @@ public function getDefaultAttributeSourceModel() public function canBeShowInCategory($product, $categoryId) { $select = $this->getConnection()->select()->from( - $this->getTable('catalog_category_product_index'), + $this->tableMaintainer->getMainTable($product->getStoreId()), 'product_id' )->where( 'product_id = ?', diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php index 0729f2bd1f8f5..9b87515450a12 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php @@ -16,6 +16,7 @@ use Magento\Framework\DB\Select; use Magento\Framework\EntityManager\MetadataPool; use Magento\Store\Model\Store; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; /** * Product collection @@ -270,6 +271,11 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Collection\Abstrac */ private $backend; + /** + * @var TableMaintainer + */ + private $tableMaintainer; + /** * Collection constructor * @@ -295,6 +301,7 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Collection\Abstrac * @param \Magento\Framework\DB\Adapter\AdapterInterface|null $connection * @param ProductLimitationFactory|null $productLimitationFactory * @param MetadataPool|null $metadataPool + * @param TableMaintainer|null $tableMaintainer * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -320,7 +327,8 @@ public function __construct( GroupManagementInterface $groupManagement, \Magento\Framework\DB\Adapter\AdapterInterface $connection = null, ProductLimitationFactory $productLimitationFactory = null, - MetadataPool $metadataPool = null + MetadataPool $metadataPool = null, + TableMaintainer $tableMaintainer = null ) { $this->moduleManager = $moduleManager; $this->_catalogProductFlatState = $catalogProductFlatState; @@ -350,6 +358,7 @@ public function __construct( $storeManager, $connection ); + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -1193,7 +1202,7 @@ public function getProductCountSelect() )->distinct( false )->join( - ['count_table' => $this->getTable('catalog_category_product_index')], + ['count_table' => $this->tableMaintainer->getMainTable($this->getStoreId())], 'count_table.product_id = e.entity_id', [ 'count_table.category_id', @@ -1561,7 +1570,7 @@ protected function getEntityPkName(\Magento\Eav\Model\Entity\AbstractEntity $ent } /** - * Add requere tax percent flag for product collection + * Add require tax percent flag for product collection * * @return $this */ @@ -1967,7 +1976,7 @@ protected function _applyProductLimitations() $this->getSelect()->setPart(\Magento\Framework\DB\Select::FROM, $fromPart); } else { $this->getSelect()->join( - ['cat_index' => $this->getTable('catalog_category_product_index')], + ['cat_index' => $this->tableMaintainer->getMainTable($this->getStoreId())], $joinCond, ['cat_index_position' => 'position'] ); diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php new file mode 100644 index 0000000000000..123f358be40c8 --- /dev/null +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php @@ -0,0 +1,97 @@ +batchQueryGenerator = $generator; + $this->resourceConnection = $resourceConnection; + $this->connection = $this->resourceConnection->getConnection(); + $this->batchSize = $batchSize; + } + + /** + * Returns product images + * + * @return \Generator + */ + public function getAllProductImages(): \Generator + { + $batchSelectIterator = $this->batchQueryGenerator->generate( + 'value_id', + $this->getVisibleImagesSelect(), + $this->batchSize, + \Magento\Framework\DB\Query\BatchIteratorInterface::NON_UNIQUE_FIELD_ITERATOR + ); + + foreach ($batchSelectIterator as $select) { + foreach ($this->connection->fetchAll($select) as $key => $value) { + yield $key => $value; + } + } + } + + /** + * Get the number of unique pictures of products + * @return int + */ + public function getCountAllProductImages(): int + { + $select = $this->getVisibleImagesSelect()->reset('columns')->columns('count(*)'); + return (int) $this->connection->fetchOne($select); + } + + /** + * @return Select + */ + private function getVisibleImagesSelect(): Select + { + return $this->connection->select()->distinct() + ->from( + ['images' => $this->resourceConnection->getTableName(Gallery::GALLERY_TABLE)], + 'value as filepath' + )->where( + 'disabled = 0' + ); + } +} diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php index c4e3fb1bf1e70..c33ea7c781aa3 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php @@ -82,6 +82,7 @@ public function reindexEntities($processIds) $this->_prepareIndex($processIds); $this->_prepareRelationIndex($processIds); $this->_removeNotVisibleEntityFromIndex(); + return $this; } @@ -159,11 +160,12 @@ protected function _removeNotVisibleEntityFromIndex() * @param array $parentIds the parent entity ids limitation * @return \Magento\Framework\DB\Select */ - protected function _prepareRelationIndexSelect($parentIds = null) + protected function _prepareRelationIndexSelect(array $parentIds = null) { $connection = $this->getConnection(); $idxTable = $this->getIdxTable(); $linkField = $this->getMetadataPool()->getMetadata(ProductInterface::class)->getLinkField(); + $select = $connection->select()->from( ['l' => $this->getTable('catalog_product_relation')], [] @@ -179,6 +181,14 @@ protected function _prepareRelationIndexSelect($parentIds = null) ['i' => $idxTable], 'l.child_id = i.entity_id AND cs.store_id = i.store_id', [] + )->join( + ['sw' => $this->getTable('store_website')], + "cs.website_id = sw.website_id", + [] + )->join( + ['cpw' => $this->getTable('catalog_product_website')], + 'i.entity_id = cpw.product_id AND sw.website_id = cpw.website_id', + [] )->group( ['parent_id', 'i.attribute_id', 'i.store_id', 'i.value', 'l.child_id'] )->columns( diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php index 591a26efbf615..4ca407a53f8ae 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php @@ -52,6 +52,16 @@ class DefaultPrice extends AbstractIndexer implements PriceInterface */ private $hasEntity = null; + /** + * @var IndexTableStructureFactory + */ + private $indexTableStructureFactory; + + /** + * @var PriceModifierInterface[] + */ + private $priceModifiers = []; + /** * DefaultPrice constructor. * @@ -61,7 +71,8 @@ class DefaultPrice extends AbstractIndexer implements PriceInterface * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Framework\Module\Manager $moduleManager * @param string|null $connectionName - * @param null|\Magento\Indexer\Model\Indexer\StateFactory $stateFactory + * @param null|IndexTableStructureFactory $indexTableStructureFactory + * @param PriceModifierInterface[] $priceModifiers */ public function __construct( \Magento\Framework\Model\ResourceModel\Db\Context $context, @@ -69,11 +80,25 @@ public function __construct( \Magento\Eav\Model\Config $eavConfig, \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Framework\Module\Manager $moduleManager, - $connectionName = null + $connectionName = null, + IndexTableStructureFactory $indexTableStructureFactory = null, + array $priceModifiers = [] ) { $this->_eventManager = $eventManager; $this->moduleManager = $moduleManager; parent::__construct($context, $tableStrategy, $eavConfig, $connectionName); + + $this->indexTableStructureFactory = $indexTableStructureFactory ?: + \Magento\Framework\App\ObjectManager::getInstance()->get(IndexTableStructureFactory::class); + foreach ($priceModifiers as $priceModifier) { + if (!($priceModifier instanceof PriceModifierInterface)) { + throw new \InvalidArgumentException( + 'Argument \'priceModifiers\' must be of the type ' . PriceModifierInterface::class . '[]' + ); + } + + $this->priceModifiers[] = $priceModifier; + } } /** @@ -209,6 +234,8 @@ protected function _getDefaultFinalPriceTable() * Prepare final price temporary index table * * @return $this + * @deprecated + * @see prepareFinalPriceTable() */ protected function _prepareDefaultFinalPriceTable() { @@ -216,6 +243,32 @@ protected function _prepareDefaultFinalPriceTable() return $this; } + /** + * Create (if needed), clean and return structure of final price table + * + * @return IndexTableStructure + */ + private function prepareFinalPriceTable() + { + $tableName = $this->_getDefaultFinalPriceTable(); + $this->getConnection()->delete($tableName); + + $finalPriceTable = $this->indexTableStructureFactory->create([ + 'tableName' => $tableName, + 'entityField' => 'entity_id', + 'customerGroupField' => 'customer_group_id', + 'websiteField' => 'website_id', + 'taxClassField' => 'tax_class_id', + 'originalPriceField' => 'orig_price', + 'finalPriceField' => 'price', + 'minPriceField' => 'min_price', + 'maxPriceField' => 'max_price', + 'tierPriceField' => 'tier_price', + ]); + + return $finalPriceTable; + } + /** * Retrieve website current dates table name * @@ -248,11 +301,14 @@ protected function _prepareFinalPriceData($entityIds = null) */ protected function prepareFinalPriceDataForType($entityIds, $type) { - $this->_prepareDefaultFinalPriceTable(); + $finalPriceTable = $this->prepareFinalPriceTable(); $select = $this->getSelect($entityIds, $type); - $query = $select->insertFromSelect($this->_getDefaultFinalPriceTable(), [], false); + $query = $select->insertFromSelect($finalPriceTable->getTableName(), [], false); $this->getConnection()->query($query); + + $this->applyDiscountPrices($finalPriceTable); + return $this; } @@ -359,7 +415,7 @@ protected function getSelect($entityIds = null, $type = null) 'e.' . $metadata->getLinkField(), 'cs.store_id' ); - $currentDate = $connection->getDatePartSql('cwd.website_date'); + $currentDate = 'cwd.website_date'; $maxUnsignedBigint = '~0'; $specialFromDate = $connection->getDatePartSql($specialFrom); @@ -409,6 +465,7 @@ protected function getSelect($entityIds = null, $type = null) 'store_field' => new \Zend_Db_Expr('cs.store_id'), ] ); + return $select; } @@ -454,6 +511,19 @@ protected function _prepareCustomOptionPriceTable() return $this; } + /** + * Apply discount prices to final price index table. + * + * @param IndexTableStructure $finalPriceTable + * @return void + */ + private function applyDiscountPrices(IndexTableStructure $finalPriceTable) : void + { + foreach ($this->priceModifiers as $priceModifier) { + $priceModifier->modifyPrice($finalPriceTable); + } + } + /** * Apply custom option minimal and maximal price to temporary final price index table * @@ -463,15 +533,21 @@ protected function _prepareCustomOptionPriceTable() protected function _applyCustomOption() { $connection = $this->getConnection(); + $finalPriceTable = $this->_getDefaultFinalPriceTable(); $coaTable = $this->_getCustomOptionAggregateTable(); $copTable = $this->_getCustomOptionPriceTable(); + $metadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); $this->_prepareCustomOptionAggregateTable(); $this->_prepareCustomOptionPriceTable(); $select = $connection->select()->from( - ['i' => $this->_getDefaultFinalPriceTable()], + ['i' => $finalPriceTable], ['entity_id', 'customer_group_id', 'website_id'] + )->join( + ['e' => $this->getTable('catalog_product_entity')], + 'e.entity_id = i.entity_id', + [] )->join( ['cw' => $this->getTable('store_website')], 'cw.website_id = i.website_id', @@ -486,7 +562,7 @@ protected function _applyCustomOption() [] )->join( ['o' => $this->getTable('catalog_product_option')], - 'o.product_id = i.entity_id', + 'o.product_id = e.' . $metadata->getLinkField(), ['option_id'] )->join( ['ot' => $this->getTable('catalog_product_option_type_value')], @@ -537,8 +613,12 @@ protected function _applyCustomOption() $connection->query($query); $select = $connection->select()->from( - ['i' => $this->_getDefaultFinalPriceTable()], + ['i' => $finalPriceTable], ['entity_id', 'customer_group_id', 'website_id'] + )->join( + ['e' => $this->getTable('catalog_product_entity')], + 'e.entity_id = i.entity_id', + [] )->join( ['cw' => $this->getTable('store_website')], 'cw.website_id = i.website_id', @@ -553,7 +633,7 @@ protected function _applyCustomOption() [] )->join( ['o' => $this->getTable('catalog_product_option')], - 'o.product_id = i.entity_id', + 'o.product_id = e.' . $metadata->getLinkField(), ['option_id'] )->join( ['opd' => $this->getTable('catalog_product_option_price')], @@ -570,13 +650,13 @@ protected function _applyCustomOption() $minPriceRound = new \Zend_Db_Expr("ROUND(i.price * ({$optPriceValue} / 100), 4)"); $priceExpr = $connection->getCheckSql("{$optPriceType} = 'fixed'", $optPriceValue, $minPriceRound); - $minPrice = $connection->getCheckSql("{$priceExpr} > 0 AND o.is_require > 1", $priceExpr, 0); + $minPrice = $connection->getCheckSql("{$priceExpr} > 0 AND o.is_require = 1", $priceExpr, 0); $maxPrice = $priceExpr; $tierPriceRound = new \Zend_Db_Expr("ROUND(i.base_tier * ({$optPriceValue} / 100), 4)"); $tierPriceExpr = $connection->getCheckSql("{$optPriceType} = 'fixed'", $optPriceValue, $tierPriceRound); - $tierPriceValue = $connection->getCheckSql("{$tierPriceExpr} > 0 AND o.is_require > 0", $tierPriceExpr, 0); + $tierPriceValue = $connection->getCheckSql("{$tierPriceExpr} > 0 AND o.is_require = 1", $tierPriceExpr, 0); $tierPrice = $connection->getCheckSql("i.base_tier IS NOT NULL", $tierPriceValue, "NULL"); $select->columns( @@ -606,7 +686,7 @@ protected function _applyCustomOption() $query = $select->insertFromSelect($copTable); $connection->query($query); - $table = ['i' => $this->_getDefaultFinalPriceTable()]; + $table = ['i' => $finalPriceTable]; $select = $connection->select()->join( ['io' => $copTable], 'i.entity_id = io.entity_id AND i.customer_group_id = io.customer_group_id' . diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/IndexTableStructure.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/IndexTableStructure.php new file mode 100644 index 0000000000000..fb3eef2bf38eb --- /dev/null +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/IndexTableStructure.php @@ -0,0 +1,181 @@ +tableName = $tableName; + $this->entityField = $entityField; + $this->customerGroupField = $customerGroupField; + $this->websiteField = $websiteField; + $this->taxClassField = $taxClassField; + $this->originalPriceField = $originalPriceField; + $this->finalPriceField = $finalPriceField; + $this->minPriceField = $minPriceField; + $this->maxPriceField = $maxPriceField; + $this->tierPriceField = $tierPriceField; + } + + /** + * @return string + */ + public function getTableName(): string + { + return $this->tableName; + } + + /** + * @return string + */ + public function getEntityField(): string + { + return $this->entityField; + } + + /** + * @return string + */ + public function getCustomerGroupField(): string + { + return $this->customerGroupField; + } + + /** + * @return string + */ + public function getWebsiteField(): string + { + return $this->websiteField; + } + + /** + * @return string + */ + public function getTaxClassField(): string + { + return $this->taxClassField; + } + + /** + * @return string + */ + public function getOriginalPriceField(): string + { + return $this->originalPriceField; + } + + /** + * @return string + */ + public function getFinalPriceField(): string + { + return $this->finalPriceField; + } + + /** + * @return string + */ + public function getMinPriceField(): string + { + return $this->minPriceField; + } + + /** + * @return string + */ + public function getMaxPriceField(): string + { + return $this->maxPriceField; + } + + /** + * @return string + */ + public function getTierPriceField(): string + { + return $this->tierPriceField; + } +} diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/PriceModifierInterface.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/PriceModifierInterface.php new file mode 100644 index 0000000000000..6ecb6aba89933 --- /dev/null +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/PriceModifierInterface.php @@ -0,0 +1,23 @@ +_storeManager = $storeManager; $this->_eavConfig = $eavConfig; @@ -126,6 +134,7 @@ public function __construct( $this->_catalogCategory = $catalogCategory; $this->_logger = $logger; parent::__construct($context, $connectionName); + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -655,43 +664,52 @@ public function getRewriteByProductStore(array $products) } $connection = $this->getConnection(); - $select = $connection->select()->from( - ['i' => $this->getTable('catalog_category_product_index')], - ['product_id', 'store_id', 'visibility'] - )->joinLeft( - ['u' => $this->getMainTable()], - 'i.product_id = u.entity_id AND i.store_id = u.store_id' - . ' AND u.entity_type = "' . ProductUrlRewriteGenerator::ENTITY_TYPE . '"', - ['request_path'] - )->joinLeft( - ['r' => $this->getTable('catalog_url_rewrite_product_category')], - 'u.url_rewrite_id = r.url_rewrite_id AND r.category_id is NULL', - [] - ); - - $bind = []; + $storesProducts = []; foreach ($products as $productId => $storeId) { - $catId = $this->_storeManager->getStore($storeId)->getRootCategoryId(); - $productBind = 'product_id' . $productId; - $storeBind = 'store_id' . $storeId; - $catBind = 'category_id' . $catId; - $cond = '(' . implode( - ' AND ', - ['i.product_id = :' . $productBind, 'i.store_id = :' . $storeBind, 'i.category_id = :' . $catBind] - ) . ')'; - $bind[$productBind] = $productId; - $bind[$storeBind] = $storeId; - $bind[$catBind] = $catId; - $select->orWhere($cond); + $storesProducts[$storeId][] = $productId; } - $rowSet = $connection->fetchAll($select, $bind); - foreach ($rowSet as $row) { - $result[$row['product_id']] = [ - 'store_id' => $row['store_id'], - 'visibility' => $row['visibility'], - 'url_rewrite' => $row['request_path'], - ]; + foreach ($storesProducts as $storeId => $productIds) { + $select = $connection->select()->from( + ['i' => $this->tableMaintainer->getMainTable($storeId)], + ['product_id', 'store_id', 'visibility'] + )->joinLeft( + ['u' => $this->getMainTable()], + 'i.product_id = u.entity_id AND i.store_id = u.store_id' + . ' AND u.entity_type = "' . ProductUrlRewriteGenerator::ENTITY_TYPE . '"', + ['request_path'] + )->joinLeft( + ['r' => $this->getTable('catalog_url_rewrite_product_category')], + 'u.url_rewrite_id = r.url_rewrite_id AND r.category_id is NULL', + [] + ); + + $bind = []; + foreach ($productIds as $productId) { + $catId = $this->_storeManager->getStore($storeId)->getRootCategoryId(); + $productBind = 'product_id' . $productId; + $storeBind = 'store_id' . $storeId; + $catBind = 'category_id' . $catId; + $bindArray = [ + 'i.product_id = :' . $productBind, + 'i.store_id = :' . $storeBind, + 'i.category_id = :' . $catBind + ]; + $cond = '(' . implode(' AND ', $bindArray) . ')'; + $bind[$productBind] = $productId; + $bind[$storeBind] = $storeId; + $bind[$catBind] = $catId; + $select->orWhere($cond); + } + + $rowSet = $connection->fetchAll($select, $bind); + foreach ($rowSet as $row) { + $result[$row['product_id']] = [ + 'store_id' => $row['store_id'], + 'visibility' => $row['visibility'], + 'url_rewrite' => $row['request_path'], + ]; + } } return $result; diff --git a/app/code/Magento/Catalog/Model/View/Asset/Image.php b/app/code/Magento/Catalog/Model/View/Asset/Image.php index ce85ba21d211f..dfae9f4b0da9c 100644 --- a/app/code/Magento/Catalog/Model/View/Asset/Image.php +++ b/app/code/Magento/Catalog/Model/View/Asset/Image.php @@ -19,6 +19,13 @@ */ class Image implements LocalInterface { + /** + * Image type of image (thumbnail,small_image,image,swatch_image,swatch_thumb) + * + * @var string + */ + private $sourceContentType; + /** * @var string */ @@ -65,8 +72,14 @@ public function __construct( ContextInterface $context, EncryptorInterface $encryptor, $filePath, - array $miscParams = [] + array $miscParams ) { + if (isset($miscParams['image_type'])) { + $this->sourceContentType = $miscParams['image_type']; + unset($miscParams['image_type']); + } else { + $this->sourceContentType = $this->contentType; + } $this->mediaConfig = $mediaConfig; $this->context = $context; $this->filePath = $filePath; @@ -79,7 +92,7 @@ public function __construct( */ public function getUrl() { - return $this->context->getBaseUrl() . $this->getRelativePath(DIRECTORY_SEPARATOR); + return $this->context->getBaseUrl() . DIRECTORY_SEPARATOR . $this->getImageInfo(); } /** @@ -95,22 +108,7 @@ public function getContentType() */ public function getPath() { - return $this->getAbsolutePath($this->context->getPath()); - } - - /** - * Subroutine for building path - * - * @param string $path - * @param string $item - * @return string - */ - private function join($path, $item) - { - return trim( - $path . ($item ? DIRECTORY_SEPARATOR . ltrim($item, DIRECTORY_SEPARATOR) : ''), - DIRECTORY_SEPARATOR - ); + return $this->context->getPath() . DIRECTORY_SEPARATOR . $this->getImageInfo(); } /** @@ -119,7 +117,7 @@ private function join($path, $item) public function getSourceFile() { return $this->mediaConfig->getBaseMediaPath() - . DIRECTORY_SEPARATOR . ltrim($this->filePath, DIRECTORY_SEPARATOR); + . DIRECTORY_SEPARATOR . ltrim($this->getFilePath(), DIRECTORY_SEPARATOR); } /** @@ -129,7 +127,7 @@ public function getSourceFile() */ public function getSourceContentType() { - return $this->contentType; + return $this->sourceContentType; } /** @@ -172,35 +170,43 @@ public function getModule() */ private function getMiscPath() { - return $this->encryptor->hash(implode('_', $this->miscParams), Encryptor::HASH_VERSION_MD5); + return $this->encryptor->hash( + implode('_', $this->convertToReadableFormat($this->miscParams)), + Encryptor::HASH_VERSION_MD5 + ); } /** - * Generate absolute path + * Generate path from image info * - * @param string $result * @return string */ - private function getAbsolutePath($result) + private function getImageInfo() { - $prefix = (substr($result, 0, 1) == DIRECTORY_SEPARATOR) ? DIRECTORY_SEPARATOR : ''; - $result = $this->join($result, $this->getModule()); - $result = $this->join($result, $this->getMiscPath()); - $result = $this->join($result, $this->getFilePath()); - return $prefix . $result; + $path = $this->getModule() + . DIRECTORY_SEPARATOR . $this->getMiscPath() + . DIRECTORY_SEPARATOR . $this->getFilePath(); + return preg_replace('|\Q'. DIRECTORY_SEPARATOR . '\E+|', DIRECTORY_SEPARATOR, $path); } /** - * Generate relative path - * - * @param string $result - * @return string + * Converting bool into a string representation + * @param $miscParams + * @return array */ - private function getRelativePath($result) + private function convertToReadableFormat($miscParams) { - $result = $this->join($result, $this->getModule()); - $result = $this->join($result, $this->getMiscPath()); - $result = $this->join($result, $this->getFilePath()); - return DIRECTORY_SEPARATOR . $result; + $miscParams['image_height'] = 'h:' . ($miscParams['image_height'] ?? 'empty'); + $miscParams['image_width'] = 'w:' . ($miscParams['image_width'] ?? 'empty'); + $miscParams['quality'] = 'q:' . ($miscParams['quality'] ?? 'empty'); + $miscParams['angle'] = 'r:' . ($miscParams['angle'] ?? 'empty'); + $miscParams['keep_aspect_ratio'] = (isset($miscParams['keep_aspect_ratio']) ? '' : 'non') . 'proportional'; + $miscParams['keep_frame'] = (isset($miscParams['keep_frame']) ? '' : 'no') . 'frame'; + $miscParams['keep_transparency'] = (isset($miscParams['keep_transparency']) ? '' : 'no') . 'transparency'; + $miscParams['constrain_only'] = (isset($miscParams['constrain_only']) ? 'do' : 'not') . 'constrainonly'; + $miscParams['background'] = isset($miscParams['background']) + ? 'rgb' . implode(',', $miscParams['background']) + : 'nobackground'; + return $miscParams; } } diff --git a/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php b/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php index 5b9f6d33d3aa8..49d150a31750c 100644 --- a/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php +++ b/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php @@ -37,6 +37,8 @@ class Context implements ContextInterface private $filesystem; /** + * @param \Magento\Catalog\Model\Product\Media\ConfigInterface $mediaConfig + * @param \Magento\Framework\Filesystem $filesystem */ public function __construct( \Magento\Catalog\Model\Product\Media\ConfigInterface $mediaConfig, diff --git a/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php b/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php new file mode 100644 index 0000000000000..91d2868afab8c --- /dev/null +++ b/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php @@ -0,0 +1,69 @@ +imageResize = $imageResize; + $this->state = $state; + } + + /** + * Process event on 'save_commit_after' event + * + * @param \Magento\Framework\Event\Observer $observer + * @return void + */ + public function execute(\Magento\Framework\Event\Observer $observer) + { + /** @var $product \Magento\Catalog\Model\Product */ + $product = $observer->getEvent()->getProduct(); + + if ($this->state->isAreaCodeEmulated()) { + return; + } + + if (!(bool) $product->getId()) { + foreach ($product->getMediaGalleryImages() as $image) { + $this->imageResize->resizeFromImageName($image->getFile()); + } + } else { + $new = $product->getData('media_gallery'); + $original = $product->getOrigData('media_gallery'); + $mediaGallery = !empty($new['images']) ? array_column($new['images'], 'file') : []; + $mediaOriginalGallery = !empty($original['images']) ? array_column($original['images'], 'file') : []; + + foreach (array_diff($mediaGallery, $mediaOriginalGallery) as $image) { + $this->imageResize->resizeFromImageName($image); + } + } + } +} diff --git a/app/code/Magento/Catalog/Plugin/Block/Topmenu.php b/app/code/Magento/Catalog/Plugin/Block/Topmenu.php index ceaae832fc36f..8cbe235e05f26 100644 --- a/app/code/Magento/Catalog/Plugin/Block/Topmenu.php +++ b/app/code/Magento/Catalog/Plugin/Block/Topmenu.php @@ -162,6 +162,7 @@ private function getCategoryAsArray($category, $currentCategory, $isParentActive 'url' => $this->catalogCategory->getCategoryUrl($category), 'has_active' => in_array((string)$category->getId(), explode('/', $currentCategory->getPath()), true), 'is_active' => $category->getId() == $currentCategory->getId(), + 'is_category' => true, 'is_parent_active' => $isParentActive ]; } diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredOptions.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredOptions.php new file mode 100644 index 0000000000000..f96d56cb8818c --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredOptions.php @@ -0,0 +1,47 @@ +getProduct(); + $value = 0.; + $optionIds = $item->getOptionByCode('option_ids'); + if ($optionIds) { + foreach (explode(',', $optionIds->getValue()) as $optionId) { + $option = $product->getOptionById($optionId); + if ($option !== null) { + $itemOption = $item->getOptionByCode('option_' . $option->getId()); + /** @var $group \Magento\Catalog\Model\Product\Option\Type\DefaultType */ + $group = $option->groupFactory($option->getType()) + ->setOption($option) + ->setConfigurationItem($item) + ->setConfigurationItemOption($itemOption); + $value += $group->getOptionPrice($itemOption->getValue(), $basePrice); + } + } + } + + return $value; + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php index 87d031d8d5b35..6ec282e45a1a0 100644 --- a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php @@ -9,6 +9,7 @@ use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; use Magento\Framework\Pricing\Adjustment\CalculatorInterface; +use Magento\Framework\App\ObjectManager; /** * Configured price model @@ -25,21 +26,29 @@ class ConfiguredPrice extends FinalPrice implements ConfiguredPriceInterface */ protected $item; + /** + * @var ConfiguredOptions + */ + private $configuredOptions; + /** * @param Product $saleableItem * @param float $quantity * @param CalculatorInterface $calculator * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency - * @param ItemInterface $item + * @param ItemInterface|null $item + * @param ConfiguredOptions|null $configuredOptions */ public function __construct( Product $saleableItem, $quantity, CalculatorInterface $calculator, \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, - ItemInterface $item = null + ItemInterface $item = null, + ConfiguredOptions $configuredOptions = null ) { $this->item = $item; + $this->configuredOptions = $configuredOptions ?: ObjectManager::getInstance()->get(ConfiguredOptions::class); parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); } @@ -54,11 +63,12 @@ public function setItem(ItemInterface $item) } /** - * Get value of configured options + * Get value of configured options. * - * @return array + * @deprecated ConfiguredOptions::getItemOptionsValue is used instead + * @return float */ - protected function getOptionsValue() + protected function getOptionsValue(): float { $product = $this->item->getProduct(); $value = 0.; @@ -78,16 +88,21 @@ protected function getOptionsValue() } } } + return $value; } /** - * Price value of product with configured options + * Price value of product with configured options. * * @return bool|float */ public function getValue() { - return $this->item ? parent::getValue() + $this->getOptionsValue() : parent::getValue(); + $basePrice = parent::getValue(); + + return $this->item + ? $basePrice + $this->configuredOptions->getItemOptionsValue($basePrice, $this->item) + : $basePrice; } } diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php index e6d35a0f5239a..e41df30ea1dec 100644 --- a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php @@ -9,15 +9,20 @@ use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; /** - * Configured price interface + * Configured price interface. */ interface ConfiguredPriceInterface { /** - * Price type configured + * Price type configured. */ const CONFIGURED_PRICE_CODE = 'configured_price'; + /** + * Regular price type configured. + */ + const CONFIGURED_REGULAR_PRICE_CODE = 'configured_regular_price'; + /** * @param ItemInterface $item * @return $this diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceSelection.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceSelection.php new file mode 100644 index 0000000000000..1dedbfa354466 --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceSelection.php @@ -0,0 +1,64 @@ +calculator = $calculator; + } + + /** + * Get Selection pricing list. + * + * @param ConfiguredPriceInterface $price + * @return array + */ + public function getSelectionPriceList(ConfiguredPriceInterface $price): array + { + $selectionPriceList = []; + foreach ($price->getOptions() as $option) { + $selectionPriceList = array_merge( + $selectionPriceList, + $this->createSelectionPriceList($option, $price->getProduct()) + ); + } + + return $selectionPriceList; + } + + /** + * Create Selection Price List. + * + * @param ExtensibleDataInterface $option + * @param Product $product + * @return array + */ + private function createSelectionPriceList(ExtensibleDataInterface $option, Product $product): array + { + return $this->calculator->createSelectionPriceList($option, $product); + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredRegularPrice.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredRegularPrice.php new file mode 100644 index 0000000000000..bcb6638b9cd25 --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredRegularPrice.php @@ -0,0 +1,80 @@ +item = $item; + $this->configuredOptions = $configuredOptions; + parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); + } + + /** + * @param ItemInterface $item + * @return $this + */ + public function setItem(ItemInterface $item) : ConfiguredRegularPrice + { + $this->item = $item; + + return $this; + } + + /** + * Price value of product with configured options. + * + * @return bool|float + */ + public function getValue() + { + $basePrice = parent::getValue(); + + return $this->item + ? $basePrice + $this->configuredOptions->getItemOptionsValue($basePrice, $this->item) + : $basePrice; + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php index 5026286610118..93bf7920355db 100644 --- a/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php +++ b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php @@ -35,32 +35,42 @@ class CustomOptionPrice extends AbstractPrice implements CustomOptionPriceInterf */ protected $excludeAdjustment = null; + /** + * @var CustomOptionPriceCalculator + */ + private $customOptionPriceCalculator; + /** * @param SaleableInterface $saleableItem * @param float $quantity * @param CalculatorInterface $calculator * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency - * @param array $excludeAdjustment + * @param array|null $excludeAdjustment + * @param CustomOptionPriceCalculator|null $customOptionPriceCalculator */ public function __construct( SaleableInterface $saleableItem, $quantity, CalculatorInterface $calculator, \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, - $excludeAdjustment = null + $excludeAdjustment = null, + CustomOptionPriceCalculator $customOptionPriceCalculator = null ) { parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); $this->excludeAdjustment = $excludeAdjustment; + $this->customOptionPriceCalculator = $customOptionPriceCalculator + ?? \Magento\Framework\App\ObjectManager::getInstance()->get(CustomOptionPriceCalculator::class); } /** - * Get minimal and maximal option values + * Get minimal and maximal option values. * + * @param string $priceCode * @return array * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ - public function getValue() + public function getValue($priceCode = \Magento\Catalog\Pricing\Price\BasePrice::PRICE_CODE) { $optionValues = []; $options = $this->product->getOptions(); @@ -85,7 +95,8 @@ public function getValue() } else { /** @var $optionValue \Magento\Catalog\Model\Product\Option\Value */ foreach ($optionItem->getValues() as $optionValue) { - $price = $optionValue->getPrice($optionValue->getPriceType() == Value::TYPE_PERCENT); + $price = + $this->customOptionPriceCalculator->getOptionPriceByPriceCode($optionValue, $priceCode); if ($min === null) { $min = $price; } elseif ($price < $min) { @@ -130,15 +141,16 @@ public function getCustomAmount($amount = null, $exclude = null, $context = []) } /** - * Return the minimal or maximal price for custom options + * Return the minimal or maximal price for custom options. * * @param bool $getMin + * @param string $priceCode * @return float */ - public function getCustomOptionRange($getMin) + public function getCustomOptionRange($getMin, $priceCode = \Magento\Catalog\Pricing\Price\BasePrice::PRICE_CODE) { $optionValue = 0.; - $options = $this->getValue(); + $options = $this->getValue($priceCode); foreach ($options as $option) { if ($getMin) { $optionValue += $option['min']; diff --git a/app/code/Magento/Catalog/Pricing/Price/CustomOptionPriceCalculator.php b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPriceCalculator.php new file mode 100644 index 0000000000000..dd5c295ce0db7 --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPriceCalculator.php @@ -0,0 +1,46 @@ +getPriceType() === ProductOptionValue::TYPE_PERCENT) { + $basePrice = $optionValue->getOption()->getProduct()->getPriceInfo()->getPrice($priceCode)->getValue(); + $price = $basePrice * ($optionValue->getData(ProductOptionValue::KEY_PRICE) / 100); + + return $price; + } + + return $optionValue->getData(ProductOptionValue::KEY_PRICE); + } +} diff --git a/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php b/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php index 0722f018ae4eb..91b2b6d2a0d7f 100644 --- a/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php +++ b/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php @@ -7,12 +7,62 @@ namespace Magento\Catalog\Pricing\Render; use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; +use Magento\Catalog\Model\Product\Pricing\Renderer\SalableResolverInterface; +use Magento\Catalog\Pricing\Price\MinimalPriceCalculatorInterface; +use Magento\Framework\Pricing\Price\PriceInterface; +use Magento\Catalog\Pricing\Price\ConfiguredPriceInterface; +use Magento\Catalog\Pricing\Price\FinalPrice; +use Magento\Catalog\Pricing\Price\RegularPrice; +use Magento\Framework\Pricing\Render\RendererPool; +use Magento\Framework\Pricing\SaleableInterface; +use Magento\Framework\View\Element\Template\Context; +use Magento\Catalog\Pricing\Price\ConfiguredPriceSelection; +use Magento\Framework\App\ObjectManager; /** - * Class for configured_price rendering + * Class for configured_price rendering. */ class ConfiguredPriceBox extends FinalPriceBox { + /** + * @var ConfiguredPriceSelection + */ + private $configuredPriceSelection; + + /** + * @param Context $context + * @param SaleableInterface $saleableItem + * @param PriceInterface $price + * @param RendererPool $rendererPool + * @param array $data + * @param SalableResolverInterface|null $salableResolver + * @param MinimalPriceCalculatorInterface|null $minimalPriceCalculator + * @param ConfiguredPriceSelection|null $configuredPriceSelection + */ + public function __construct( + Context $context, + SaleableInterface $saleableItem, + PriceInterface $price, + RendererPool $rendererPool, + array $data = [], + SalableResolverInterface $salableResolver = null, + MinimalPriceCalculatorInterface $minimalPriceCalculator = null, + ConfiguredPriceSelection $configuredPriceSelection = null + ) { + $this->configuredPriceSelection = $configuredPriceSelection + ?: ObjectManager::getInstance() + ->get(ConfiguredPriceSelection::class); + parent::__construct( + $context, + $saleableItem, + $price, + $rendererPool, + $data, + $salableResolver, + $minimalPriceCalculator + ); + } + /** * Retrieve an item instance to the configured price model * @@ -34,4 +84,67 @@ protected function _prepareLayout() } return parent::_prepareLayout(); } + + /** + * {@inheritdoc} + */ + public function getPriceType($priceCode) + { + $price = $this->saleableItem->getPriceInfo()->getPrice($priceCode); + $item = $this->getData('item'); + if ($price instanceof ConfiguredPriceInterface + && $item instanceof ItemInterface + ) { + $price->setItem($item); + } + + return $price; + } + + /** + * @return PriceInterface + */ + public function getConfiguredPrice(): PriceInterface + { + /** @var \Magento\Bundle\Pricing\Price\ConfiguredPrice $configuredPrice */ + $configuredPrice = $this->getPrice(); + if (empty($this->configuredPriceSelection->getSelectionPriceList($configuredPrice))) { + // If there was no selection we must show minimal regular price + return $this->getSaleableItem()->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE); + } + + return $configuredPrice; + } + + /** + * @return PriceInterface + */ + public function getConfiguredRegularPrice(): PriceInterface + { + /** @var \Magento\Bundle\Pricing\Price\ConfiguredPrice $configuredPrice */ + $configuredPrice = $this->getPriceType(ConfiguredPriceInterface::CONFIGURED_REGULAR_PRICE_CODE); + if (empty($this->configuredPriceSelection->getSelectionPriceList($configuredPrice))) { + // If there was no selection we must show minimal regular price + return $this->getSaleableItem()->getPriceInfo()->getPrice(RegularPrice::PRICE_CODE); + } + + return $configuredPrice; + } + + /** + * Define if the special price should be shown. + * + * @return bool + */ + public function hasSpecialPrice(): bool + { + if ($this->price->getPriceCode() == ConfiguredPriceInterface::CONFIGURED_PRICE_CODE) { + $displayRegularPrice = $this->getConfiguredRegularPrice()->getAmount()->getValue(); + $displayFinalPrice = $this->getConfiguredPrice()->getAmount()->getValue(); + + return $displayFinalPrice < $displayRegularPrice; + } + + return parent::hasSpecialPrice(); + } } diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/EnableSegmentation.php b/app/code/Magento/Catalog/Setup/Patch/Data/EnableSegmentation.php new file mode 100644 index 0000000000000..d7b683a439ff1 --- /dev/null +++ b/app/code/Magento/Catalog/Setup/Patch/Data/EnableSegmentation.php @@ -0,0 +1,90 @@ +moduleDataSetup = $moduleDataSetup; + } + + /** + * {@inheritdoc} + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + $setup = $this->moduleDataSetup; + + $catalogCategoryProductIndexColumns = array_keys( + $setup->getConnection()->describeTable($setup->getTable('catalog_category_product_index')) + ); + $storeSelect = $setup->getConnection()->select()->from($setup->getTable('store'))->where('store_id > 0'); + foreach ($setup->getConnection()->fetchAll($storeSelect) as $store) { + $catalogCategoryProductIndexSelect = $setup->getConnection()->select() + ->from( + $setup->getTable('catalog_category_product_index') + )->where( + 'store_id = ?', + $store['store_id'] + ); + $indexTable = $setup->getTable('catalog_category_product_index') . + '_' . + \Magento\Store\Model\Store::ENTITY . + $store['store_id']; + $setup->getConnection()->query( + $setup->getConnection()->insertFromSelect( + $catalogCategoryProductIndexSelect, + $indexTable, + $catalogCategoryProductIndexColumns, + \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE + ) + ); + } + $setup->getConnection()->delete($setup->getTable('catalog_category_product_index')); + $setup->getConnection()->delete($setup->getTable('catalog_category_product_index_replica')); + $setup->getConnection()->delete($setup->getTable('catalog_category_product_index_tmp')); + + $this->moduleDataSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Catalog/Setup/Patch/Schema/EnableSegmentation.php b/app/code/Magento/Catalog/Setup/Patch/Schema/EnableSegmentation.php new file mode 100644 index 0000000000000..8ae84f9f8e321 --- /dev/null +++ b/app/code/Magento/Catalog/Setup/Patch/Schema/EnableSegmentation.php @@ -0,0 +1,85 @@ +schemaSetup = $schemaSetup; + } + + /** + * {@inheritdoc} + */ + public function apply() + { + $this->schemaSetup->startSetup(); + $setup = $this->schemaSetup; + + $storeSelect = $setup->getConnection()->select()->from($setup->getTable('store'))->where('store_id > 0'); + foreach ($setup->getConnection()->fetchAll($storeSelect) as $store) { + $indexTable = $setup->getTable('catalog_category_product_index') . + '_' . + \Magento\Store\Model\Store::ENTITY . + $store['store_id']; + if (!$setup->getConnection()->isTableExists($indexTable)) { + $setup->getConnection()->createTable( + $setup->getConnection()->createTableByDdl( + $setup->getTable('catalog_category_product_index'), + $indexTable + ) + ); + } + if (!$setup->getConnection()->isTableExists($indexTable . '_replica')) { + $setup->getConnection()->createTable( + $setup->getConnection()->createTableByDdl( + $setup->getTable('catalog_category_product_index'), + $indexTable . '_replica' + ) + ); + } + } + + $this->schemaSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Category/Plugin/PriceBoxTagsTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Category/Plugin/PriceBoxTagsTest.php index 55402eb1f6fd2..3f388d00eaf9f 100644 --- a/app/code/Magento/Catalog/Test/Unit/Block/Category/Plugin/PriceBoxTagsTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Block/Category/Plugin/PriceBoxTagsTest.php @@ -16,6 +16,11 @@ class PriceBoxTagsTest extends \PHPUnit\Framework\TestCase */ private $priceCurrencyInterface; + /** + * @var \Magento\Directory\Model\Currency | \PHPUnit_Framework_MockObject_MockObject + */ + private $currency; + /** * @var \Magento\Framework\Stdlib\DateTime\TimezoneInterface | \PHPUnit_Framework_MockObject_MockObject */ @@ -46,6 +51,9 @@ protected function setUp() $this->priceCurrencyInterface = $this->getMockBuilder( \Magento\Framework\Pricing\PriceCurrencyInterface::class )->getMock(); + $this->currency = $this->getMockBuilder(\Magento\Directory\Model\Currency::class) + ->disableOriginalConstructor() + ->getMock(); $this->timezoneInterface = $this->getMockBuilder( \Magento\Framework\Stdlib\DateTime\TimezoneInterface::class )->getMock(); @@ -82,7 +90,7 @@ protected function setUp() public function testAfterGetCacheKey() { $date = date('Ymd'); - $currencySymbol = '$'; + $currencyCode = 'USD'; $result = 'result_string'; $billingAddress = ['billing_address']; $shippingAddress = ['shipping_address']; @@ -95,7 +103,7 @@ public function testAfterGetCacheKey() '-', [ $result, - $currencySymbol, + $currencyCode, $date, $scopeId, $customerGroupId, @@ -104,7 +112,8 @@ public function testAfterGetCacheKey() ); $priceBox = $this->getMockBuilder(\Magento\Framework\Pricing\Render\PriceBox::class) ->disableOriginalConstructor()->getMock(); - $this->priceCurrencyInterface->expects($this->once())->method('getCurrencySymbol')->willReturn($currencySymbol); + $this->priceCurrencyInterface->expects($this->once())->method('getCurrency')->willReturn($this->currency); + $this->currency->expects($this->once())->method('getCode')->willReturn($currencyCode); $scope = $this->getMockBuilder(\Magento\Framework\App\ScopeInterface::class)->getMock(); $this->scopeResolverInterface->expects($this->any())->method('getScope')->willReturn($scope); $scope->expects($this->any())->method('getId')->willReturn($scopeId); diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php index 36bd6abea761b..d003c6d01373f 100644 --- a/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php @@ -6,6 +6,10 @@ namespace Magento\Catalog\Test\Unit\Block\Product; +use Magento\Catalog\Block\Product\Image; +use Magento\Catalog\Block\Product\ImageBuilder; +use Magento\Catalog\Model\Product; + /** * Class for testing methods of AbstractProduct */ @@ -32,7 +36,7 @@ class AbstractProductTest extends \PHPUnit\Framework\TestCase protected $stockRegistryMock; /** - * @var \Magento\Catalog\Block\Product\ImageBuilder|\PHPUnit_Framework_MockObject_MockObject + * @var ImageBuilder|\PHPUnit_Framework_MockObject_MockObject */ protected $imageBuilder; @@ -58,9 +62,7 @@ protected function setUp() ['getStockItem'] ); - $this->imageBuilder = $this->getMockBuilder(\Magento\Catalog\Block\Product\ImageBuilder::class) - ->disableOriginalConstructor() - ->getMock(); + $this->imageBuilder = $this->createPartialMock(ImageBuilder::class, ['create']); $this->productContextMock->expects($this->once()) ->method('getStockRegistry') @@ -88,7 +90,7 @@ public function testGetProductPrice() { $expectedPriceHtml = 'Expected Price html with price $30'; $priceRenderBlock = $this->createPartialMock(\Magento\Framework\Pricing\Render::class, ['render']); - $product = $this->createMock(\Magento\Catalog\Model\Product::class); + $product = $this->createMock(Product::class); $this->layoutMock->expects($this->once()) ->method('getBlock') @@ -108,7 +110,7 @@ public function testGetProductPriceHtml() { $expectedPriceHtml = 'Expected Price html with price $30'; $priceRenderBlock = $this->createPartialMock(\Magento\Framework\Pricing\Render::class, ['render']); - $product = $this->createMock(\Magento\Catalog\Model\Product::class); + $product = $this->createMock(Product::class); $this->layoutMock->expects($this->once()) ->method('getBlock') @@ -139,7 +141,7 @@ public function testGetMinimalQty($minSale, $result) $id = 10; $websiteId = 99; - $productMock = $this->createPartialMock(\Magento\Catalog\Model\Product::class, ['getId', 'getStore']); + $productMock = $this->createPartialMock(Product::class, ['getId', 'getStore']); $storeMock = $this->createPartialMock(\Magento\Store\Model\Store::class, ['getWebsiteId']); $stockItemMock = $this->getMockForAbstractClass( \Magento\CatalogInventory\Api\Data\StockItemInterface::class, @@ -168,7 +170,7 @@ public function testGetMinimalQty($minSale, $result) ->method('getMinSaleQty') ->will($this->returnValue($minSale)); - /** @var \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject $productMock */ + /** @var Product|\PHPUnit_Framework_MockObject_MockObject $productMock */ $this->assertEquals($result, $this->block->getMinimalQty($productMock)); } @@ -195,34 +197,14 @@ public function testGetImage() { $imageId = 'test_image_id'; $attributes = []; - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $imageMock = $this->getMockBuilder(\Magento\Catalog\Block\Product\Image::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageBuilder->expects($this->once()) - ->method('setProduct') - ->with($productMock) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setImageId') - ->with($imageId) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setAttributes') - ->with($attributes) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) + $productMock = $this->createMock(Product::class); + $imageMock = $this->createMock(Image::class); + $this->imageBuilder->expects(static::once()) ->method('create') ->willReturn($imageMock); - $this->assertInstanceOf( - \Magento\Catalog\Block\Product\Image::class, - $this->block->getImage($productMock, $imageId, $attributes) - ); + $image = $this->block->getImage($productMock, $imageId, $attributes); + + static::assertInstanceOf(Image::class, $image); } } diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageBuilderTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageBuilderTest.php deleted file mode 100644 index 3094b423a4be4..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageBuilderTest.php +++ /dev/null @@ -1,295 +0,0 @@ -helperFactory = $this->createPartialMock(\Magento\Catalog\Helper\ImageFactory::class, ['create']); - - $this->imageFactory = $this->createPartialMock(ImageFactory::class, ['create']); - - $this->model = new ImageBuilder($this->helperFactory, $this->imageFactory); - } - - public function testSetProduct() - { - $productMock = $this->createMock(Product::class); - - $this->assertInstanceOf( - ImageBuilder::class, - $this->model->setProduct($productMock) - ); - } - - public function testSetImageId() - { - $imageId = 'test_image_id'; - - $this->assertInstanceOf( - ImageBuilder::class, - $this->model->setImageId($imageId) - ); - } - - public function testSetAttributes() - { - $attributes = [ - 'name' => 'value', - ]; - $this->assertInstanceOf( - ImageBuilder::class, - $this->model->setAttributes($attributes) - ); - } - - /** - * @param array $data - * @dataProvider createDataProvider - */ - public function testCreate($data, $expected) - { - $imageId = 'test_image_id'; - - $productMock = $this->createMock(Product::class); - - $helperMock = $this->createMock(Image::class); - $helperMock->expects($this->once()) - ->method('init') - ->with($productMock, $imageId) - ->willReturnSelf(); - - $helperMock->expects($this->once()) - ->method('getFrame') - ->willReturn($data['frame']); - $helperMock->expects($this->once()) - ->method('getUrl') - ->willReturn($data['url']); - $helperMock->expects($this->exactly(2)) - ->method('getWidth') - ->willReturn($data['width']); - $helperMock->expects($this->exactly(2)) - ->method('getHeight') - ->willReturn($data['height']); - $helperMock->expects($this->once()) - ->method('getLabel') - ->willReturn($data['label']); - $helperMock->expects($this->once()) - ->method('getResizedImageInfo') - ->willReturn($data['imagesize']); - - $this->helperFactory->expects($this->once()) - ->method('create') - ->willReturn($helperMock); - - $imageMock = $this->createMock(\Magento\Catalog\Block\Product\Image::class); - - $this->imageFactory->expects($this->once()) - ->method('create') - ->with($expected) - ->willReturn($imageMock); - - $this->model->setProduct($productMock); - $this->model->setImageId($imageId); - $this->model->setAttributes($data['custom_attributes']); - $this->assertInstanceOf(\Magento\Catalog\Block\Product\Image::class, $this->model->create()); - } - - /** - * Check if custom attributes will be overridden when builder used few times - * @param array $data - * @dataProvider createMultipleCallsDataProvider - */ - public function testCreateMultipleCalls($data) - { - list ($firstCall, $secondCall) = array_values($data); - - $imageId = 'test_image_id'; - - $productMock = $this->createMock(Product::class); - - $helperMock = $this->createMock(Image::class); - $helperMock->expects($this->exactly(2)) - ->method('init') - ->with($productMock, $imageId) - ->willReturnSelf(); - - $helperMock->expects($this->exactly(2)) - ->method('getFrame') - ->willReturnOnConsecutiveCalls($firstCall['data']['frame'], $secondCall['data']['frame']); - $helperMock->expects($this->exactly(2)) - ->method('getUrl') - ->willReturnOnConsecutiveCalls($firstCall['data']['url'], $secondCall['data']['url']); - $helperMock->expects($this->exactly(4)) - ->method('getWidth') - ->willReturnOnConsecutiveCalls( - $firstCall['data']['width'], - $firstCall['data']['width'], - $secondCall['data']['width'], - $secondCall['data']['width'] - ); - $helperMock->expects($this->exactly(4)) - ->method('getHeight') - ->willReturnOnConsecutiveCalls( - $firstCall['data']['height'], - $firstCall['data']['height'], - $secondCall['data']['height'], - $secondCall['data']['height'] - ); - $helperMock->expects($this->exactly(2)) - ->method('getLabel') - ->willReturnOnConsecutiveCalls($firstCall['data']['label'], $secondCall['data']['label']); - $helperMock->expects($this->exactly(2)) - ->method('getResizedImageInfo') - ->willReturnOnConsecutiveCalls($firstCall['data']['imagesize'], $secondCall['data']['imagesize']); - $this->helperFactory->expects($this->exactly(2)) - ->method('create') - ->willReturn($helperMock); - - $imageMock = $this->createMock(\Magento\Catalog\Block\Product\Image::class); - - $this->imageFactory->expects($this->at(0)) - ->method('create') - ->with($firstCall['expected']) - ->willReturn($imageMock); - - $this->imageFactory->expects($this->at(1)) - ->method('create') - ->with($secondCall['expected']) - ->willReturn($imageMock); - - $this->model->setProduct($productMock); - $this->model->setImageId($imageId); - $this->model->setAttributes($firstCall['data']['custom_attributes']); - - $this->assertInstanceOf(\Magento\Catalog\Block\Product\Image::class, $this->model->create()); - - $this->model->setProduct($productMock); - $this->model->setImageId($imageId); - $this->model->setAttributes($secondCall['data']['custom_attributes']); - $this->assertInstanceOf(\Magento\Catalog\Block\Product\Image::class, $this->model->create()); - } - - /** - * @return array - */ - public function createDataProvider(): array - { - return [ - $this->getTestDataWithoutAttributes(), - $this->getTestDataWithAttributes(), - ]; - } - - /** - * @return array - */ - public function createMultipleCallsDataProvider(): array - { - return [ - [ - [ - 'without_attributes' => $this->getTestDataWithoutAttributes(), - 'with_attributes' => $this->getTestDataWithAttributes(), - ], - ], - [ - [ - 'with_attributes' => $this->getTestDataWithAttributes(), - 'without_attributes' => $this->getTestDataWithoutAttributes(), - ], - ], - ]; - } - - /** - * @return array - */ - private function getTestDataWithoutAttributes(): array - { - return [ - 'data' => [ - 'frame' => 0, - 'url' => 'test_url_1', - 'width' => 100, - 'height' => 100, - 'label' => 'test_label', - 'custom_attributes' => [], - 'imagesize' => [100, 100], - ], - 'expected' => [ - 'data' => [ - 'template' => 'Magento_Catalog::product/image_with_borders.phtml', - 'image_url' => 'test_url_1', - 'width' => 100, - 'height' => 100, - 'label' => 'test_label', - 'ratio' => 1, - 'custom_attributes' => '', - 'resized_image_width' => 100, - 'resized_image_height' => 100, - 'product_id' => null - ], - ], - ]; - } - - /** - * @return array - */ - private function getTestDataWithAttributes(): array - { - return [ - 'data' => [ - 'frame' => 1, - 'url' => 'test_url_2', - 'width' => 100, - 'height' => 50, - 'label' => 'test_label_2', - 'custom_attributes' => [ - 'name_1' => 'value_1', - 'name_2' => 'value_2', - ], - 'imagesize' => [120, 70], - ], - 'expected' => [ - 'data' => [ - 'template' => 'Magento_Catalog::product/image.phtml', - 'image_url' => 'test_url_2', - 'width' => 100, - 'height' => 50, - 'label' => 'test_label_2', - 'ratio' => 0.5, - 'custom_attributes' => 'name_1="value_1" name_2="value_2"', - 'resized_image_width' => 120, - 'resized_image_height' => 70, - 'product_id' => null - ], - ], - ]; - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageFactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageFactoryTest.php new file mode 100644 index 0000000000000..8a42865a3fe4d --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageFactoryTest.php @@ -0,0 +1,209 @@ +viewConfig = $this->createMock(View::class); + $configInterface = $this->createMock(ConfigInterface::class); + $configInterface->method('getViewConfig')->willReturn($this->viewConfig); + $this->viewAssetImageFactory = $this->createMock(ViewAssetImageFactory::class); + $this->paramsBuilder = $this->createMock(ParamsBuilder::class); + $this->objectManager = $this->createMock(ObjectManager::class); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->model = $objectManager->getObject( + ImageFactory::class, + [ + 'objectManager' => $this->objectManager, + 'presentationConfig' => $configInterface, + 'viewAssetImageFactory' => $this->viewAssetImageFactory, + 'imageParamsBuilder' => $this->paramsBuilder + ] + ); + } + + /** + * @param array $data + * @dataProvider createDataProvider + */ + public function testCreate($data, $expected) + { + $product = $this->createMock(Product::class); + $product->method('getName')->willReturn($data['product']['name']); + $product->method('getData')->willReturnOnConsecutiveCalls( + $data['product']['image_type'], + $data['product']['image_type_label'] + ); + $imageBlock = $this->createMock(Image::class); + $this->viewConfig->method('getMediaAttributes')->willReturn($data['viewImageConfig']); + $this->viewConfig->method('getVarValue')->willReturn($data['frame']); + $this->viewAssetImageFactory->method('create')->willReturn( + $viewAssetImage = $this->createMock(ViewAssetImage::class) + ); + $this->paramsBuilder->method('build')->willReturn($data['imageParamsBuilder']); + $viewAssetImage->method('getUrl')->willReturn($data['url']); + + $this->objectManager->expects(self::once()) + ->method('create') + ->with(Image::class, $expected) + ->willReturn($imageBlock); + $actual = $this->model->create($product, 'image_id', $data['custom_attributes']); + self::assertInstanceOf(Image::class, $actual); + } + + /** + * @return array + */ + public function createDataProvider(): array + { + return [ + $this->getTestDataWithoutAttributes(), + $this->getTestDataWithAttributes(), + ]; + } + + /** + * @return array + */ + private function getTestDataWithoutAttributes(): array + { + return [ + 'data' => [ + 'viewImageConfig' => [ + 'width' => 100, + 'height' => 100, + 'constrain_only' => false, + 'aspect_ratio' => false, + 'frame' => false, + 'transparency' => false, + 'background' => '255,255,255', + 'type' => 'image_type' //thumbnail,small_image,image,swatch_image,swatch_thumb + ], + 'imageParamsBuilder' => [ + 'image_width' => 100, + 'image_height' => 100, + 'constrain_only' => false, + 'keep_aspect_ratio' => false, + 'keep_frame' => false, + 'keep_transparency' => false, + 'background' => '255,255,255', + 'image_type' => 'image_type', //thumbnail,small_image,image,swatch_image,swatch_thumb + 'quality' => 80, // <=== + 'angle' => null // <=== + ], + 'product' => [ + 'image_type_label' => 'test_image_label', + 'name' => 'test_product_name', + 'image_type' => 'test_image_path' + ], + 'url' => 'test_url_1', + 'frame' => 'test_frame', + 'custom_attributes' => [], + ], + 'expected' => [ + 'data' => [ + 'template' => 'Magento_Catalog::product/image_with_borders.phtml', + 'image_url' => 'test_url_1', + 'width' => 100, + 'height' => 100, + 'label' => 'test_image_label', + 'ratio' => 1, + 'custom_attributes' => '', + 'product_id' => null + ], + ], + ]; + } + + /** + * @return array + */ + private function getTestDataWithAttributes(): array + { + return [ + 'data' => [ + 'viewImageConfig' => [ + 'width' => 100, + 'height' => 50, // <=== + 'constrain_only' => false, + 'aspect_ratio' => false, + 'frame' => true, // <=== + 'transparency' => false, + 'background' => '255,255,255', + 'type' => 'image_type' //thumbnail,small_image,image,swatch_image,swatch_thumb + ], + 'imageParamsBuilder' => [ + 'image_width' => 100, + 'image_height' => 50, + 'constrain_only' => false, + 'keep_aspect_ratio' => false, + 'keep_frame' => true, + 'keep_transparency' => false, + 'background' => '255,255,255', + 'image_type' => 'image_type', //thumbnail,small_image,image,swatch_image,swatch_thumb + 'quality' => 80, + 'angle' => null + ], + 'product' => [ + 'image_type_label' => null, // <== + 'name' => 'test_product_name', + 'image_type' => 'test_image_path' + ], + 'url' => 'test_url_2', + 'frame' => 'test_frame', + 'custom_attributes' => [ + 'name_1' => 'value_1', + 'name_2' => 'value_2', + ], + ], + 'expected' => [ + 'data' => [ + 'template' => 'Magento_Catalog::product/image_with_borders.phtml', + 'image_url' => 'test_url_2', + 'width' => 100, + 'height' => 50, + 'label' => 'test_product_name', + 'ratio' => 0.5, // <== + 'custom_attributes' => 'name_1="value_1" name_2="value_2"', + 'product_id' => null + ], + ], + ]; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php index b10b9061a9b4c..c9b7dc50beb9e 100644 --- a/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php @@ -5,23 +5,38 @@ */ namespace Magento\Catalog\Test\Unit\Block\Product\View; +use Magento\Catalog\Block\Product\Context; +use Magento\Catalog\Block\Product\ImageBuilder; +use Magento\Catalog\Block\Product\View\Gallery; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface; +use Magento\Catalog\Model\Product\Image\UrlBuilder; +use Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Framework\Data\Collection; +use Magento\Framework\DataObject; +use Magento\Framework\Json\EncoderInterface; +use Magento\Framework\Registry; +use Magento\Framework\Stdlib\ArrayUtils; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Store\Model\Store; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class GalleryTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Catalog\Block\Product\View\Gallery + * @var Gallery */ protected $model; /** - * @var \Magento\Catalog\Block\Product\Context|\PHPUnit_Framework_MockObject_MockObject + * @var Context|\PHPUnit_Framework_MockObject_MockObject */ protected $context; /** - * @var \Magento\Framework\Stdlib\ArrayUtils|\PHPUnit_Framework_MockObject_MockObject + * @var ArrayUtils|\PHPUnit_Framework_MockObject_MockObject */ protected $arrayUtils; @@ -31,174 +46,94 @@ class GalleryTest extends \PHPUnit\Framework\TestCase protected $imageHelper; /** - * @var \Magento\Framework\Registry|\PHPUnit_Framework_MockObject_MockObject + * @var Registry|\PHPUnit_Framework_MockObject_MockObject */ protected $registry; /** - * @var \Magento\Framework\Json\EncoderInterface|\PHPUnit_Framework_MockObject_MockObject + * @var EncoderInterface|\PHPUnit_Framework_MockObject_MockObject */ protected $jsonEncoderMock; /** - * @var \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ImagesConfigFactoryInterface|\PHPUnit_Framework_MockObject_MockObject */ protected $imagesConfigFactoryMock; /** - * @var \Magento\Framework\Data\Collection|\PHPUnit_Framework_MockObject_MockObject + * @var Collection|\PHPUnit_Framework_MockObject_MockObject */ protected $galleryImagesConfigMock; + /** @var UrlBuilder|\PHPUnit_Framework_MockObject_MockObject */ + private $urlBuilder; + protected function setUp() { - $this->mockContext(); - - $this->arrayUtils = $this->getMockBuilder(\Magento\Framework\Stdlib\ArrayUtils::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->jsonEncoderMock = $this->getMockBuilder(\Magento\Framework\Json\EncoderInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imagesConfigFactoryMock = $this->getImagesConfigFactory(); - - $this->model = new \Magento\Catalog\Block\Product\View\Gallery( - $this->context, - $this->arrayUtils, - $this->jsonEncoderMock, - [], - $this->imagesConfigFactoryMock + $this->registry = $this->createMock(Registry::class); + $this->context = $this->createConfiguredMock( + Context::class, + ['getRegistry' => $this->registry] ); - } - protected function mockContext() - { - $this->context = $this->getMockBuilder(\Magento\Catalog\Block\Product\Context::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageHelper = $this->getMockBuilder(\Magento\Catalog\Helper\Image::class) - ->disableOriginalConstructor() - ->getMock(); - $this->context->expects($this->any()) - ->method('getImageHelper') - ->willReturn($this->imageHelper); - - $this->registry = $this->getMockBuilder(\Magento\Framework\Registry::class) - ->disableOriginalConstructor() - ->getMock(); - $this->context->expects($this->any()) - ->method('getRegistry') - ->willReturn($this->registry); + $this->arrayUtils = $this->createMock(ArrayUtils::class); + $this->jsonEncoderMock = $this->createMock(EncoderInterface::class); + $this->imagesConfigFactoryMock = $this->getImagesConfigFactory(); + $this->urlBuilder = $this->createMock(UrlBuilder::class); + + $objectManager = new ObjectManager($this); + $this->model = $objectManager->getObject(Gallery::class, [ + 'context' => $this->context, + 'arrayUtils' => $this->arrayUtils, + 'jsonEncoder' => $this->jsonEncoderMock, + 'urlBuilder' => $this->urlBuilder, + 'imagesConfigFactory' => $this->imagesConfigFactoryMock + ]); } public function testGetGalleryImages() { - $storeMock = $this->getMockBuilder(\Magento\Store\Model\Store::class) - ->disableOriginalConstructor() - ->getMock(); - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $productTypeMock = $this->getMockBuilder(\Magento\Catalog\Model\Product\Type\AbstractType::class) - ->disableOriginalConstructor() - ->getMock(); - $productTypeMock->expects($this->once()) + $productMock = $this->createMock(Product::class); + $productTypeMock = $this->createMock(AbstractType::class); + $productTypeMock->expects(static::once()) ->method('getStoreFilter') ->with($productMock) - ->willReturn($storeMock); + ->willReturn($this->createMock(Store::class)); - $productMock->expects($this->once()) - ->method('getTypeInstance') - ->willReturn($productTypeMock); - $productMock->expects($this->once()) - ->method('getMediaGalleryImages') - ->willReturn($this->getImagesCollection()); + $imagesCollection = $this->createConfiguredMock( + Collection::class, + ['getIterator' => new \ArrayIterator([new DataObject(['file' => 'test_file'])])] + ); - $this->registry->expects($this->once()) + $productMock->method('getTypeInstance')->willReturn($productTypeMock); + $productMock->method('getMediaGalleryImages')->willReturn($imagesCollection); + $this->registry->expects(static::once()) ->method('registry') ->with('product') ->willReturn($productMock); - - $this->galleryImagesConfigMock->expects($this->exactly(1)) + $this->galleryImagesConfigMock->expects(static::exactly(1)) ->method('getItems') ->willReturn($this->getGalleryImagesConfigItems()); - $this->imageHelper->expects($this->exactly(3)) - ->method('init') - ->willReturnMap([ - [$productMock, 'product_page_image_small', [], $this->imageHelper], - [$productMock, 'product_page_image_medium_no_frame', [], $this->imageHelper], - [$productMock, 'product_page_image_large_no_frame', [], $this->imageHelper], - ]) - ->willReturnSelf(); - $this->imageHelper->expects($this->exactly(3)) - ->method('setImageFile') - ->with('test_file') - ->willReturnSelf(); - $this->imageHelper->expects($this->at(0)) - ->method('getUrl') - ->willReturn('product_page_image_small_url'); - $this->imageHelper->expects($this->at(1)) - ->method('getUrl') - ->willReturn('product_page_image_medium_url'); - $this->imageHelper->expects($this->at(2)) - ->method('getUrl') - ->willReturn('product_page_image_large_url'); - $images = $this->model->getGalleryImages(); - $this->assertInstanceOf(\Magento\Framework\Data\Collection::class, $images); - } - - /** - * @return \Magento\Framework\Data\Collection - */ - private function getImagesCollection() - { - $collectionMock = $this->getMockBuilder(\Magento\Framework\Data\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - - $items = [ - new \Magento\Framework\DataObject([ - 'file' => 'test_file' - ]), - ]; - - $collectionMock->expects($this->any()) - ->method('getIterator') - ->willReturn(new \ArrayIterator($items)); - - return $collectionMock; + static::assertInstanceOf(Collection::class, $images); } /** * getImagesConfigFactory * - * @return \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface + * @return ImagesConfigFactoryInterface */ private function getImagesConfigFactory() { - $this->galleryImagesConfigMock = $this->getMockBuilder(\Magento\Framework\Data\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->galleryImagesConfigMock->expects($this->any()) - ->method('getIterator') - ->willReturn(new \ArrayIterator($this->getGalleryImagesConfigItems())); - - $galleryImagesConfigFactoryMock = $this - ->getMockBuilder(\Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $galleryImagesConfigFactoryMock->expects($this->any()) - ->method('create') - ->willReturn($this->galleryImagesConfigMock); + $this->galleryImagesConfigMock = $this->createConfiguredMock( + Collection::class, + ['getIterator' => new \ArrayIterator($this->getGalleryImagesConfigItems())] + ); + $galleryImagesConfigFactoryMock = $this->createConfiguredMock( + ImagesConfigFactoryInterface::class, + ['create' => $this->galleryImagesConfigMock] + ); return $galleryImagesConfigFactoryMock; } @@ -211,17 +146,17 @@ private function getImagesConfigFactory() private function getGalleryImagesConfigItems() { return [ - new \Magento\Framework\DataObject([ + new DataObject([ 'image_id' => 'product_page_image_small', 'data_object_key' => 'small_image_url', 'json_object_key' => 'thumb' ]), - new \Magento\Framework\DataObject([ + new DataObject([ 'image_id' => 'product_page_image_medium', 'data_object_key' => 'medium_image_url', 'json_object_key' => 'img' ]), - new \Magento\Framework\DataObject([ + new DataObject([ 'image_id' => 'product_page_image_large', 'data_object_key' => 'large_image_url', 'json_object_key' => 'full' diff --git a/app/code/Magento/Catalog/Test/Unit/Console/Command/ImagesResizeCommandTest.php b/app/code/Magento/Catalog/Test/Unit/Console/Command/ImagesResizeCommandTest.php deleted file mode 100644 index 457ba7b94529b..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Console/Command/ImagesResizeCommandTest.php +++ /dev/null @@ -1,211 +0,0 @@ -appState = $this->getMockBuilder(\Magento\Framework\App\State::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->productRepository = $this->getMockBuilder(\Magento\Catalog\Api\ProductRepositoryInterface::class) - ->getMockForAbstractClass(); - - $this->prepareProductCollection(); - $this->prepareImageCache(); - - $this->command = new ImagesResizeCommand( - $this->appState, - $this->productCollectionFactory, - $this->productRepository, - $this->imageCacheFactory - ); - } - - public function testExecuteNoProducts() - { - $this->appState->expects($this->once()) - ->method('setAreaCode') - ->with(Area::AREA_GLOBAL) - ->willReturnSelf(); - - $this->productCollection->expects($this->once()) - ->method('getAllIds') - ->willReturn([]); - - $commandTester = new CommandTester($this->command); - $commandTester->execute([]); - - $this->assertContains( - 'No product images to resize', - $commandTester->getDisplay() - ); - } - - public function testExecute() - { - $productsIds = [1, 2]; - - $this->appState->expects($this->once()) - ->method('setAreaCode') - ->with(Area::AREA_GLOBAL) - ->willReturnSelf(); - - $this->productCollection->expects($this->once()) - ->method('getAllIds') - ->willReturn($productsIds); - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->productRepository->expects($this->at(0)) - ->method('getById') - ->with($productsIds[0]) - ->willReturn($productMock); - $this->productRepository->expects($this->at(1)) - ->method('getById') - ->with($productsIds[1]) - ->willThrowException(new NoSuchEntityException()); - - $this->imageCache->expects($this->exactly(count($productsIds) - 1)) - ->method('generate') - ->with($productMock) - ->willReturnSelf(); - - $commandTester = new CommandTester($this->command); - $commandTester->execute([]); - - $this->assertContains( - 'Product images resized successfully', - $commandTester->getDisplay() - ); - } - - public function testExecuteWithException() - { - $productsIds = [1]; - $exceptionMessage = 'Test exception text'; - - $this->appState->expects($this->once()) - ->method('setAreaCode') - ->with(Area::AREA_GLOBAL) - ->willReturnSelf(); - - $this->productCollection->expects($this->once()) - ->method('getAllIds') - ->willReturn($productsIds); - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->productRepository->expects($this->exactly(count($productsIds))) - ->method('getById') - ->with($productsIds[0]) - ->willReturn($productMock); - - $this->imageCache->expects($this->once()) - ->method('generate') - ->with($productMock) - ->willThrowException(new \Exception($exceptionMessage)); - - $commandTester = new CommandTester($this->command); - $commandTester->execute([]); - - $this->assertContains( - $exceptionMessage, - $commandTester->getDisplay() - ); - } - - protected function prepareProductCollection() - { - $this->productCollectionFactory = $this->getMockBuilder( - \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory::class - ) - ->disableOriginalConstructor() - ->setMethods(['create']) - ->getMock(); - - $this->productCollection = $this->getMockBuilder( - \Magento\Catalog\Model\ResourceModel\Product\Collection::class - ) - ->disableOriginalConstructor() - ->getMock(); - - $this->productCollectionFactory->expects($this->any()) - ->method('create') - ->willReturn($this->productCollection); - } - - protected function prepareImageCache() - { - $this->imageCacheFactory = $this->getMockBuilder(\Magento\Catalog\Model\Product\Image\CacheFactory::class) - ->disableOriginalConstructor() - ->setMethods(['create']) - ->getMock(); - - $this->imageCache = $this->getMockBuilder(\Magento\Catalog\Model\Product\Image\Cache::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageCacheFactory->expects($this->any()) - ->method('create') - ->willReturn($this->imageCache); - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilterTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilterTest.php index 28617addc6d27..424427b871456 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilterTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilterTest.php @@ -3,10 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\Initialization\Helper; use Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper\AttributeFilter; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use PHPUnit_Framework_MockObject_MockObject as MockObject; class AttributeFilterTest extends \PHPUnit\Framework\TestCase { @@ -16,12 +19,12 @@ class AttributeFilterTest extends \PHPUnit\Framework\TestCase protected $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var MockObject */ protected $objectManagerMock; /** - * @var Product|\PHPUnit_Framework_MockObject_MockObject + * @var Product|MockObject */ protected $productMock; @@ -44,15 +47,25 @@ public function testPrepareProductAttributes( $expectedProductData, $initialProductData ) { + /** @var MockObject | Product $productMockMap */ $productMockMap = $this->getMockBuilder(Product::class) ->disableOriginalConstructor() - ->setMethods(['getData']) + ->setMethods(['getData', 'getAttributes']) ->getMock(); if (!empty($initialProductData)) { $productMockMap->expects($this->any())->method('getData')->willReturnMap($initialProductData); } + if ($useDefaults) { + $productMockMap + ->expects($this->once()) + ->method('getAttributes') + ->willReturn( + $this->getProductAttributesMock($useDefaults) + ); + } + $actualProductData = $this->model->prepareProductAttributes($productMockMap, $requestProductData, $useDefaults); $this->assertEquals($expectedProductData, $actualProductData); } @@ -69,15 +82,15 @@ public function setupInputDataProvider() 'name' => 'testName', 'sku' => 'testSku', 'price' => '100', - 'description' => '' + 'description' => '', ], 'useDefaults' => [], 'expectedProductData' => [ 'name' => 'testName', 'sku' => 'testSku', - 'price' => '100' + 'price' => '100', ], - 'initialProductData' => [] + 'initialProductData' => [], ], 'update_product_without_use_defaults' => [ 'productData' => [ @@ -85,21 +98,21 @@ public function setupInputDataProvider() 'sku' => 'testSku2', 'price' => '101', 'description' => '', - 'special_price' => null + 'special_price' => null, ], 'useDefaults' => [], 'expectedProductData' => [ 'name' => 'testName2', 'sku' => 'testSku2', 'price' => '101', - 'special_price' => null + 'special_price' => null, ], 'initialProductData' => [ ['name', 'testName2'], ['sku', 'testSku2'], ['price', '101'], - ['special_price', null] - ] + ['special_price', null], + ], ], 'update_product_without_use_defaults_2' => [ 'productData' => [ @@ -107,7 +120,7 @@ public function setupInputDataProvider() 'sku' => 'testSku2', 'price' => '101', 'description' => 'updated description', - 'special_price' => null + 'special_price' => null, ], 'useDefaults' => [], 'expectedProductData' => [ @@ -115,14 +128,14 @@ public function setupInputDataProvider() 'sku' => 'testSku2', 'price' => '101', 'description' => 'updated description', - 'special_price' => null + 'special_price' => null, ], 'initialProductData' => [ ['name', 'testName2'], ['sku', 'testSku2'], ['price', '101'], - ['special_price', null] - ] + ['special_price', null], + ], ], 'update_product_with_use_defaults' => [ 'productData' => [ @@ -130,25 +143,25 @@ public function setupInputDataProvider() 'sku' => 'testSku2', 'price' => '101', 'description' => '', - 'special_price' => null + 'special_price' => null, ], 'useDefaults' => [ - 'description' => '0' + 'description' => '0', ], 'expectedProductData' => [ 'name' => 'testName2', 'sku' => 'testSku2', 'price' => '101', 'special_price' => null, - 'description' => '' + 'description' => '', ], 'initialProductData' => [ ['name', 'testName2'], ['sku', 'testSku2'], ['price', '101'], ['special_price', null], - ['description', 'descr text'] - ] + ['description', 'descr text'], + ], ], 'update_product_with_use_defaults_2' => [ 'requestProductData' => [ @@ -156,48 +169,73 @@ public function setupInputDataProvider() 'sku' => 'testSku3', 'price' => '103', 'description' => 'descr modified', - 'special_price' => '100' + 'special_price' => '100', ], 'useDefaults' => [ - 'description' => '0' + 'description' => '0', ], 'expectedProductData' => [ 'name' => 'testName3', 'sku' => 'testSku3', 'price' => '103', 'special_price' => '100', - 'description' => 'descr modified' + 'description' => 'descr modified', ], 'initialProductData' => [ - ['name', null,'testName2'], + ['name', null, 'testName2'], ['sku', null, 'testSku2'], ['price', null, '101'], - ['description', null, 'descr text'] - ] + ['description', null, 'descr text'], + ], ], 'update_product_with_use_defaults_3' => [ 'requestProductData' => [ 'name' => 'testName3', 'sku' => 'testSku3', 'price' => '103', - 'special_price' => '100' + 'special_price' => '100', + 'description' => 'descr modified', ], 'useDefaults' => [ - 'description' => '1' + 'description' => '1', ], 'expectedProductData' => [ 'name' => 'testName3', 'sku' => 'testSku3', 'price' => '103', 'special_price' => '100', + 'description' => false, ], 'initialProductData' => [ - ['name', null,'testName2'], + ['name', null, 'testName2'], ['sku', null, 'testSku2'], ['price', null, '101'], - ['description', null, 'descr text'] - ] + ['description', null, 'descr text'], + ], ], ]; } + + /** + * @param array $useDefaults + * @return array + */ + private function getProductAttributesMock(array $useDefaults): array + { + $returnArray = []; + foreach ($useDefaults as $attributecode => $isDefault) { + if ($isDefault === '1') { + /** @var Attribute | MockObject $attribute */ + $attribute = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->getMock(); + $attribute->expects($this->any()) + ->method('getBackendType') + ->willReturn('varchar'); + + $returnArray[$attributecode] = $attribute; + } + } + return $returnArray; + } } diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php index 9d0ab628a6450..53d2770f19cc8 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php @@ -27,11 +27,9 @@ protected function setUp() public function testCreateWithInvalidType() { - $this->expectException( - '\InvalidArgumentException', - \Magento\Framework\DataObject::class . ' does not implement ' . - \Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper\HandlerInterface::class - ); + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage(\Magento\Framework\DataObject::class . ' does not implement ' . + \Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper\HandlerInterface::class); $this->_objectManagerMock->expects($this->never())->method('create'); $this->_model->create(\Magento\Framework\DataObject::class); } diff --git a/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php index 6f4d30de1468a..2759371dc96e7 100644 --- a/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php @@ -5,10 +5,12 @@ */ namespace Magento\Catalog\Test\Unit\Helper; +use Magento\Catalog\Helper\Image; + class ImageTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Catalog\Helper\Image + * @var Image */ protected $helper; @@ -63,7 +65,7 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); - $this->helper = new \Magento\Catalog\Helper\Image( + $this->helper = new Image( $this->context, $this->imageFactory, $this->assetRepository, @@ -118,7 +120,7 @@ public function testInit($data) $this->prepareWatermarkProperties($data); $this->assertInstanceOf( - \Magento\Catalog\Helper\Image::class, + Image::class, $this->helper->init($productMock, $imageId, $attributes) ); } @@ -160,7 +162,7 @@ protected function prepareAttributes($data, $imageId) ->getMock(); $configViewMock->expects($this->once()) ->method('getMediaAttributes') - ->with('Magento_Catalog', 'images', $imageId) + ->with('Magento_Catalog', Image::MEDIA_TYPE_CONFIG_NODE, $imageId) ->willReturn($data); $this->viewConfig->expects($this->once()) diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/FactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/FactoryTest.php new file mode 100644 index 0000000000000..e29eef0bed076 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/FactoryTest.php @@ -0,0 +1,141 @@ +productResourceMock = $this->getMockBuilder(ProductResource::class) + ->disableOriginalConstructor() + ->setMethods(['getEntityTable']) + ->getMock(); + + $this->eavConfigMock = $this->getMockBuilder(EavConfig::class) + ->disableOriginalConstructor() + ->setMethods(['getAttribute']) + ->getMock(); + + $this->eavAttrConditionBuilderMock = $this->getMockBuilder(CustomConditionInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->nativeAttrConditionBuilderMock = $this->getMockBuilder(CustomConditionInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + + $this->conditionBuilderFactory = $objectManagerHelper->getObject( + Factory::class, + [ + 'eavConfig' => $this->eavConfigMock, + 'productResource' => $this->productResourceMock, + 'eavAttributeConditionBuilder' => $this->eavAttrConditionBuilderMock, + 'nativeAttributeConditionBuilder' => $this->nativeAttrConditionBuilderMock, + ] + ); + } + + public function testNativeAttrConditionBuilder() + { + $fieldName = 'super_field'; + $attributeTable = 'my-table'; + $productResourceTable = 'my-table'; + + $filterMock = $this->getMockBuilder(Filter::class) + ->disableOriginalConstructor() + ->setMethods(['getField']) + ->getMock(); + + $filterMock + ->method('getField') + ->willReturn($fieldName); + + $attributeMock = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->setMethods(['getBackendTable']) + ->getMock(); + + $this->eavConfigMock + ->method('getAttribute') + ->with(Product::ENTITY, $fieldName) + ->willReturn($attributeMock); + + $attributeMock + ->method('getBackendTable') + ->willReturn($attributeTable); + + $this->productResourceMock + ->method('getEntityTable') + ->willReturn($productResourceTable); + + $this->assertEquals( + $this->nativeAttrConditionBuilderMock, + $this->conditionBuilderFactory->createByFilter($filterMock) + ); + } + + public function testEavAttrConditionBuilder() + { + $fieldName = 'super_field'; + $attributeTable = 'my-table'; + $productResourceTable = 'not-my-table'; + + $filterMock = $this->getMockBuilder(Filter::class) + ->disableOriginalConstructor() + ->setMethods(['getField']) + ->getMock(); + + $filterMock + ->method('getField') + ->willReturn($fieldName); + + $attributeMock = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->setMethods(['getBackendTable']) + ->getMock(); + + $this->eavConfigMock + ->method('getAttribute') + ->with(Product::ENTITY, $fieldName) + ->willReturn($attributeMock); + + $attributeMock + ->method('getBackendTable') + ->willReturn($attributeTable); + + $this->productResourceMock + ->method('getEntityTable') + ->willReturn($productResourceTable); + + $this->assertEquals( + $this->eavAttrConditionBuilderMock, + $this->conditionBuilderFactory->createByFilter($filterMock) + ); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php index 1bc5e450ae153..0f2166d1a2a6f 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php @@ -255,7 +255,8 @@ public function testSaveWithException() */ public function testSaveWithValidateCategoryException($error, $expectedException, $expectedExceptionMessage) { - $this->expectException($expectedException, $expectedExceptionMessage); + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); $categoryId = 5; $categoryMock = $this->createMock(\Magento\Catalog\Model\Category::class); $this->extensibleDataObjectConverterMock @@ -284,7 +285,7 @@ public function saveWithValidateCategoryExceptionDataProvider() return [ [ true, \Magento\Framework\Exception\CouldNotSaveException::class, - 'Could not save category: Attribute "ValidateCategoryTest" is required.', + 'Could not save category: The "ValidateCategoryTest" attribute is required. Enter and try again.' ], [ 'Something went wrong', \Magento\Framework\Exception\CouldNotSaveException::class, 'Could not save category: Something went wrong' diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php index 51521db53312e..60937dd3f83f0 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php @@ -468,7 +468,7 @@ public function testGetCustomAttributes() ); //Change the attribute value, should reflect in getCustomAttribute - $this->category->setData($customAttributeCode, $newCustomAttributeValue); + $this->category->setCustomAttribute($customAttributeCode, $newCustomAttributeValue); $this->assertEquals(1, count($this->category->getCustomAttributes())); $this->assertNotNull($this->category->getCustomAttribute($customAttributeCode)); $this->assertEquals( diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php index 33b7892462f4c..fb90eaaaf1ec5 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php @@ -7,6 +7,8 @@ use Magento\Catalog\Model\CustomOptions\CustomOption; use Magento\Catalog\Model\Webapi\Product\Option\Type\File\Processor as FileProcessor; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Catalog\Api\Data\CustomOptionExtensionInterface; class CustomOptionTest extends \PHPUnit\Framework\TestCase { @@ -15,6 +17,12 @@ class CustomOptionTest extends \PHPUnit\Framework\TestCase */ protected $model; + /** @var \Magento\Framework\Api\ExtensionAttributesFactory | \PHPUnit_Framework_MockObject_MockObject */ + private $extensionAttributesFactoryMock; + + /** @var \Magento\Catalog\Api\Data\CustomOptionExtensionInterface | \PHPUnit_Framework_MockObject_MockObject */ + private $extensionMock; + /** * @var FileProcessor | \PHPUnit_Framework_MockObject_MockObject */ @@ -30,7 +38,7 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); - $extensionAttributesFactory = $this->getMockBuilder(\Magento\Framework\Api\ExtensionAttributesFactory::class) + $this->extensionAttributesFactoryMock = $this->getMockBuilder(ExtensionAttributesFactory::class) ->disableOriginalConstructor() ->getMock(); @@ -52,10 +60,17 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->extensionMock = $this->getMockBuilder(\Magento\Catalog\Api\Data\CustomOptionExtensionInterface::class) + ->setMethods(['getFileInfo']) + ->getMockForAbstractClass(); + + $this->extensionAttributesFactoryMock->expects(self::any()) + ->method('create')->willReturn($this->extensionMock); + $this->model = new CustomOption( $context, $registry, - $extensionAttributesFactory, + $this->extensionAttributesFactoryMock, $attributeValueFactory, $this->fileProcessor, $resource, @@ -84,14 +99,10 @@ public function testGetOptionValue() public function testGetOptionValueWithFileInfo() { - $customOption = $this->getMockBuilder(\Magento\Catalog\Api\Data\CustomOptionExtensionInterface::class) - ->setMethods(['getFileInfo']) - ->getMockForAbstractClass(); - $imageContent = $this->getMockBuilder(\Magento\Framework\Api\Data\ImageContentInterface::class) ->getMockForAbstractClass(); - $customOption->expects($this->once()) + $this->extensionMock->expects($this->once()) ->method('getFileInfo') ->willReturn($imageContent); @@ -112,7 +123,6 @@ public function testGetOptionValueWithFileInfo() ->with($imageContent) ->willReturn($imageResult); - $this->model->setExtensionAttributes($customOption); $this->model->setData(\Magento\Catalog\Api\Data\CustomOptionInterface::OPTION_VALUE, 'file'); $this->assertEquals($imageResult, $this->model->getOptionValue()); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php index 6e3cd6ed30b52..4da831f5257d0 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php @@ -33,6 +33,11 @@ class StoreViewTest extends \PHPUnit\Framework\TestCase */ protected $indexerRegistryMock; + /** + * @var \Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer|\PHPUnit_Framework_MockObject_MockObject + */ + protected $tableMaintainer; + /** * @var \PHPUnit_Framework_MockObject_MockObject */ @@ -51,15 +56,30 @@ protected function setUp() ); $this->subject = $this->createMock(Group::class); $this->indexerRegistryMock = $this->createPartialMock(IndexerRegistry::class, ['get']); - $this->storeMock = $this->createPartialMock(Store::class, ['isObjectNew', 'dataHasChangedFor', '__wakeup']); + $this->storeMock = $this->createPartialMock( + Store::class, + [ + 'isObjectNew', + 'getId', + 'dataHasChangedFor', + '__wakeup' + ] + ); + $this->tableMaintainer = $this->createPartialMock( + \Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer::class, + [ + 'createTablesForStore' + ] + ); - $this->model = new StoreView($this->indexerRegistryMock); + $this->model = new StoreView($this->indexerRegistryMock, $this->tableMaintainer); } public function testAroundSaveNewObject() { $this->mockIndexerMethods(); - $this->storeMock->expects($this->once())->method('isObjectNew')->willReturn(true); + $this->storeMock->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->storeMock->expects($this->atLeastOnce())->method('getId')->willReturn(1); $this->model->beforeSave($this->subject, $this->storeMock); $this->assertSame($this->subject, $this->model->afterSave($this->subject, $this->subject, $this->storeMock)); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php index 58654136ab5a8..9d58822fb6073 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php @@ -113,11 +113,20 @@ public function testReindexWithoutArgumentsExecutesReindexAll() $this->_model->reindex(); } - public function testReindexWithNotNullArgumentExecutesReindexEntities() - { - $childIds = [1, 2, 3]; - $parentIds = [4]; - $reindexIds = array_merge($childIds, $parentIds); + /** + * @param array $ids + * @param array $parentIds + * @param array $childIds + * @return void + * @dataProvider reindexEntitiesDataProvider + */ + public function testReindexWithNotNullArgumentExecutesReindexEntities( + array $ids, + array $parentIds, + array $childIds + ) : void { + $reindexIds = array_unique(array_merge($ids, $parentIds, $childIds)); + $connectionMock = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) ->getMockForAbstractClass(); @@ -129,11 +138,23 @@ public function testReindexWithNotNullArgumentExecutesReindexEntities() ->disableOriginalConstructor() ->getMock(); - $eavSource->expects($this->once())->method('getRelationsByChild')->with($childIds)->willReturn($childIds); - $eavSource->expects($this->once())->method('getRelationsByParent')->with($childIds)->willReturn($parentIds); + $eavSource->expects($this->once()) + ->method('getRelationsByChild') + ->with($ids) + ->willReturn($parentIds); + $eavSource->expects($this->once()) + ->method('getRelationsByParent') + ->with(array_unique(array_merge($parentIds, $ids))) + ->willReturn($childIds); - $eavDecimal->expects($this->once())->method('getRelationsByChild')->with($reindexIds)->willReturn($reindexIds); - $eavDecimal->expects($this->once())->method('getRelationsByParent')->with($reindexIds)->willReturn([]); + $eavDecimal->expects($this->once()) + ->method('getRelationsByChild') + ->with($reindexIds) + ->willReturn($parentIds); + $eavDecimal->expects($this->once()) + ->method('getRelationsByParent') + ->with(array_unique(array_merge($parentIds, $reindexIds))) + ->willReturn($childIds); $eavSource->expects($this->once())->method('getConnection')->willReturn($connectionMock); $eavDecimal->expects($this->once())->method('getConnection')->willReturn($connectionMock); @@ -153,6 +174,18 @@ public function testReindexWithNotNullArgumentExecutesReindexEntities() ->method('create') ->will($this->returnValue($eavDecimal)); - $this->_model->reindex($childIds); + $this->_model->reindex($ids); + } + + /** + * @return array + */ + public function reindexEntitiesDataProvider() : array + { + return [ + [[4], [], [1, 2, 3]], + [[3], [4], []], + [[5], [], []], + ]; } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php index eb5fdabe53303..c254557904da1 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php @@ -48,7 +48,8 @@ public function testExecuteWithAdapterErrorThrowsException() $tableSwitcherMock ); - $this->expectException(\Magento\Framework\Exception\LocalizedException::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage($exceptionMessage); $model->execute(); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Frontend/InputType/PresentationTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Frontend/InputType/PresentationTest.php new file mode 100644 index 0000000000000..16dff2d210f27 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Frontend/InputType/PresentationTest.php @@ -0,0 +1,80 @@ +presentation = new \Magento\Catalog\Model\Product\Attribute\Frontend\Inputtype\Presentation(); + $this->attributeMock = $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @param string $inputType + * @param boolean $isWysiwygEnabled + * @param string $expectedResult + * @dataProvider getPresentationInputTypeDataProvider + */ + public function testGetPresentationInputType(string $inputType, bool $isWysiwygEnabled, string $expectedResult) + { + $this->attributeMock->expects($this->once())->method('getFrontendInput')->willReturn($inputType); + $this->attributeMock->expects($this->any())->method('getIsWysiwygEnabled')->willReturn($isWysiwygEnabled); + $this->assertEquals($expectedResult, $this->presentation->getPresentationInputType($this->attributeMock)); + } + + public function getPresentationInputTypeDataProvider() + { + return [ + 'attribute_is_textarea_and_wysiwyg_enabled' => ['textarea', true, 'texteditor'], + 'attribute_is_input_and_wysiwyg_enabled' => ['input', true, 'input'], + 'attribute_is_textarea_and_wysiwyg_disabled' => ['textarea', false, 'textarea'], + ]; + } + + /** + * @param array $data + * @param array $expectedResult + * @dataProvider convertPresentationDataToInputTypeDataProvider + */ + public function testConvertPresentationDataToInputType(array $data, array $expectedResult) + { + $this->assertEquals($expectedResult, $this->presentation->convertPresentationDataToInputType($data)); + } + + public function convertPresentationDataToInputTypeDataProvider() + { + return [ + [['key' => 'value'], ['key' => 'value']], + [ + ['frontend_input' => 'texteditor'], + ['frontend_input' => 'textarea', 'is_wysiwyg_enabled' => 1] + ], + [ + ['frontend_input' => 'textarea'], + ['frontend_input' => 'textarea', 'is_wysiwyg_enabled' => 0] + ], + [ + ['frontend_input' => 'input'], + ['frontend_input' => 'input'] + ] + ]; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php index a01e9814b0579..1c0ea6ee1ed39 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php @@ -27,8 +27,8 @@ protected function setUp() public function testCreateWithInvalidType() { - $this->expectException( - '\InvalidArgumentException', + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage( 'Magento\Framework\DataObject does not implement \Magento\Catalog\Model\Product\CopyConstructorInterface' ); $this->_objectManagerMock->expects($this->never())->method('create'); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php index 627aa1848506e..430db70701356 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php @@ -5,10 +5,12 @@ */ namespace Magento\Catalog\Test\Unit\Model\Product; +use Magento\Catalog\Model\Product\Image\ParamsBuilder; +use Magento\Catalog\Model\View\Asset\Image\ContextFactory; use Magento\Catalog\Model\View\Asset\ImageFactory; use Magento\Catalog\Model\View\Asset\PlaceholderFactory; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -81,6 +83,11 @@ class ImageTest extends \PHPUnit\Framework\TestCase */ private $cacheManager; + /** + * @var ParamsBuilder|\PHPUnit_Framework_MockObject_MockObject + */ + private $paramsBuilder; + protected function setUp() { $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -141,6 +148,9 @@ function ($value) { return json_decode($value, true); } ); + $this->paramsBuilder = $this->getMockBuilder(ParamsBuilder::class) + ->disableOriginalConstructor() + ->getMock(); $this->image = $objectManager->getObject( \Magento\Catalog\Model\Product\Image::class, @@ -153,15 +163,14 @@ function ($value) { 'imageFactory' => $this->factory, 'viewAssetImageFactory' => $this->viewAssetImageFactory, 'viewAssetPlaceholderFactory' => $this->viewAssetPlaceholderFactory, - 'serializer' => $this->serializer + 'serializer' => $this->serializer, + 'paramsBuilder' => $this->paramsBuilder ] ); - // Settings for backward compatible property - $objectManagerHelper = new ObjectManagerHelper($this); $this->imageAsset = $this->getMockBuilder(\Magento\Framework\View\Asset\LocalInterface::class) ->getMockForAbstractClass(); - $objectManagerHelper->setBackwardCompatibleProperty( + $objectManager->setBackwardCompatibleProperty( $this->image, 'imageAsset', $this->imageAsset @@ -213,6 +222,21 @@ public function testSetSize() public function testSetGetBaseFile() { + $miscParams = [ + 'image_type' => null, + 'image_height' => null, + 'image_width' => null, + 'keep_aspect_ratio' => 'proportional', + 'keep_frame' => 'frame', + 'keep_transparency' => 'transparency', + 'constrain_only' => 'doconstrainonly', + 'background' => 'ffffff', + 'angle' => null, + 'quality' => 80, + ]; + $this->paramsBuilder->expects(self::once()) + ->method('build') + ->willReturn($miscParams); $this->mediaDirectory->expects($this->any())->method('isFile')->will($this->returnValue(true)); $this->mediaDirectory->expects($this->any())->method('isExist')->will($this->returnValue(true)); $absolutePath = dirname(dirname(__DIR__)) . '/_files/catalog/product/somefile.png'; @@ -222,18 +246,7 @@ public function testSetGetBaseFile() ->method('create') ->with( [ - 'miscParams' => [ - 'image_type' => null, - 'image_height' => null, - 'image_width' => null, - 'keep_aspect_ratio' => 'proportional', - 'keep_frame' => 'frame', - 'keep_transparency' => 'transparency', - 'constrain_only' => 'doconstrainonly', - 'background' => 'ffffff', - 'angle' => null, - 'quality' => 80, - ], + 'miscParams' => $miscParams, 'filePath' => '/somefile.png', ] ) @@ -243,6 +256,10 @@ public function testSetGetBaseFile() $this->imageAsset->expects($this->any())->method('getSourceFile')->willReturn('catalog/product/somefile.png'); $this->image->setBaseFile('/somefile.png'); $this->assertEquals('catalog/product/somefile.png', $this->image->getBaseFile()); + $this->assertEquals( + null, + $this->image->getNewFile() + ); } public function testSetBaseNoSelectionFile() diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php index d3a0cb81b8876..1ff5bef78cd79 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php @@ -19,6 +19,32 @@ class ValueTest extends \PHPUnit\Framework\TestCase */ private $model; + /** + * @var \Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator + */ + private $customOptionPriceCalculatorMock; + + protected function setUp() + { + $mockedResource = $this->getMockedResource(); + $mockedCollectionFactory = $this->getMockedValueCollectionFactory(); + + $this->customOptionPriceCalculatorMock = $this->createMock( + \Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator::class + ); + + $helper = new ObjectManager($this); + $this->model = $helper->getObject( + \Magento\Catalog\Model\Product\Option\Value::class, + [ + 'resource' => $mockedResource, + 'valueCollectionFactory' => $mockedCollectionFactory, + 'customOptionPriceCalculator' => $this->customOptionPriceCalculatorMock, + ] + ); + $this->model->setOption($this->getMockedOption()); + } + public function testSaveProduct() { $this->model->setValues([100]) @@ -35,11 +61,16 @@ public function testSaveProduct() public function testGetPrice() { - $this->model->setPrice(1000); + $price = 1000; + $this->model->setPrice($price); $this->model->setPriceType(Value::TYPE_PERCENT); - $this->assertEquals(1000, $this->model->getPrice(false)); + $this->assertEquals($price, $this->model->getPrice(false)); - $this->assertEquals(100, $this->model->getPrice(true)); + $percentPice = 100; + $this->customOptionPriceCalculatorMock->expects($this->atLeastOnce()) + ->method('getOptionPriceByPriceCode') + ->willReturn($percentPice); + $this->assertEquals($percentPice, $this->model->getPrice(true)); } public function testGetValuesCollection() @@ -78,23 +109,6 @@ public function testDeleteValue() $this->assertInstanceOf(\Magento\Catalog\Model\Product\Option\Value::class, $this->model->deleteValue(1)); } - protected function setUp() - { - $mockedResource = $this->getMockedResource(); - $mockedCollectionFactory = $this->getMockedValueCollectionFactory(); - $mockedContext = $this->getMockedContext(); - $helper = new ObjectManager($this); - $this->model = $helper->getObject( - \Magento\Catalog\Model\Product\Option\Value::class, - [ - 'resource' => $mockedResource, - 'valueCollectionFactory' => $mockedCollectionFactory, - 'context' => $mockedContext - ] - ); - $this->model->setOption($this->getMockedOption()); - } - /** * @return \Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory */ @@ -243,61 +257,4 @@ private function getMockedResource() return $mock; } - - /** - * @return \Magento\Framework\Model\Context - */ - private function getMockedContext() - { - $mockedRemoveAction = $this->getMockedRemoveAction(); - $mockEventManager = $this->getMockedEventManager(); - - $mockBuilder = $this->getMockBuilder(\Magento\Framework\Model\Context::class) - ->setMethods(['getActionValidator', 'getEventDispatcher']) - ->disableOriginalConstructor(); - $mock = $mockBuilder->getMock(); - - $mock->expects($this->any()) - ->method('getActionValidator') - ->will($this->returnValue($mockedRemoveAction)); - - $mock->expects($this->any()) - ->method('getEventDispatcher') - ->will($this->returnValue($mockEventManager)); - - return $mock; - } - - /** - * @return RemoveAction - */ - private function getMockedRemoveAction() - { - $mockBuilder = $this->getMockBuilder(\Magento\Framework\Model\Context::class) - ->setMethods(['isAllowed']) - ->disableOriginalConstructor(); - $mock = $mockBuilder->getMock(); - - $mock->expects($this->any()) - ->method('isAllowed') - ->will($this->returnValue(true)); - - return $mock; - } - - /** - * @return \Magento\Framework\Event\ManagerInterface - */ - private function getMockedEventManager() - { - $mockBuilder = $this->getMockBuilder(\Magento\Framework\Event\ManagerInterface::class) - ->setMethods(['dispatch']) - ->disableOriginalConstructor(); - $mock = $mockBuilder->getMockForAbstractClass(); - - $mock->expects($this->any()) - ->method('dispatch'); - - return $mock; - } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php index 8d65153d7ba20..a1cf6662d78f0 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php @@ -381,11 +381,11 @@ public function testGetByIdAbsentProduct() public function testGetByIdProductInEditMode() { $productId = 123; - $this->productFactoryMock->expects($this->once())->method('create') - ->will($this->returnValue($this->productMock)); - $this->productMock->expects($this->once())->method('setData')->with('_edit_mode', true); - $this->productMock->expects($this->once())->method('load')->with($productId); + $this->productFactoryMock->method('create')->willReturn($this->productMock); + $this->productMock->method('setData')->with('_edit_mode', true); + $this->productMock->method('load')->with($productId); $this->productMock->expects($this->atLeastOnce())->method('getId')->willReturn($productId); + $this->productMock->method('getSku')->willReturn('simple'); $this->assertEquals($this->productMock, $this->model->getById($productId, true)); } @@ -411,6 +411,7 @@ public function testGetByIdForCacheKeyGenerate($identifier, $editMode, $storeId) } $this->productMock->expects($this->once())->method('load')->with($identifier); $this->productMock->expects($this->atLeastOnce())->method('getId')->willReturn($identifier); + $this->productMock->method('getSku')->willReturn('simple'); $this->assertEquals($this->productMock, $this->model->getById($identifier, $editMode, $storeId)); //Second invocation should just return from cache $this->assertEquals($this->productMock, $this->model->getById($identifier, $editMode, $storeId)); @@ -433,6 +434,7 @@ public function testGetByIdForcedReload() $this->serializerMock->expects($this->exactly(3))->method('serialize'); $this->productMock->expects($this->exactly(4))->method('getId')->willReturn($identifier); + $this->productMock->method('getSku')->willReturn('simple'); $this->assertEquals($this->productMock, $this->model->getById($identifier, $editMode, $storeId)); //second invocation should just return from cache $this->assertEquals($this->productMock, $this->model->getById($identifier, $editMode, $storeId)); @@ -532,6 +534,7 @@ public function testGetByIdWithSetStoreId() $this->productMock->expects($this->once())->method('setData')->with('store_id', $storeId); $this->productMock->expects($this->once())->method('load')->with($productId); $this->productMock->expects($this->atLeastOnce())->method('getId')->willReturn($productId); + $this->productMock->method('getSku')->willReturn('simple'); $this->assertEquals($this->productMock, $this->model->getById($productId, false, $storeId)); } @@ -550,7 +553,6 @@ public function testGetBySkuFromCacheInitializedInGetById() public function testSaveExisting() { - $this->storeManagerMock->expects($this->any())->method('getWebsites')->willReturn([1 => 'default']); $this->resourceModelMock->expects($this->any())->method('getIdBySku')->will($this->returnValue(100)); $this->productFactoryMock->expects($this->any()) ->method('create') @@ -563,7 +565,6 @@ public function testSaveExisting() ->expects($this->once()) ->method('toNestedArray') ->will($this->returnValue($this->productData)); - $this->productMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); $this->productMock->expects($this->atLeastOnce())->method('getSku')->willReturn($this->productData['sku']); $this->assertEquals($this->productMock, $this->model->save($this->productMock)); @@ -585,7 +586,7 @@ public function testSaveNew() ->expects($this->once()) ->method('toNestedArray') ->will($this->returnValue($this->productData)); - $this->productMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); + $this->productMock->method('getSku')->willReturn('simple'); $this->assertEquals($this->productMock, $this->model->save($this->productMock)); } @@ -597,7 +598,8 @@ public function testSaveNew() public function testSaveUnableToSaveException() { $this->storeManagerMock->expects($this->any())->method('getWebsites')->willReturn([1 => 'default']); - $this->resourceModelMock->expects($this->exactly(1))->method('getIdBySku')->will($this->returnValue(null)); + $this->resourceModelMock->expects($this->exactly(1)) + ->method('getIdBySku')->willReturn(null); $this->productFactoryMock->expects($this->exactly(2)) ->method('create') ->will($this->returnValue($this->productMock)); @@ -610,7 +612,7 @@ public function testSaveUnableToSaveException() ->expects($this->once()) ->method('toNestedArray') ->will($this->returnValue($this->productData)); - $this->productMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); + $this->productMock->method('getSku')->willReturn('simple'); $this->model->save($this->productMock); } @@ -636,7 +638,7 @@ public function testSaveException() ->expects($this->once()) ->method('toNestedArray') ->will($this->returnValue($this->productData)); - $this->productMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); + $this->productMock->method('getSku')->willReturn('simple'); $this->model->save($this->productMock); } @@ -660,7 +662,7 @@ public function testSaveInvalidProductException() ->expects($this->once()) ->method('toNestedArray') ->will($this->returnValue($this->productData)); - $this->productMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); + $this->productMock->method('getSku')->willReturn('simple'); $this->model->save($this->productMock); } @@ -689,9 +691,7 @@ public function testSaveThrowsTemporaryStateExceptionIfDatabaseConnectionErrorOc ->expects($this->once()) ->method('toNestedArray') ->will($this->returnValue($this->productData)); - $this->productMock->expects($this->once()) - ->method('getWebsiteIds') - ->willReturn([]); + $this->productMock->method('getSku')->willReturn('simple'); $this->model->save($this->productMock); } @@ -734,9 +734,8 @@ public function testGetList() { $searchCriteriaMock = $this->createMock(\Magento\Framework\Api\SearchCriteriaInterface::class); $collectionMock = $this->createMock(\Magento\Catalog\Model\ResourceModel\Product\Collection::class); - $this->collectionFactoryMock->expects($this->once())->method('create')->willReturn($collectionMock); - + $this->productMock->method('getSku')->willReturn('simple'); $collectionMock->expects($this->once())->method('addAttributeToSelect')->with('*'); $collectionMock->expects($this->exactly(2))->method('joinAttribute')->withConsecutive( ['status', 'catalog_product/status', 'entity_id', null, 'inner'], @@ -839,7 +838,6 @@ public function testSaveExistingWithOptions(array $newOptions, array $existingOp ->method('toNestedArray') ->will($this->returnValue($this->productData)); - $this->initializedProductMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); $this->initializedProductMock->expects($this->atLeastOnce()) ->method('getSku')->willReturn($this->productData['sku']); $this->productMock->expects($this->atLeastOnce())->method('getSku')->willReturn($this->productData['sku']); @@ -1075,7 +1073,6 @@ public function testSaveWithLinks(array $newLinks, array $existingLinks, array $ $outputLinks[] = $outputLink; } } - $this->initializedProductMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); if (!empty($outputLinks)) { $this->initializedProductMock->expects($this->once()) @@ -1256,7 +1253,6 @@ public function testSaveExistingWithNewMediaGalleryEntries() 'media_type' => 'media_type', ] ); - $this->initializedProductMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); $this->initializedProductMock->expects($this->atLeastOnce()) ->method('getSku')->willReturn($this->productData['sku']); $this->productMock->expects($this->atLeastOnce())->method('getSku')->willReturn($this->productData['sku']); @@ -1297,8 +1293,8 @@ public function testSaveWithDifferentWebsites() 2 => ['second'], 3 => ['third'] ]); - $this->productMock->expects($this->once())->method('getWebsiteIds')->willReturn([1,2,3]); $this->productMock->expects($this->once())->method('setWebsiteIds')->willReturn([2,3]); + $this->productMock->method('getSku')->willReturn('simple'); $this->assertEquals($this->productMock, $this->model->save($this->productMock)); } @@ -1336,7 +1332,6 @@ public function testSaveExistingWithMediaGalleryEntries() $expectedResult = [ [ - 'value_id' => 5, 'value_id' => 5, "label" => "new_label_text", 'file' => 'filename1', @@ -1369,7 +1364,6 @@ public function testSaveExistingWithMediaGalleryEntries() $this->mediaGalleryProcessor->expects($this->once()) ->method('setMediaAttribute') ->with($this->initializedProductMock, ['image', 'small_image'], 'filename1'); - $this->initializedProductMock->expects($this->once())->method('getWebsiteIds')->willReturn([]); $this->initializedProductMock->expects($this->atLeastOnce()) ->method('getSku')->willReturn($this->productData['sku']); $this->productMock->expects($this->atLeastOnce())->method('getSku')->willReturn($this->productData['sku']); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php index 1ba35dde7ad2f..809ec8b9b7a2d 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php @@ -330,9 +330,9 @@ protected function setUp() $this->mediaGalleryEntryFactoryMock = $this->getMockBuilder(\Magento\Catalog\Api\Data\ProductAttributeMediaGalleryEntryInterfaceFactory::class) - ->setMethods(['create']) - ->disableOriginalConstructor() - ->getMock(); + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); $this->metadataServiceMock = $this->createMock(\Magento\Catalog\Api\ProductAttributeRepositoryInterface::class); $this->attributeValueFactory = $this->getMockBuilder(\Magento\Framework\Api\AttributeValueFactory::class) @@ -870,13 +870,10 @@ public function testGetQty() */ public function testSave() { - $this->imageCache->expects($this->once()) - ->method('generate') - ->with($this->model); - $this->imageCacheFactory->expects($this->once()) - ->method('create') - ->willReturn($this->imageCache); - + $collection = $this->createMock(\Magento\Framework\Data\Collection::class); + $collection->method('count')->willReturn(1); + $collection->method('getIterator')->willReturn(new \ArrayObject([])); + $this->collectionFactoryMock->method('create')->willReturn($collection); $this->model->setIsDuplicate(false); $this->configureSaveTest(); $this->model->beforeSave(); @@ -902,13 +899,10 @@ public function testSaveIfAreaEmulated() */ public function testSaveAndDuplicate() { - $this->imageCache->expects($this->once()) - ->method('generate') - ->with($this->model); - $this->imageCacheFactory->expects($this->once()) - ->method('create') - ->willReturn($this->imageCache); - + $collection = $this->createMock(\Magento\Framework\Data\Collection::class); + $collection->method('count')->willReturn(1); + $collection->method('getIterator')->willReturn(new \ArrayObject([])); + $this->collectionFactoryMock->method('create')->willReturn($collection); $this->model->setIsDuplicate(true); $this->configureSaveTest(); $this->model->beforeSave(); @@ -1171,19 +1165,19 @@ public function testSetMediaGalleryEntries() ]; $entryMock = $this->getMockBuilder(\Magento\Catalog\Api\Data\ProductAttributeMediaGalleryEntryInterface::class) - ->setMethods( - [ - 'getId', - 'getFile', - 'getLabel', - 'getPosition', - 'isDisabled', - 'types', - 'getContent', - 'getMediaType' - ] - ) - ->getMockForAbstractClass(); + ->setMethods( + [ + 'getId', + 'getFile', + 'getLabel', + 'getPosition', + 'isDisabled', + 'types', + 'getContent', + 'getMediaType' + ] + ) + ->getMockForAbstractClass(); $result = [ 'value_id' => 1, @@ -1218,7 +1212,7 @@ public function testGetMediaGalleryImagesMerging() 'media_type' => 'image', ], [ - 'value_id' => 1, + 'value_id' => 3, 'file' => 'imageFile.jpg', ], [ @@ -1245,33 +1239,31 @@ public function testGetMediaGalleryImagesMerging() 'path' => '/var/www/html/pub/smallImageFile.jpg', ]); - $directoryMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Directory\ReadInterface::class) - ->disableOriginalConstructor() - ->getMock(); - $this->filesystemMock->expects($this->once())->method('getDirectoryRead')->willReturn($directoryMock); + $directoryMock = $this->createMock(\Magento\Framework\Filesystem\Directory\ReadInterface::class); + $directoryMock->method('getAbsolutePath')->willReturnOnConsecutiveCalls( + '/var/www/html/pub/imageFile.jpg', + '/var/www/html/pub/smallImageFile.jpg' + ); + $this->mediaConfig->method('getMediaUrl')->willReturnOnConsecutiveCalls( + 'http://magento.dev/pub/imageFile.jpg', + 'http://magento.dev/pub/smallImageFile.jpg' + ); + $this->filesystemMock->method('getDirectoryRead')->willReturn($directoryMock); $this->model->setData('media_gallery', $mediaEntries); - $imagesCollectionMock = $this->getMockBuilder(\Magento\Framework\Data\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - $this->collectionFactoryMock->expects($this->once())->method('create')->willReturn($imagesCollectionMock); - $imagesCollectionMock->expects($this->at(2)) - ->method('getItemById') - ->with(1) - ->willReturn($expectedImageDataObject); - $this->mediaConfig->expects($this->at(0)) - ->method('getMediaUrl') - ->willReturn('http://magento.dev/pub/imageFile.jpg'); - $directoryMock->expects($this->at(0)) - ->method('getAbsolutePath') - ->willReturn('/var/www/html/pub/imageFile.jpg'); - $this->mediaConfig->expects($this->at(2)) - ->method('getMediaUrl') - ->willReturn('http://magento.dev/pub/smallImageFile.jpg'); - $directoryMock->expects($this->at(1)) - ->method('getAbsolutePath') - ->willReturn('/var/www/html/pub/smallImageFile.jpg'); - $imagesCollectionMock->expects($this->at(1))->method('addItem')->with($expectedImageDataObject); - $imagesCollectionMock->expects($this->at(4))->method('addItem')->with($expectedSmallImageDataObject); + $imagesCollectionMock = $this->createMock(\Magento\Framework\Data\Collection::class); + $imagesCollectionMock->method('count')->willReturn(0); + $imagesCollectionMock->method('getItemById')->willReturnMap( + [ + [1, null], + [2, null], + [3, 'not_null_skeep_foreache'], + ] + ); + $imagesCollectionMock->expects(self::exactly(2))->method('addItem')->withConsecutive( + $expectedImageDataObject, + $expectedSmallImageDataObject + ); + $this->collectionFactoryMock->method('create')->willReturn($imagesCollectionMock); $this->model->getMediaGalleryImages(); } @@ -1305,7 +1297,7 @@ public function testGetCustomAttributes() ); //Change the attribute value, should reflect in getCustomAttribute - $this->model->setData($customAttributeCode, $newCustomAttributeValue); + $this->model->setCustomAttribute($customAttributeCode, $newCustomAttributeValue); $this->assertEquals(1, count($this->model->getCustomAttributes())); $this->assertNotNull($this->model->getCustomAttribute($customAttributeCode)); $this->assertEquals( @@ -1407,7 +1399,20 @@ public function testGetFinalPricePreset() $qty = 1; $this->model->setQty($qty); $this->model->setFinalPrice($finalPrice); - $this->productTypeInstanceMock->expects($this->never())->method('priceFactory'); + $productTypePriceMock = $this->createPartialMock( + \Magento\Catalog\Model\Product\Type\Price::class, + ['getFinalPrice'] + ); + $productTypePriceMock->expects($this->any()) + ->method('getFinalPrice') + ->with($qty, $this->model) + ->willReturn($finalPrice); + + $this->productTypeInstanceMock->expects($this->any()) + ->method('priceFactory') + ->with($this->model->getTypeId()) + ->willReturn($productTypePriceMock); + $this->assertEquals($finalPrice, $this->model->getFinalPrice($qty)); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php index 96336d2b0706a..3dcea33d5e00e 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php @@ -25,7 +25,7 @@ protected function _getAttributes() foreach ($codes as $code) { $mock = $this->createPartialMock( \Magento\Eav\Model\Entity\Attribute\AbstractAttribute::class, - ['isInSet', 'getBackend', '__wakeup'] + ['isInSet', 'getApplyTo', 'getBackend', '__wakeup'] ); $mock->setAttributeId($code); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AttributeTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AttributeTest.php new file mode 100644 index 0000000000000..0501d995aaf53 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AttributeTest.php @@ -0,0 +1,230 @@ +selectMock = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->setMethods(['from', 'where', 'join', 'deleteFromSelect']) + ->getMock(); + + $this->connectionMock = $this->getMockBuilder(Adapter::class)->getMockForAbstractClass(); + $this->connectionMock->expects($this->once())->method('select')->willReturn($this->selectMock); + $this->connectionMock->expects($this->once())->method('query')->willReturn($this->selectMock); + $this->connectionMock->expects($this->once())->method('delete')->willReturn($this->selectMock); + $this->selectMock->expects($this->once())->method('from')->willReturnSelf(); + $this->selectMock->expects($this->once())->method('join')->willReturnSelf(); + $this->selectMock->expects($this->any())->method('where')->willReturnSelf(); + $this->selectMock->expects($this->any())->method('deleteFromSelect')->willReturnSelf(); + + $this->resourceMock = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->setMethods(['delete', 'getConnection']) + ->getMock(); + + $this->contextMock = $this->getMockBuilder(Context::class)->disableOriginalConstructor()->getMock(); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class)->getMock(); + $this->eavEntityTypeMock = $this->getMockBuilder(Type::class) + ->disableOriginalConstructor() + ->getMock(); + $this->eavConfigMock = $this->getMockBuilder(\Magento\Eav\Model\Config::class) + ->disableOriginalConstructor() + ->setMethods(['getAttribute']) + ->getMock(); + $this->lockValidatorMock = $this->getMockBuilder(LockValidatorInterface::class) + ->disableOriginalConstructor() + ->setMethods(['validate']) + ->getMock(); + $this->entityMetaDataInterfaceMock = $this->getMockBuilder(EntityMetadataInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * Sets object non-public property. + * + * @param mixed $object + * @param string $propertyName + * @param mixed $value + * + * @return void + */ + private function setObjectProperty($object, string $propertyName, $value) : void + { + $reflectionClass = new \ReflectionClass($object); + $reflectionProperty = $reflectionClass->getProperty($propertyName); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($object, $value); + } + + /** + * @return void + */ + public function testDeleteEntity() : void + { + $entityAttributeId = 196; + $entityTypeId = 4; + $result = [ + 'entity_attribute_id' => 196, + 'entity_type_id' => 4, + 'attribute_set_id'=> 4, + 'attribute_group_id' => 7, + 'attribute_id' => 177, + 'sort_order' => 3, + ]; + + $backendTableName = 'weee_tax'; + $backendFieldName = 'value_id'; + + $attributeModel = $this->getMockBuilder(Attribute::class) + ->setMethods(['getEntityAttribute', 'getMetadataPool', 'getConnection', 'getTable']) + ->setConstructorArgs([ + $this->contextMock, + $this->storeManagerMock, + $this->eavEntityTypeMock, + $this->eavConfigMock, + $this->lockValidatorMock, + null, + ])->getMock(); + $attributeModel->expects($this->any()) + ->method('getEntityAttribute') + ->with($entityAttributeId) + ->willReturn($result); + $metadataPoolMock = $this->getMockBuilder(MetadataPool::class) + ->disableOriginalConstructor() + ->setMethods(['getMetadata']) + ->getMock(); + + $this->setObjectProperty($attributeModel, 'metadataPool', $metadataPoolMock); + + $eavAttributeMock = $this->getMockBuilder(\Magento\Eav\Model\Entity\Attribute\AbstractAttribute::class) + ->disableOriginalConstructor() + ->getMock(); + + $eavAttributeMock->expects($this->any())->method('getId')->willReturn($result['attribute_id']); + + $this->eavConfigMock->expects($this->any()) + ->method('getAttribute') + ->with($entityTypeId, $result['attribute_id']) + ->willReturn($eavAttributeMock); + + $abstractModelMock = $this->getMockBuilder(AbstractModel::class) + ->disableOriginalConstructor() + ->setMethods(['getEntityAttributeId','getEntityTypeId']) + ->getMockForAbstractClass(); + $abstractModelMock->expects($this->any())->method('getEntityAttributeId')->willReturn($entityAttributeId); + $abstractModelMock->expects($this->any())->method('getEntityTypeId')->willReturn($entityTypeId); + + $this->lockValidatorMock->expects($this->any()) + ->method('validate') + ->with($eavAttributeMock, $result['attribute_set_id']) + ->willReturn(true); + + $backendModelMock = $this->getMockBuilder(AbstractBackend::class) + ->disableOriginalConstructor() + ->setMethods(['getBackend', 'getTable', 'getEntityIdField']) + ->getMock(); + + $abstractAttributeMock = $this->getMockBuilder(AbstractAttribute::class) + ->disableOriginalConstructor() + ->setMethods(['getEntity']) + ->getMockForAbstractClass(); + + $eavAttributeMock->expects($this->any())->method('getBackend')->willReturn($backendModelMock); + $eavAttributeMock->expects($this->any())->method('getEntity')->willReturn($abstractAttributeMock); + + $backendModelMock->expects($this->any())->method('getTable')->willReturn($backendTableName); + $backendModelMock->expects($this->once())->method('getEntityIdField')->willReturn($backendFieldName); + + $metadataPoolMock->expects($this->any()) + ->method('getMetadata') + ->with(ProductInterface::class) + ->willReturn($this->entityMetaDataInterfaceMock); + + $this->entityMetaDataInterfaceMock->expects($this->any()) + ->method('getLinkField') + ->willReturn('row_id'); + + $attributeModel->expects($this->any())->method('getConnection')->willReturn($this->connectionMock); + $attributeModel->expects($this->any()) + ->method('getTable') + ->with('eav_entity_attribute') + ->willReturn('eav_entity_attribute'); + + $attributeModel->deleteEntity($abstractModelMock); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/CategoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/CategoryTest.php index 4812751792f18..b7d05fd2b70ee 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/CategoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/CategoryTest.php @@ -7,6 +7,7 @@ namespace Magento\Catalog\Test\Unit\Model\ResourceModel; use Magento\Catalog\Model\Factory; +use Magento\Catalog\Model\Indexer\Category\Product\Processor; use Magento\Catalog\Model\ResourceModel\Category; use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory; use Magento\Eav\Model\Config; @@ -91,6 +92,11 @@ class CategoryTest extends \PHPUnit\Framework\TestCase */ private $serializerMock; + /** + * @var Processor|\PHPUnit_Framework_MockObject_MockObject + */ + private $indexerProcessorMock; + /** * {@inheritDoc} */ @@ -121,6 +127,9 @@ protected function setUp() $this->collectionFactoryMock = $this->getMockBuilder(CollectionFactory::class) ->disableOriginalConstructor() ->getMock(); + $this->indexerProcessorMock = $this->getMockBuilder(Processor::class) + ->disableOriginalConstructor() + ->getMock(); $this->serializerMock = $this->getMockBuilder(Json::class)->getMock(); @@ -131,6 +140,7 @@ protected function setUp() $this->managerMock, $this->treeFactoryMock, $this->collectionFactoryMock, + $this->indexerProcessorMock, [], $this->serializerMock ); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php index 517b5949ee8ea..431d5736bb6dd 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php @@ -8,7 +8,9 @@ use Magento\Catalog\Model\Product\Media\ConfigInterface; use Magento\Catalog\Model\View\Asset\Image; use Magento\Framework\Encryption\EncryptorInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\View\Asset\ContextInterface; +use Magento\Framework\View\Asset\Repository; /** * Class ImageTest @@ -33,18 +35,43 @@ class ImageTest extends \PHPUnit\Framework\TestCase /** * @var ContextInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $imageContext; + protected $context; + + /** + * @var Repository|\PHPUnit_Framework_MockObject_MockObject + */ + private $assetRepo; + + private $objectManager; protected function setUp() { - $this->mediaConfig = $this->getMockBuilder(ConfigInterface::class)->getMockForAbstractClass(); - $this->encryptor = $this->getMockBuilder(EncryptorInterface::class)->getMockForAbstractClass(); - $this->imageContext = $this->getMockBuilder(ContextInterface::class)->getMockForAbstractClass(); - $this->model = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - '/somefile.png' + $this->mediaConfig = $this->createMock(ConfigInterface::class); + $this->encryptor = $this->createMock(EncryptorInterface::class); + $this->context = $this->createMock(ContextInterface::class); + $this->assetRepo = $this->createMock(Repository::class); + $this->objectManager = new ObjectManager($this); + $this->model = $this->objectManager->getObject( + Image::class, + [ + 'mediaConfig' => $this->mediaConfig, + 'imageContext' => $this->context, + 'encryptor' => $this->encryptor, + 'filePath' => '/somefile.png', + 'assetRepo' => $this->assetRepo, + 'miscParams' => [ + 'image_width' => 100, + 'image_height' => 50, + 'constrain_only' => false, + 'keep_aspect_ratio' => false, + 'keep_frame' => true, + 'keep_transparency' => false, + 'background' => '255,255,255', + 'image_type' => 'image', //thumbnail,small_image,image,swatch_image,swatch_thumb + 'quality' => 80, + 'angle' => null + ] + ] ); } @@ -80,43 +107,24 @@ public function testGetContext() */ public function testGetPath($filePath, $miscParams) { - $imageModel = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - $filePath, - $miscParams + $imageModel = $this->objectManager->getObject( + Image::class, + [ + 'mediaConfig' => $this->mediaConfig, + 'context' => $this->context, + 'encryptor' => $this->encryptor, + 'filePath' => $filePath, + 'assetRepo' => $this->assetRepo, + 'miscParams' => $miscParams + ] ); + $miscParams['background'] = isset($miscParams['background']) ? implode(',', $miscParams['background']) : ''; $absolutePath = '/var/www/html/magento2ce/pub/media/catalog/product'; $hashPath = md5(implode('_', $miscParams)); - $this->imageContext->expects($this->once())->method('getPath')->willReturn($absolutePath); - $this->encryptor->expects($this->once())->method('hash')->willReturn($hashPath); - $this->assertEquals( - $absolutePath . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . $hashPath . $filePath, - $imageModel->getPath() - ); - } - - /** - * @param string $filePath - * @param array $miscParams - * @dataProvider getPathDataProvider - */ - public function testGetNotUnixPath($filePath, $miscParams) - { - $imageModel = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - $filePath, - $miscParams - ); - $absolutePath = 'C:\www\magento2ce\pub\media\catalog\product'; - $hashPath = md5(implode('_', $miscParams)); - $this->imageContext->expects($this->once())->method('getPath')->willReturn($absolutePath); - $this->encryptor->expects($this->once())->method('hash')->willReturn($hashPath); - $this->assertEquals( - $absolutePath . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . $hashPath . $filePath, + $this->context->method('getPath')->willReturn($absolutePath); + $this->encryptor->method('hash')->willReturn($hashPath); + static::assertEquals( + $absolutePath . '/cache/'. $hashPath . $filePath, $imageModel->getPath() ); } @@ -128,19 +136,24 @@ public function testGetNotUnixPath($filePath, $miscParams) */ public function testGetUrl($filePath, $miscParams) { - $imageModel = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - $filePath, - $miscParams + $imageModel = $this->objectManager->getObject( + Image::class, + [ + 'mediaConfig' => $this->mediaConfig, + 'context' => $this->context, + 'encryptor' => $this->encryptor, + 'filePath' => $filePath, + 'assetRepo' => $this->assetRepo, + 'miscParams' => $miscParams + ] ); + $miscParams['background'] = isset($miscParams['background']) ? implode(',', $miscParams['background']) : ''; $absolutePath = 'http://localhost/pub/media/catalog/product'; $hashPath = md5(implode('_', $miscParams)); - $this->imageContext->expects($this->once())->method('getBaseUrl')->willReturn($absolutePath); - $this->encryptor->expects($this->once())->method('hash')->willReturn($hashPath); - $this->assertEquals( - $absolutePath . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . $hashPath . $filePath, + $this->context->expects(static::once())->method('getBaseUrl')->willReturn($absolutePath); + $this->encryptor->expects(static::once())->method('hash')->willReturn($hashPath); + static::assertEquals( + $absolutePath . '/cache/' . $hashPath . $filePath, $imageModel->getUrl() ); } @@ -162,7 +175,7 @@ public function getPathDataProvider() 'keep_frame' => 'frame', 'keep_transparency' => 'transparency', 'constrain_only' => 'doconstrainonly', - 'background' => 'ffffff', + 'background' => [233,1,0], 'angle' => null, 'quality' => 80, ], diff --git a/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php b/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php index f3436b8f9f09f..9225a37c3e5b4 100644 --- a/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php @@ -279,7 +279,7 @@ protected function getOptionValueMock($price) { $optionValueMock = $this->getMockBuilder(\Magento\Catalog\Model\Product\Option\Value::class) ->disableOriginalConstructor() - ->setMethods(['getPriceType', 'getPrice', 'getId', '__wakeup']) + ->setMethods(['getPriceType', 'getPrice', 'getId', '__wakeup', 'getOption', 'getData']) ->getMock(); $optionValueMock->expects($this->any()) ->method('getPriceType') @@ -288,6 +288,29 @@ protected function getOptionValueMock($price) ->method('getPrice') ->with($this->equalTo(true)) ->will($this->returnValue($price)); + + $optionValueMock->expects($this->any()) + ->method('getData') + ->with(\Magento\Catalog\Model\Product\Option\Value::KEY_PRICE) + ->willReturn($price); + + $optionMock = $this->getMockBuilder(\Magento\Catalog\Model\Product\Option::class) + ->disableOriginalConstructor() + ->setMethods(['getProduct']) + ->getMock(); + + $optionValueMock->expects($this->any())->method('getOption')->willReturn($optionMock); + + $optionMock->expects($this->any())->method('getProduct')->willReturn($this->product); + + $priceMock = $this->getMockBuilder(\Magento\Framework\Pricing\Price\PriceInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getValue']) + ->getMockForAbstractClass(); + $priceMock->method('getValue')->willReturn($price); + + $this->priceInfo->method('getPrice')->willReturn($priceMock); + return $optionValueMock; } diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/Component/Product/MassActionTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/Component/Product/MassActionTest.php new file mode 100644 index 0000000000000..604136bb3ad4b --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Ui/Component/Product/MassActionTest.php @@ -0,0 +1,238 @@ +objectManager = new ObjectManager($this); + + $this->contextMock = $this->getMockBuilder(ContextInterface::class) + ->getMockForAbstractClass(); + $this->authorizationMock = $this->getMockBuilder(AuthorizationInterface::class) + ->getMockForAbstractClass(); + + $this->massAction = $this->objectManager->getObject( + MassAction::class, + [ + 'authorization' => $this->authorizationMock, + 'context' => $this->contextMock, + 'data' => [] + ] + ); + } + + public function testGetComponentName() + { + $this->assertTrue($this->massAction->getComponentName() === MassAction::NAME); + } + + /** + * @param string $componentName + * @param array $componentData + * @param bool $isAllowed + * @param bool $expectActionConfig + * @return void + * @dataProvider getPrepareDataProvider + */ + public function testPrepare($componentName, $componentData, $isAllowed = true, $expectActionConfig = true) + { + $processor = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponent\Processor::class) + ->disableOriginalConstructor() + ->getMock(); + $this->contextMock->expects($this->atLeastOnce())->method('getProcessor')->willReturn($processor); + /** @var \Magento\Ui\Component\MassAction $action */ + $action = $this->objectManager->getObject( + \Magento\Ui\Component\MassAction::class, + [ + 'context' => $this->contextMock, + 'data' => [ + 'name' => $componentName, + 'config' => $componentData, + ] + ] + ); + $this->authorizationMock->method('isAllowed') + ->willReturn($isAllowed); + $this->massAction->addComponent('action', $action); + $this->massAction->prepare(); + $expected = $expectActionConfig ? ['actions' => [$action->getConfiguration()]] : []; + $this->assertEquals($expected, $this->massAction->getConfiguration()); + } + + /** + * @return array + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function getPrepareDataProvider() : array + { + return [ + [ + 'test_component1', + [ + 'type' => 'first_action', + 'label' => 'First Action', + 'url' => '/module/controller/firstAction' + ], + ], + [ + 'test_component2', + [ + 'type' => 'second_action', + 'label' => 'Second Action', + 'actions' => [ + [ + 'type' => 'second_sub_action1', + 'label' => 'Second Sub Action 1', + 'url' => '/module/controller/secondSubAction1' + ], + [ + 'type' => 'second_sub_action2', + 'label' => 'Second Sub Action 2', + 'url' => '/module/controller/secondSubAction2' + ], + ] + ], + ], + [ + 'status_component', + [ + 'type' => 'status', + 'label' => 'Status', + 'actions' => [ + [ + 'type' => 'enable', + 'label' => 'Second Sub Action 1', + 'url' => '/module/controller/enable' + ], + [ + 'type' => 'disable', + 'label' => 'Second Sub Action 2', + 'url' => '/module/controller/disable' + ], + ] + ], + ], + [ + 'status_component_not_allowed', + [ + 'type' => 'status', + 'label' => 'Status', + 'actions' => [ + [ + 'type' => 'enable', + 'label' => 'Second Sub Action 1', + 'url' => '/module/controller/enable' + ], + [ + 'type' => 'disable', + 'label' => 'Second Sub Action 2', + 'url' => '/module/controller/disable' + ], + ] + ], + false, + false + ], + [ + 'delete_component', + [ + 'type' => 'delete', + 'label' => 'First Action', + 'url' => '/module/controller/delete' + ], + ], + [ + 'delete_component_not_allowed', + [ + 'type' => 'delete', + 'label' => 'First Action', + 'url' => '/module/controller/delete' + ], + false, + false + ], + [ + 'attributes_component', + [ + 'type' => 'delete', + 'label' => 'First Action', + 'url' => '/module/controller/attributes' + ], + ], + [ + 'attributes_component_not_allowed', + [ + 'type' => 'delete', + 'label' => 'First Action', + 'url' => '/module/controller/attributes' + ], + false, + false + ], + ]; + } + + /** + * @param bool $expected + * @param string $actionType + * @param int $callNum + * @param string $resource + * @param bool $isAllowed + * @dataProvider isActionAllowedDataProvider + */ + public function testIsActionAllowed($expected, $actionType, $callNum, $resource = '', $isAllowed = true) + { + $this->authorizationMock->expects($this->exactly($callNum)) + ->method('isAllowed') + ->with($resource) + ->willReturn($isAllowed); + + $this->assertEquals($expected, $this->massAction->isActionAllowed($actionType)); + } + + public function isActionAllowedDataProvider() + { + return [ + 'other' => [true, 'other', 0,], + 'delete-allowed' => [true, 'delete', 1, 'Magento_Catalog::products'], + 'delete-not-allowed' => [false, 'delete', 1, 'Magento_Catalog::products', false], + 'status-allowed' => [true, 'status', 1, 'Magento_Catalog::products'], + 'status-not-allowed' => [false, 'status', 1, 'Magento_Catalog::products', false], + 'attributes-allowed' => [true, 'attributes', 1, 'Magento_Catalog::update_attributes'], + 'attributes-not-allowed' => [false, 'attributes', 1, 'Magento_Catalog::update_attributes', false], + + ]; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AbstractModifierTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AbstractModifierTest.php index af10eeea42fd3..473f1aea33618 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AbstractModifierTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AbstractModifierTest.php @@ -63,7 +63,8 @@ protected function setUp() 'getAttributes', 'getStore', 'getAttributeDefaultValue', - 'getExistsStoreValueFlag' + 'getExistsStoreValueFlag', + 'isLockedAttribute' ])->getMockForAbstractClass(); $this->storeMock = $this->getMockBuilder(StoreInterface::class) ->setMethods(['load', 'getId', 'getConfig']) @@ -81,9 +82,6 @@ protected function setUp() $this->arrayManagerMock->expects($this->any()) ->method('set') ->willReturnArgument(1); - $this->arrayManagerMock->expects($this->any()) - ->method('merge') - ->willReturnArgument(1); $this->arrayManagerMock->expects($this->any()) ->method('remove') ->willReturnArgument(1); diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AttributeSetTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AttributeSetTest.php index c22dde0b456ac..5fc6231b03735 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AttributeSetTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/AttributeSetTest.php @@ -5,6 +5,7 @@ */ namespace Magento\Catalog\Test\Unit\Ui\DataProvider\Product\Form\Modifier; +use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier; use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AttributeSet; use Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory; use Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\Collection; @@ -84,7 +85,30 @@ protected function createModel() public function testModifyMeta() { - $this->assertNotEmpty($this->getModel()->modifyMeta(['test_group' => []])); + $modifyMeta = $this->getModel()->modifyMeta(['test_group' => []]); + $this->assertNotEmpty($modifyMeta); + } + + /** + * @param bool $locked + * @dataProvider modifyMetaLockedDataProvider + */ + public function testModifyMetaLocked($locked) + { + $this->productMock->expects($this->any()) + ->method('isLockedAttribute') + ->willReturn($locked); + $modifyMeta = $this->getModel()->modifyMeta([AbstractModifier::DEFAULT_GENERAL_PANEL => []]); + $children = $modifyMeta[AbstractModifier::DEFAULT_GENERAL_PANEL]['children']; + $this->assertEquals( + $locked, + $children['attribute_set_id']['arguments']['data']['config']['disabled'] + ); + } + + public function modifyMetaLockedDataProvider() + { + return [[true], [false]]; } public function testModifyMetaToBeEmpty() diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CategoriesTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CategoriesTest.php index 4daff7e7930e3..5f5913c20209a 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CategoriesTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CategoriesTest.php @@ -114,6 +114,44 @@ public function testModifyMeta() $this->assertArrayHasKey($groupCode, $this->getModel()->modifyMeta($meta)); } + /** + * @param bool $locked + * @dataProvider modifyMetaLockedDataProvider + */ + public function testModifyMetaLocked($locked) + { + $groupCode = 'test_group_code'; + $meta = [ + $groupCode => [ + 'children' => [ + 'category_ids' => [ + 'sortOrder' => 10, + ], + ], + ], + ]; + + $this->arrayManagerMock->expects($this->any()) + ->method('findPath') + ->willReturn('path'); + + $this->productMock->expects($this->any()) + ->method('isLockedAttribute') + ->willReturn($locked); + + $this->arrayManagerMock->expects($this->any()) + ->method('merge') + ->willReturnArgument(2); + + $modifyMeta = $this->createModel()->modifyMeta($meta); + $this->assertEquals($locked, $modifyMeta['arguments']['data']['config']['disabled']); + } + + public function modifyMetaLockedDataProvider() + { + return [[true], [false]]; + } + public function testModifyMetaWithCaching() { $this->arrayManagerMock->expects($this->exactly(2)) diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/EavTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/EavTest.php index a29379647b9e1..22bb712d42f0f 100755 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/EavTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/EavTest.php @@ -5,11 +5,10 @@ */ namespace Magento\Catalog\Test\Unit\Ui\DataProvider\Product\Form\Modifier; -use Magento\Catalog\Model\Product\Type; use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Eav; use Magento\Eav\Model\Config; +use Magento\Eav\Model\Entity\Attribute\Source\SourceInterface; use Magento\Framework\App\RequestInterface; -use Magento\Framework\EntityManager\EventManager; use Magento\Framework\Phrase; use Magento\Store\Model\StoreManagerInterface; use Magento\Store\Api\Data\StoreInterface; @@ -257,7 +256,15 @@ protected function setUp() $this->searchResultsMock = $this->getMockBuilder(SearchResultsInterface::class) ->getMockForAbstractClass(); $this->eavAttributeMock = $this->getMockBuilder(Attribute::class) - ->setMethods(['load', 'getAttributeGroupCode', 'getApplyTo', 'getFrontendInput', 'getAttributeCode']) + ->setMethods([ + 'load', + 'getAttributeGroupCode', + 'getApplyTo', + 'getFrontendInput', + 'getAttributeCode', + 'usesSource', + 'getSource', + ]) ->disableOriginalConstructor() ->getMock(); $this->productAttributeMock = $this->getMockBuilder(ProductAttributeInterface::class) @@ -451,64 +458,63 @@ public function testModifyData() } /** - * @param int $productId + * @param int|null $productId * @param bool $productRequired - * @param string $attrValue - * @param string $note + * @param string|null $attrValue * @param array $expected + * @param bool $locked * @covers \Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Eav::isProductExists * @covers \Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Eav::setupAttributeMeta * @dataProvider setupAttributeMetaDataProvider */ - public function testSetupAttributeMetaDefaultAttribute($productId, $productRequired, $attrValue, $note, $expected) - { - $configPath = 'arguments/data/config'; + public function testSetupAttributeMetaDefaultAttribute( + $productId, + bool $productRequired, + $attrValue, + array $expected, + $locked = false + ) : void { + $configPath = 'arguments/data/config'; $groupCode = 'product-details'; $sortOrder = '0'; + $attributeOptions = [ + ['value' => 1, 'label' => 'Int label'], + ['value' => 1.5, 'label' => 'Float label'], + ['value' => true, 'label' => 'Boolean label'], + ['value' => 'string', 'label' => 'String label'], + ['value' => ['test1', 'test2'], 'label' => 'Array label'], + ]; + $attributeOptionsExpected = [ + ['value' => '1', 'label' => 'Int label'], + ['value' => '1.5', 'label' => 'Float label'], + ['value' => '1', 'label' => 'Boolean label'], + ['value' => 'string', 'label' => 'String label'], + ['value' => ['test1', 'test2'], 'label' => 'Array label'], + ]; - $this->productMock->expects($this->any()) - ->method('getId') - ->willReturn($productId); - - $this->productAttributeMock->expects($this->any()) - ->method('getIsRequired') - ->willReturn($productRequired); - - $this->productAttributeMock->expects($this->any()) - ->method('getDefaultValue') - ->willReturn('required_value'); - - $this->productAttributeMock->expects($this->any()) - ->method('getAttributeCode') - ->willReturn('code'); - - $this->productAttributeMock->expects($this->any()) - ->method('getValue') - ->willReturn('value'); - - $this->productAttributeMock->expects($this->any()) - ->method('getNote') - ->willReturn($note); - - $this->productAttributeMock->expects($this->any()) - ->method('getDefaultFrontendLabel') - ->willReturn(new Phrase('mylabel')); + $this->productMock->method('getId')->willReturn($productId); + $this->productMock->expects($this->any())->method('isLockedAttribute')->willReturn($locked); + $this->productAttributeMock->method('getIsRequired')->willReturn($productRequired); + $this->productAttributeMock->method('getDefaultValue')->willReturn('required_value'); + $this->productAttributeMock->method('getAttributeCode')->willReturn('code'); + $this->productAttributeMock->method('getValue')->willReturn('value'); $attributeMock = $this->getMockBuilder(AttributeInterface::class) ->setMethods(['getValue']) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $attributeMock->expects($this->any()) - ->method('getValue') - ->willReturn($attrValue); + $attributeMock->method('getValue')->willReturn($attrValue); - $this->productMock->expects($this->any()) - ->method('getCustomAttribute') - ->willReturn($attributeMock); + $this->productMock->method('getCustomAttribute')->willReturn($attributeMock); + $this->eavAttributeMock->method('usesSource')->willReturn(true); - $this->arrayManagerMock->expects($this->any()) - ->method('set') + $attributeSource = $this->getMockBuilder(SourceInterface::class)->getMockForAbstractClass(); + $attributeSource->method('getAllOptions')->willReturn($attributeOptions); + + $this->eavAttributeMock->method('getSource')->willReturn($attributeSource); + + $this->arrayManagerMock->method('set') ->with( $configPath, [], @@ -518,14 +524,19 @@ public function testSetupAttributeMetaDefaultAttribute($productId, $productRequi $this->arrayManagerMock->expects($this->any()) ->method('merge') + ->with( + $this->anything(), + $this->anything(), + $this->callback( + function ($value) use ($attributeOptionsExpected) { + return isset($value['options']) ? $value['options'] === $attributeOptionsExpected : true; + } + ) + ) ->willReturn($expected); - $this->arrayManagerMock->expects($this->any()) - ->method('get') - ->willReturn([]); - - $this->arrayManagerMock->expects($this->any()) - ->method('exists'); + $this->arrayManagerMock->method('get')->willReturn([]); + $this->arrayManagerMock->method('exists')->willReturn(true); $this->assertEquals( $expected, @@ -535,151 +546,127 @@ public function testSetupAttributeMetaDefaultAttribute($productId, $productRequi /** * @return array + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function setupAttributeMetaDataProvider() { return [ - 'default_null_prod_not_new_and_required' => $this->defaultNullProdNotNewAndRequired(), - 'default_null_prod_not_new_and_not_required' => $this->defaultNullProdNotNewAndNotRequired(), - 'default_null_prod_new_and_not_required' => $this->defaultNullProdNewAndNotRequired(), - 'default_null_prod_new_and_required' => $this->defaultNullProdNewAndRequired(), - 'default_null_prod_new_and_required_and_filled_notice' => - $this->defaultNullProdNewAndRequiredAndFilledNotice() - ]; - } - - /** - * @return array - */ - private function defaultNullProdNotNewAndRequired() - { - return [ - 'productId' => 1, - 'productRequired' => true, - 'attrValue' => 'val', - 'note' => null, - 'expected' => [ - 'dataType' => null, - 'formElement' => null, - 'visible' => null, - 'required' => true, - 'notice' => null, - 'default' => null, - 'label' => new Phrase('mylabel'), - 'code' => 'code', - 'source' => 'product-details', - 'scopeLabel' => '', - 'globalScope' => false, - 'sortOrder' => 0 + 'default_null_prod_not_new_and_required' => [ + 'productId' => 1, + 'productRequired' => true, + 'attrValue' => 'val', + 'expected' => [ + 'dataType' => null, + 'formElement' => null, + 'visible' => null, + 'required' => true, + 'notice' => null, + 'default' => null, + 'label' => new Phrase(null), + 'code' => 'code', + 'source' => 'product-details', + 'scopeLabel' => '', + 'globalScope' => false, + 'sortOrder' => 0, + ], ], - ]; - } - - /** - * @return array - */ - private function defaultNullProdNotNewAndNotRequired() - { - return [ - 'productId' => 1, - 'productRequired' => false, - 'attrValue' => 'val', - 'note' => null, - 'expected' => [ - 'dataType' => null, - 'formElement' => null, - 'visible' => null, - 'required' => false, - 'notice' => null, - 'default' => null, - 'label' => new Phrase('mylabel'), - 'code' => 'code', - 'source' => 'product-details', - 'scopeLabel' => '', - 'globalScope' => false, - 'sortOrder' => 0 + 'default_null_prod_not_new_locked_and_required' => [ + 'productId' => 1, + 'productRequired' => true, + 'attrValue' => 'val', + 'expected' => [ + 'dataType' => null, + 'formElement' => null, + 'visible' => null, + 'required' => true, + 'notice' => null, + 'default' => null, + 'label' => new Phrase(null), + 'code' => 'code', + 'source' => 'product-details', + 'scopeLabel' => '', + 'globalScope' => false, + 'sortOrder' => 0, + ], + 'locked' => true, ], - ]; - } - - /** - * @return array - */ - private function defaultNullProdNewAndNotRequired() - { - return [ - 'productId' => null, - 'productRequired' => false, - 'attrValue' => null, - 'note' => null, - 'expected' => [ - 'dataType' => null, - 'formElement' => null, - 'visible' => null, - 'required' => false, - 'notice' => null, - 'default' => 'required_value', - 'label' => new Phrase('mylabel'), - 'code' => 'code', - 'source' => 'product-details', - 'scopeLabel' => '', - 'globalScope' => false, - 'sortOrder' => 0 + 'default_null_prod_not_new_and_not_required' => [ + 'productId' => 1, + 'productRequired' => false, + 'attrValue' => 'val', + 'expected' => [ + 'dataType' => null, + 'formElement' => null, + 'visible' => null, + 'required' => false, + 'notice' => null, + 'default' => null, + 'label' => new Phrase(null), + 'code' => 'code', + 'source' => 'product-details', + 'scopeLabel' => '', + 'globalScope' => false, + 'sortOrder' => 0, + ], ], - ]; - } - - /** - * @return array - */ - private function defaultNullProdNewAndRequired() - { - return [ - 'productId' => null, - 'productRequired' => false, - 'attrValue' => null, - 'note' => null, - 'expected' => [ - 'dataType' => null, - 'formElement' => null, - 'visible' => null, - 'required' => false, - 'notice' => null, - 'default' => 'required_value', - 'label' => new Phrase('mylabel'), - 'code' => 'code', - 'source' => 'product-details', - 'scopeLabel' => '', - 'globalScope' => false, - 'sortOrder' => 0 + 'default_null_prod_new_and_not_required' => [ + 'productId' => null, + 'productRequired' => false, + 'attrValue' => null, + 'expected' => [ + 'dataType' => null, + 'formElement' => null, + 'visible' => null, + 'required' => false, + 'notice' => null, + 'default' => 'required_value', + 'label' => new Phrase(null), + 'code' => 'code', + 'source' => 'product-details', + 'scopeLabel' => '', + 'globalScope' => false, + 'sortOrder' => 0, + ], ], - ]; - } - - /** - * @return array - */ - private function defaultNullProdNewAndRequiredAndFilledNotice() - { - return [ - 'productId' => null, - 'productRequired' => false, - 'attrValue' => null, - 'note' => 'example notice', - 'expected' => [ - 'dataType' => null, - 'formElement' => null, - 'visible' => null, - 'required' => false, - 'notice' => __('example notice'), - 'default' => 'required_value', - 'label' => new Phrase('mylabel'), - 'code' => 'code', - 'source' => 'product-details', - 'scopeLabel' => '', - 'globalScope' => false, - 'sortOrder' => 0 + 'default_null_prod_new_locked_and_not_required' => [ + 'productId' => null, + 'productRequired' => false, + 'attrValue' => null, + 'expected' => [ + 'dataType' => null, + 'formElement' => null, + 'visible' => null, + 'required' => false, + 'notice' => null, + 'default' => 'required_value', + 'label' => new Phrase(null), + 'code' => 'code', + 'source' => 'product-details', + 'scopeLabel' => '', + 'globalScope' => false, + 'sortOrder' => 0, + ], + 'locked' => true, ], + 'default_null_prod_new_and_required' => [ + 'productId' => null, + 'productRequired' => false, + 'attrValue' => null, + 'expected' => [ + 'dataType' => null, + 'formElement' => null, + 'visible' => null, + 'required' => false, + 'notice' => null, + 'default' => 'required_value', + 'label' => new Phrase(null), + 'code' => 'code', + 'source' => 'product-details', + 'scopeLabel' => '', + 'globalScope' => false, + 'sortOrder' => 0, + ], + ] ]; } } diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GeneralTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GeneralTest.php index b4460b314513b..a9d717db7b7f9 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GeneralTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GeneralTest.php @@ -5,8 +5,11 @@ */ namespace Magento\Catalog\Test\Unit\Ui\DataProvider\Product\Form\Modifier; -use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Api\Data\ProductAttributeInterface; use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\General; +use Magento\Eav\Api\AttributeRepositoryInterface; +use Magento\Eav\Api\Data\AttributeInterface; +use Magento\Framework\Stdlib\ArrayManager; /** * Class GeneralTest @@ -15,6 +18,35 @@ */ class GeneralTest extends AbstractModifierTest { + /** + * @var AttributeRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $attributeRepositoryMock; + + /** + * @var General + */ + private $generalModifier; + + protected function setUp() + { + parent::setUp(); + + $this->attributeRepositoryMock = $this->getMockBuilder(AttributeRepositoryInterface::class) + ->getMockForAbstractClass(); + + $arrayManager = $this->objectManager->getObject(ArrayManager::class); + + $this->generalModifier = $this->objectManager->getObject( + General::class, + [ + 'attributeRepository' => $this->attributeRepositoryMock, + 'locator' => $this->locatorMock, + 'arrayManager' => $arrayManager, + ] + ); + } + /** * {@inheritdoc} */ @@ -28,6 +60,9 @@ protected function createModel() public function testModifyMeta() { + $this->arrayManagerMock->expects($this->any()) + ->method('merge') + ->willReturnArgument(2); $this->assertNotEmpty($this->getModel()->modifyMeta([ 'first_panel_code' => [ 'arguments' => [ @@ -40,4 +75,59 @@ public function testModifyMeta() ] ])); } + + /** + * @param array $data + * @param int $defaultStatusValue + * @param array $expectedResult + * @throws \Magento\Framework\Exception\NoSuchEntityException + * @dataProvider modifyDataDataProvider + */ + public function testModifyDataNewProduct(array $data, int $defaultStatusValue, array $expectedResult) + { + $attributeMock = $this->getMockBuilder(AttributeInterface::class) + ->getMockForAbstractClass(); + $attributeMock + ->method('getDefaultValue') + ->willReturn($defaultStatusValue); + $this->attributeRepositoryMock + ->method('get') + ->with( + ProductAttributeInterface::ENTITY_TYPE_CODE, + ProductAttributeInterface::CODE_STATUS + ) + ->willReturn($attributeMock); + $this->assertSame($expectedResult, $this->generalModifier->modifyData($data)); + } + + /** + * @return array + */ + public function modifyDataDataProvider(): array + { + return [ + 'With default status value' => [ + 'data' => [], + 'defaultStatusAttributeValue' => 5, + 'expectedResult' => [ + null => [ + General::DATA_SOURCE_DEFAULT => [ + ProductAttributeInterface::CODE_STATUS => 5, + ], + ], + ], + ], + 'Without default status value' => [ + 'data' => [], + 'defaultStatusAttributeValue' => 0, + 'expectedResult' => [ + null => [ + General::DATA_SOURCE_DEFAULT => [ + ProductAttributeInterface::CODE_STATUS => 1, + ], + ], + ], + ], + ]; + } } diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/ScheduleDesignUpdateTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/ScheduleDesignUpdateTest.php index d4d4136bf4157..783c6247b9df3 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/ScheduleDesignUpdateTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/ScheduleDesignUpdateTest.php @@ -24,6 +24,9 @@ protected function createModel() public function testModifyMeta() { + $this->arrayManagerMock->expects($this->any()) + ->method('merge') + ->willReturnArgument(1); $this->assertSame([], $this->getModel()->modifyMeta([])); } diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/WebsitesTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/WebsitesTest.php index 997b66861c21b..c3096770729a6 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/WebsitesTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/WebsitesTest.php @@ -76,7 +76,10 @@ class WebsitesTest extends AbstractModifierTest protected function setUp() { - $this->objectManager = new ObjectManager($this); + parent::setUp(); + $this->productMock->expects($this->any()) + ->method('getId') + ->willReturn(self::PRODUCT_ID); $this->assignedWebsites = [self::SECOND_WEBSITE_ID]; $this->websiteMock = $this->getMockBuilder(\Magento\Store\Model\Website::class) ->setMethods(['getId', 'getName']) @@ -101,15 +104,9 @@ protected function setUp() $this->storeRepositoryMock = $this->getMockBuilder(\Magento\Store\Api\StoreRepositoryInterface::class) ->setMethods(['getList']) ->getMockForAbstractClass(); - $this->locatorMock = $this->getMockBuilder(\Magento\Catalog\Model\Locator\LocatorInterface::class) - ->setMethods(['getProduct', 'getWebsiteIds']) - ->getMockForAbstractClass(); $this->productMock = $this->getMockBuilder(\Magento\Catalog\Api\Data\ProductInterface::class) ->setMethods(['getId']) ->getMockForAbstractClass(); - $this->locatorMock->expects($this->any()) - ->method('getProduct') - ->willReturn($this->productMock); $this->locatorMock->expects($this->any()) ->method('getWebsiteIds') ->willReturn($this->assignedWebsites); @@ -148,9 +145,6 @@ protected function setUp() $this->storeRepositoryMock->expects($this->any()) ->method('getList') ->willReturn([$this->storeViewMock]); - $this->productMock->expects($this->any()) - ->method('getId') - ->willReturn(self::PRODUCT_ID); $this->secondWebsiteMock->expects($this->any()) ->method('getId') ->willReturn($this->assignedWebsites[0]); diff --git a/app/code/Magento/Catalog/Test/Unit/ViewModel/Product/BreadcrumbsTest.php b/app/code/Magento/Catalog/Test/Unit/ViewModel/Product/BreadcrumbsTest.php new file mode 100644 index 0000000000000..316e08a56f99c --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/ViewModel/Product/BreadcrumbsTest.php @@ -0,0 +1,129 @@ +catalogHelper = $this->getMockBuilder(CatalogHelper::class) + ->setMethods(['getProduct']) + ->disableOriginalConstructor() + ->getMock(); + + $this->scopeConfig = $this->getMockBuilder(ScopeConfigInterface::class) + ->setMethods(['getValue', 'isSetFlag']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->viewModel = $this->getObjectManager()->getObject( + Breadcrumbs::class, + [ + 'catalogData' => $this->catalogHelper, + 'scopeConfig' => $this->scopeConfig, + ] + ); + } + + /** + * @return void + */ + public function testGetCategoryUrlSuffix() + { + $this->scopeConfig->expects($this->once()) + ->method('getValue') + ->with('catalog/seo/category_url_suffix', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn('.html'); + + $this->assertEquals('.html', $this->viewModel->getCategoryUrlSuffix()); + } + + /** + * @return void + */ + public function testIsCategoryUsedInProductUrl() + { + $this->scopeConfig->expects($this->once()) + ->method('isSetFlag') + ->with('catalog/seo/product_use_categories', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn(false); + + $this->assertFalse($this->viewModel->isCategoryUsedInProductUrl()); + } + + /** + * @dataProvider productDataProvider + * + * @param Product|null $product + * @param string $expectedName + * @return void + */ + public function testGetProductName($product, string $expectedName) + { + $this->catalogHelper->expects($this->atLeastOnce()) + ->method('getProduct') + ->willReturn($product); + + $this->assertEquals($expectedName, $this->viewModel->getProductName()); + } + + /** + * @return array + */ + public function productDataProvider() + { + return [ + [$this->getObjectManager()->getObject(Product::class, ['data' => ['name' => 'Test']]), 'Test'], + [null, ''], + ]; + } + + /** + * @return ObjectManager + */ + private function getObjectManager() + { + if (null === $this->objectManager) { + $this->objectManager = new ObjectManager($this); + } + + return $this->objectManager; + } +} diff --git a/app/code/Magento/Catalog/Ui/Component/Product/MassAction.php b/app/code/Magento/Catalog/Ui/Component/Product/MassAction.php new file mode 100644 index 0000000000000..894e2b701b5ac --- /dev/null +++ b/app/code/Magento/Catalog/Ui/Component/Product/MassAction.php @@ -0,0 +1,98 @@ +authorization = $authorization; + parent::__construct($context, $components, $data); + } + + /** + * {@inheritdoc} + */ + public function prepare() : void + { + $config = $this->getConfiguration(); + + foreach ($this->getChildComponents() as $actionComponent) { + $actionType = $actionComponent->getConfiguration()['type']; + if ($this->isActionAllowed($actionType)) { + $config['actions'][] = $actionComponent->getConfiguration(); + } + } + $origConfig = $this->getConfiguration(); + if ($origConfig !== $config) { + $config = array_replace_recursive($config, $origConfig); + } + + $this->setData('config', $config); + $this->components = []; + + parent::prepare(); + } + + /** + * {@inheritdoc} + */ + public function getComponentName() : string + { + return static::NAME; + } + + /** + * Check if the given type of action is allowed + * + * @param string $actionType + * @return bool + */ + public function isActionAllowed($actionType) : bool + { + $isAllowed = true; + switch ($actionType) { + case 'delete': + $isAllowed = $this->authorization->isAllowed('Magento_Catalog::products'); + break; + case 'status': + $isAllowed = $this->authorization->isAllowed('Magento_Catalog::products'); + break; + case 'attributes': + $isAllowed = $this->authorization->isAllowed('Magento_Catalog::update_attributes'); + break; + default: + break; + } + return $isAllowed; + } +} diff --git a/app/code/Magento/Catalog/Ui/Component/UrlInput/Category.php b/app/code/Magento/Catalog/Ui/Component/UrlInput/Category.php index 836bc5058777d..01d93de577927 100644 --- a/app/code/Magento/Catalog/Ui/Component/UrlInput/Category.php +++ b/app/code/Magento/Catalog/Ui/Component/UrlInput/Category.php @@ -46,6 +46,7 @@ public function getConfig(): array 'sortOrder' => 30, 'missingValuePlaceholder' => __('Category with ID: %s doesn\'t exist'), 'isDisplayMissingValuePlaceholder' => true, + 'isRemoveSelectedIcon' => true, ]; } } diff --git a/app/code/Magento/Catalog/Ui/Component/UrlInput/Product.php b/app/code/Magento/Catalog/Ui/Component/UrlInput/Product.php index efa8417e4686a..be73940237db4 100644 --- a/app/code/Magento/Catalog/Ui/Component/UrlInput/Product.php +++ b/app/code/Magento/Catalog/Ui/Component/UrlInput/Product.php @@ -50,6 +50,7 @@ public function getConfig(): array 'emptyOptionsHtml' => __('Start typing to find products'), 'missingValuePlaceholder' => __('Product with ID: %s doesn\'t exist'), 'isDisplayMissingValuePlaceholder' => true, + 'isRemoveSelectedIcon' => true, 'validationUrl' => $this->urlBuilder->getUrl('catalog/product/getSelected'), ]; } diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php index a8378c364a63e..336aeffa10584 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php @@ -432,7 +432,8 @@ private function getTierPriceStructure($tierPricePath) 'dndConfig' => [ 'enabled' => false, ], - 'disabled' => false, + 'disabled' => + $this->arrayManager->get($tierPricePath . '/arguments/data/config/disabled', $this->meta), 'required' => false, 'sortOrder' => $this->arrayManager->get($tierPricePath . '/arguments/data/config/sortOrder', $this->meta), @@ -500,7 +501,8 @@ private function getTierPriceStructure($tierPricePath) 'validation' => [ 'required-entry' => true, 'validate-greater-than-zero' => true, - 'validate-digits' => true, + 'validate-digits' => false, + 'validate-number' => true, ], ], ], diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AttributeSet.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AttributeSet.php index a1aacc91f2e47..0733d21bf47d7 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AttributeSet.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AttributeSet.php @@ -108,6 +108,7 @@ public function modifyMeta(array $meta) self::ATTRIBUTE_SET_FIELD_ORDER ), 'multiple' => false, + 'disabled' => $this->locator->getProduct()->isLockedAttribute('attribute_set_id'), ]; } diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php index aec6549f400fc..683a96133ad30 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php @@ -182,6 +182,11 @@ private function customizeAddAttributeModal(array $meta) . '.create_new_attribute_modal', 'actionName' => 'toggleModal', ], + [ + 'targetName' => 'product_form.product_form.add_attribute_modal' + . '.create_new_attribute_modal.product_attribute_add_form', + 'actionName' => 'destroyInserted' + ], [ 'targetName' => 'product_form.product_form.add_attribute_modal' diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Categories.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Categories.php index 7456c1bfef91f..ed737df708ab8 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Categories.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Categories.php @@ -228,6 +228,7 @@ protected function customizeCategoriesField(array $meta) 'componentType' => 'container', 'component' => 'Magento_Ui/js/form/components/group', 'scopeLabel' => __('[GLOBAL]'), + 'disabled' => $this->locator->getProduct()->isLockedAttribute($fieldCode), ], ], ], diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php index b216ee8c9c547..7cd81419c0347 100755 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php @@ -41,6 +41,7 @@ * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @since 101.0.0 */ class Eav extends AbstractModifier @@ -593,6 +594,7 @@ private function isProductExists() public function setupAttributeMeta(ProductAttributeInterface $attribute, $groupCode, $sortOrder) { $configPath = ltrim(static::META_CONFIG_PATH, ArrayManager::DEFAULT_PATH_DELIMITER); + $attributeCode = $attribute->getAttributeCode(); $meta = $this->arrayManager->set($configPath, [], [ 'dataType' => $attribute->getFrontendInput(), 'formElement' => $this->getFormElementsMapValue($attribute->getFrontendInput()), @@ -601,7 +603,7 @@ public function setupAttributeMeta(ProductAttributeInterface $attribute, $groupC 'notice' => $attribute->getNote() === null ? null : __($attribute->getNote()), 'default' => (!$this->isProductExists()) ? $this->getAttributeDefaultValue($attribute) : null, 'label' => __($attribute->getDefaultFrontendLabel()), - 'code' => $attribute->getAttributeCode(), + 'code' => $attributeCode, 'source' => $groupCode, 'scopeLabel' => $this->getScopeLabel($attribute), 'globalScope' => $this->isScopeGlobal($attribute), @@ -611,8 +613,9 @@ public function setupAttributeMeta(ProductAttributeInterface $attribute, $groupC // TODO: Refactor to $attribute->getOptions() when MAGETWO-48289 is done $attributeModel = $this->getAttributeModel($attribute); if ($attributeModel->usesSource()) { + $options = $attributeModel->getSource()->getAllOptions(); $meta = $this->arrayManager->merge($configPath, $meta, [ - 'options' => $attributeModel->getSource()->getAllOptions(), + 'options' => $this->convertOptionsValueToString($options), ]); } @@ -630,7 +633,9 @@ public function setupAttributeMeta(ProductAttributeInterface $attribute, $groupC ]); } - if (in_array($attribute->getAttributeCode(), $this->attributesToDisable)) { + $product = $this->locator->getProduct(); + if (in_array($attributeCode, $this->attributesToDisable) + || $product->isLockedAttribute($attributeCode)) { $meta = $this->arrayManager->merge($configPath, $meta, [ 'disabled' => true, ]); @@ -683,6 +688,23 @@ private function getAttributeDefaultValue(ProductAttributeInterface $attribute) return $attribute->getDefaultValue(); } + /** + * Convert options value to string. + * + * @param array $options + * @return array + */ + private function convertOptionsValueToString(array $options) : array + { + array_walk($options, function (&$value) { + if (isset($value['value']) && is_scalar($value['value'])) { + $value['value'] = (string)$value['value']; + } + }); + + return $options; + } + /** * @param ProductAttributeInterface $attribute * @param array $meta diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php index ea69ebf4dda24..98de8ea347671 100755 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php @@ -7,6 +7,7 @@ use Magento\Catalog\Api\Data\ProductAttributeInterface; use Magento\Catalog\Model\Locator\LocatorInterface; +use Magento\Eav\Api\AttributeRepositoryInterface; use Magento\Ui\Component\Form; use Magento\Framework\Stdlib\ArrayManager; @@ -35,21 +36,31 @@ class General extends AbstractModifier */ private $localeCurrency; + /** + * @var AttributeRepositoryInterface + */ + private $attributeRepository; + /** * @param LocatorInterface $locator * @param ArrayManager $arrayManager + * @param AttributeRepositoryInterface|null $attributeRepository */ public function __construct( LocatorInterface $locator, - ArrayManager $arrayManager + ArrayManager $arrayManager, + AttributeRepositoryInterface $attributeRepository = null ) { $this->locator = $locator; $this->arrayManager = $arrayManager; + $this->attributeRepository = $attributeRepository + ?: \Magento\Framework\App\ObjectManager::getInstance()->get(AttributeRepositoryInterface::class); } /** * {@inheritdoc} * @since 101.0.0 + * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function modifyData(array $data) { @@ -58,7 +69,12 @@ public function modifyData(array $data) $modelId = $this->locator->getProduct()->getId(); if (!isset($data[$modelId][static::DATA_SOURCE_DEFAULT][ProductAttributeInterface::CODE_STATUS])) { - $data[$modelId][static::DATA_SOURCE_DEFAULT][ProductAttributeInterface::CODE_STATUS] = '1'; + $attributeStatus = $this->attributeRepository->get( + ProductAttributeInterface::ENTITY_TYPE_CODE, + ProductAttributeInterface::CODE_STATUS + ); + $data[$modelId][static::DATA_SOURCE_DEFAULT][ProductAttributeInterface::CODE_STATUS] = + $attributeStatus->getDefaultValue() ?: 1; } return $data; @@ -106,7 +122,7 @@ protected function customizeAdvancedPriceFormat(array $data) $value[ProductAttributeInterface::CODE_TIER_PRICE_FIELD_PRICE] = $this->formatPrice($value[ProductAttributeInterface::CODE_TIER_PRICE_FIELD_PRICE]); $value[ProductAttributeInterface::CODE_TIER_PRICE_FIELD_PRICE_QTY] = - (int)$value[ProductAttributeInterface::CODE_TIER_PRICE_FIELD_PRICE_QTY]; + (float) $value[ProductAttributeInterface::CODE_TIER_PRICE_FIELD_PRICE_QTY]; } } @@ -187,7 +203,7 @@ protected function customizeStatusField(array $meta) protected function customizeWeightField(array $meta) { $weightPath = $this->arrayManager->findPath(ProductAttributeInterface::CODE_WEIGHT, $meta, null, 'children'); - + $disabled = $this->arrayManager->get($weightPath . '/arguments/data/config/disabled', $meta); if ($weightPath) { $meta = $this->arrayManager->merge( $weightPath . static::META_CONFIG_PATH, @@ -199,7 +215,7 @@ protected function customizeWeightField(array $meta) ], 'additionalClasses' => 'admin__field-small', 'addafter' => $this->locator->getStore()->getConfig('general/locale/weight_unit'), - 'imports' => [ + 'imports' => $disabled ? [] : [ 'disabled' => '!${$.provider}:' . self::DATA_SCOPE_PRODUCT . '.product_has_weight:value' ] @@ -239,6 +255,7 @@ protected function customizeWeightField(array $meta) ], ], 'value' => (int)$this->locator->getProduct()->getTypeInstance()->hasWeight(), + 'disabled' => $disabled, ] ); } diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php index 298da3d5cd6f2..bab36ce5fc4d8 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php @@ -135,7 +135,6 @@ public function modifyMeta(array $meta) 'collapsible' => true, 'componentType' => Form\Fieldset::NAME, 'dataScope' => self::DATA_SCOPE_PRODUCT, - 'disabled' => false, 'sortOrder' => $this->getNextGroupSortOrder( $meta, 'search-engine-optimization', @@ -196,6 +195,7 @@ protected function getFieldsForFieldset() 'false' => '0', ], 'value' => $isChecked ? (string)$website['id'] : '0', + 'disabled' => $this->locator->getProduct()->isLockedAttribute('website_ids'), ], ], ], diff --git a/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php b/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php new file mode 100644 index 0000000000000..4c3945569db2a --- /dev/null +++ b/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php @@ -0,0 +1,83 @@ +catalogData = $catalogData; + $this->scopeConfig = $scopeConfig; + } + + /** + * Returns category URL suffix. + * + * @return mixed + */ + public function getCategoryUrlSuffix() + { + return $this->scopeConfig->getValue( + 'catalog/seo/category_url_suffix', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Checks if categories path is used for product URLs. + * + * @return bool + */ + public function isCategoryUsedInProductUrl(): bool + { + return $this->scopeConfig->isSetFlag( + 'catalog/seo/product_use_categories', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Returns product name. + * + * @return string + */ + public function getProductName(): string + { + return $this->catalogData->getProduct() !== null + ? $this->catalogData->getProduct()->getName() + : ''; + } +} diff --git a/app/code/Magento/Catalog/composer.json b/app/code/Magento/Catalog/composer.json index 2da5c05d15183..44d051933909b 100644 --- a/app/code/Magento/Catalog/composer.json +++ b/app/code/Magento/Catalog/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog-inventory": "*", @@ -34,7 +34,7 @@ "suggest": { "magento/module-cookie": "*", "magento/module-sales": "*", - "magento/module-catalog-sample-data": "Sample Data version:100.3.*" + "magento/module-catalog-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Catalog/etc/adminhtml/menu.xml b/app/code/Magento/Catalog/etc/adminhtml/menu.xml index aa910e6d5ade4..cfcce3a26cbec 100644 --- a/app/code/Magento/Catalog/etc/adminhtml/menu.xml +++ b/app/code/Magento/Catalog/etc/adminhtml/menu.xml @@ -12,7 +12,6 @@ - diff --git a/app/code/Magento/Catalog/etc/adminhtml/system.xml b/app/code/Magento/Catalog/etc/adminhtml/system.xml index 39803c7ecc03a..e6dbb10e811b4 100644 --- a/app/code/Magento/Catalog/etc/adminhtml/system.xml +++ b/app/code/Magento/Catalog/etc/adminhtml/system.xml @@ -36,10 +36,10 @@ - + - + @@ -83,7 +83,7 @@ Magento\Catalog\Model\Indexer\Product\Flat\System\Config\Mode Magento\Config\Model\Config\Source\Yesno - + Magento\Catalog\Model\Config\Source\ListSort diff --git a/app/code/Magento/Catalog/etc/config.xml b/app/code/Magento/Catalog/etc/config.xml index 3569c0a27b83f..1d92197e390a1 100644 --- a/app/code/Magento/Catalog/etc/config.xml +++ b/app/code/Magento/Catalog/etc/config.xml @@ -56,6 +56,7 @@ tmp + media/catalog/product/cache/ catalog custom_options diff --git a/app/code/Magento/Catalog/etc/db_schema.xml b/app/code/Magento/Catalog/etc/db_schema.xml index f39a78d922f9f..60789c016ff0f 100644 --- a/app/code/Magento/Catalog/etc/db_schema.xml +++ b/app/code/Magento/Catalog/etc/db_schema.xml @@ -1639,7 +1639,7 @@ - + @@ -1809,7 +1809,7 @@ - diff --git a/app/code/Magento/Catalog/etc/db_schema_whitelist.json b/app/code/Magento/Catalog/etc/db_schema_whitelist.json index b38817331bee5..1c2c660ca9b00 100644 --- a/app/code/Magento/Catalog/etc/db_schema_whitelist.json +++ b/app/code/Magento/Catalog/etc/db_schema_whitelist.json @@ -1096,7 +1096,8 @@ "index": { "CAT_CTGR_PRD_IDX_REPLICA_PRD_ID_STORE_ID_CTGR_ID_VISIBILITY": true, "IDX_87EB2E3059853CF89A75B4C55074810B": true, - "CAT_CTGR_PRD_IDX_PRD_ID_STORE_ID_CTGR_ID_VISIBILITY": true + "CAT_CTGR_PRD_IDX_PRD_ID_STORE_ID_CTGR_ID_VISIBILITY": true, + "CAT_CTGR_PRD_IDX_STORE_ID_CTGR_ID_VISIBILITY_IS_PARENT_POSITION": true }, "constraint": { "PRIMARY": true @@ -1114,8 +1115,9 @@ "constraint": { "PRIMARY": true, "CAT_PRD_FRONTEND_ACTION_CSTR_ID_CSTR_ENTT_ENTT_ID": true, + "CAT_PRD_FRONTEND_ACTION_PRD_ID_CAT_PRD_ENTT_ENTT_ID": true, "CATALOG_PRODUCT_FRONTEND_ACTION_VISITOR_ID_PRODUCT_ID_TYPE_ID": true, "CATALOG_PRODUCT_FRONTEND_ACTION_CUSTOMER_ID_PRODUCT_ID_TYPE_ID": true } } -} \ No newline at end of file +} diff --git a/app/code/Magento/Catalog/etc/di.xml b/app/code/Magento/Catalog/etc/di.xml index a3cd2d8558f87..9f1fb020ef95a 100644 --- a/app/code/Magento/Catalog/etc/di.xml +++ b/app/code/Magento/Catalog/etc/di.xml @@ -363,6 +363,7 @@ Magento\Catalog\Pricing\Price\BasePrice Magento\Catalog\Pricing\Price\CustomOptionPrice Magento\Catalog\Pricing\Price\ConfiguredPrice + Magento\Catalog\Pricing\Price\ConfiguredRegularPrice @@ -552,16 +553,10 @@ - Magento\Catalog\Console\Command\ImagesResizeCommand Magento\Catalog\Console\Command\ProductAttributesCleanUp - - - Magento\Catalog\Api\ProductRepositoryInterface\Proxy - - @@ -870,6 +865,7 @@ Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductCategoryFilter Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductStoreFilter + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductStoreFilter Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductWebsiteFilter @@ -1078,4 +1074,10 @@ indexer + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\ConditionBuilder\EavAttributeCondition + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\ConditionBuilder\NativeAttributeCondition + + diff --git a/app/code/Magento/Catalog/etc/events.xml b/app/code/Magento/Catalog/etc/events.xml index 63bd574894339..5bcdc88369064 100644 --- a/app/code/Magento/Catalog/etc/events.xml +++ b/app/code/Magento/Catalog/etc/events.xml @@ -60,4 +60,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/frontend/di.xml b/app/code/Magento/Catalog/etc/frontend/di.xml index 3989a62a56cc4..659ba2b731366 100644 --- a/app/code/Magento/Catalog/etc/frontend/di.xml +++ b/app/code/Magento/Catalog/etc/frontend/di.xml @@ -100,4 +100,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/webapi_async.xml b/app/code/Magento/Catalog/etc/webapi_async.xml new file mode 100644 index 0000000000000..50baad7845e95 --- /dev/null +++ b/app/code/Magento/Catalog/etc/webapi_async.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/code/Magento/Catalog/etc/webapi_rest/di.xml b/app/code/Magento/Catalog/etc/webapi_rest/di.xml index 1d2b013f2035d..a0d3e850b3c64 100644 --- a/app/code/Magento/Catalog/etc/webapi_rest/di.xml +++ b/app/code/Magento/Catalog/etc/webapi_rest/di.xml @@ -8,7 +8,6 @@ - @@ -16,4 +15,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/webapi_soap/di.xml b/app/code/Magento/Catalog/etc/webapi_soap/di.xml index 98a8ef4de8408..a0d3e850b3c64 100644 --- a/app/code/Magento/Catalog/etc/webapi_soap/di.xml +++ b/app/code/Magento/Catalog/etc/webapi_soap/di.xml @@ -15,4 +15,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/widget.xml b/app/code/Magento/Catalog/etc/widget.xml index ef9009549da24..a11d206e2ce42 100644 --- a/app/code/Magento/Catalog/etc/widget.xml +++ b/app/code/Magento/Catalog/etc/widget.xml @@ -64,7 +64,11 @@ - 86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache. + + If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
Widget will not show products that begin to match the specified conditions until cache is refreshed.]]> +
diff --git a/app/code/Magento/Catalog/i18n/en_US.csv b/app/code/Magento/Catalog/i18n/en_US.csv index b9012c030dace..f2a7cf0b1950b 100644 --- a/app/code/Magento/Catalog/i18n/en_US.csv +++ b/app/code/Magento/Catalog/i18n/en_US.csv @@ -516,6 +516,9 @@ Groups,Groups "Maximum image width","Maximum image width" "Maximum image height","Maximum image height" "Maximum number of characters:","Maximum number of characters:" +"Maximum %1 characters", "Maximum %1 characters" +"too many", "too many" +"remaining", "remaining" "start typing to search template","start typing to search template" "Product online","Product online" "Product offline","Product offline" @@ -705,7 +708,13 @@ Template,Template "New Products Names Only Template","New Products Names Only Template" "New Products Images Only Template","New Products Images Only Template" "Cache Lifetime (Seconds)","Cache Lifetime (Seconds)" -"86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache.","86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache." +"Time in seconds between the widget updates. +
If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
Widget will not show products that begin to match the specified conditions until cache is refreshed." +, +"Time in seconds between the widget updates. +
If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
Widget will not show products that begin to match the specified conditions until cache is refreshed." "Catalog Product Link","Catalog Product Link" "Link to a Specified Product","Link to a Specified Product" "Select Product...","Select Product..." diff --git a/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js b/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js index 5ffc587f65bec..9053c700d1a3b 100644 --- a/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js +++ b/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js @@ -6,12 +6,13 @@ var config = { map: { '*': { - categoryForm: 'Magento_Catalog/catalog/category/form', - newCategoryDialog: 'Magento_Catalog/js/new-category-dialog', - categoryTree: 'Magento_Catalog/js/category-tree', - productGallery: 'Magento_Catalog/js/product-gallery', - baseImage: 'Magento_Catalog/catalog/base-image-uploader', - productAttributes: 'Magento_Catalog/catalog/product-attributes' + categoryForm: 'Magento_Catalog/catalog/category/form', + newCategoryDialog: 'Magento_Catalog/js/new-category-dialog', + categoryTree: 'Magento_Catalog/js/category-tree', + productGallery: 'Magento_Catalog/js/product-gallery', + baseImage: 'Magento_Catalog/catalog/base-image-uploader', + productAttributes: 'Magento_Catalog/catalog/product-attributes', + categoryCheckboxTree: 'Magento_Catalog/js/category-checkbox-tree' } }, deps: [ diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml index 740d389735974..00a1580923a7b 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml @@ -5,187 +5,33 @@ */ // @codingStandardsIgnoreFile - -?> - - -
- - - categoryLoader.buildHash = function(node) + diff --git a/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml b/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml index 44e13da69fc3b..6c5d37a92ea4a 100644 --- a/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml +++ b/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml @@ -152,7 +152,6 @@ true true container - attribute_options.position @@ -189,12 +188,8 @@ - - true - text false - position diff --git a/app/code/Magento/Catalog/view/adminhtml/ui_component/product_listing.xml b/app/code/Magento/Catalog/view/adminhtml/ui_component/product_listing.xml index 09332d66633f1..65090fa3ac461 100644 --- a/app/code/Magento/Catalog/view/adminhtml/ui_component/product_listing.xml +++ b/app/code/Magento/Catalog/view/adminhtml/ui_component/product_listing.xml @@ -48,7 +48,9 @@ - + diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/category-checkbox-tree.js b/app/code/Magento/Catalog/view/adminhtml/web/js/category-checkbox-tree.js new file mode 100644 index 0000000000000..bc44128663cd0 --- /dev/null +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/category-checkbox-tree.js @@ -0,0 +1,271 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/* global Ext, varienWindowOnload, varienElementMethods */ + +define([ + 'jquery', + 'prototype', + 'extjs/ext-tree-checkbox', + 'mage/adminhtml/form' +], function (jQuery) { + 'use strict'; + + return function (config) { + var tree, + options = { + dataUrl: config.dataUrl, + divId: config.divId, + rootVisible: config.rootVisible, + useAjax: config.useAjax, + currentNodeId: config.currentNodeId, + jsFormObject: window[config.jsFormObject], + name: config.name, + checked: config.checked, + allowDrop: config.allowDrop, + rootId: config.rootId, + expanded: config.expanded, + categoryId: config.categoryId, + treeJson: config.treeJson + }, + data = {}, + parameters = {}, + root = {}, + len = 0, + key = ''; + + /** + * Fix ext compatibility with prototype 1.6 + */ + Ext.lib.Event.getTarget = function (e) { + var ee = e.browserEvent || e; + + return ee.target ? Event.element(ee) : null; + }; + + /** + * @param {Object} el + * @param {Object} nodeConfig + */ + Ext.tree.TreePanel.Enhanced = function (el, nodeConfig) { + Ext.tree.TreePanel.Enhanced.superclass.constructor.call(this, el, nodeConfig); + }; + + Ext.extend(Ext.tree.TreePanel.Enhanced, Ext.tree.TreePanel, { + /** + * @param {Object} treeConfig + * @param {Boolean} firstLoad + */ + loadTree: function (treeConfig, firstLoad) { + parameters = treeConfig.parameters, + data = treeConfig.data, + root = new Ext.tree.TreeNode(parameters); + + if (typeof parameters.rootVisible !== 'undefined') { + this.rootVisible = parameters.rootVisible * 1; + } + + this.nodeHash = {}; + this.setRootNode(root); + + if (firstLoad) { + this.addListener('click', this.categoryClick.createDelegate(this)); + } + + this.loader.buildCategoryTree(root, data); + this.el.dom.innerHTML = ''; + // render the tree + this.render(); + }, + + /** + * @param {Object} node + */ + categoryClick: function (node) { + node.getUI().check(!node.getUI().checked()); + } + }); + + jQuery(function () { + var categoryLoader = new Ext.tree.TreeLoader({ + dataUrl: config.dataUrl + }); + + /** + * @param {Object} response + * @param {Object} parent + * @param {Function} callback + */ + categoryLoader.processResponse = function (response, parent, callback) { + config = JSON.parse(response.responseText); + + this.buildCategoryTree(parent, config); + + if (typeof callback === 'function') { + callback(this, parent); + } + }; + + /** + * @param {Object} nodeConfig + * @returns {Object} + */ + categoryLoader.createNode = function (nodeConfig) { + var node; + + nodeConfig.uiProvider = Ext.tree.CheckboxNodeUI; + + if (nodeConfig.children && !nodeConfig.children.length) { + delete nodeConfig.children; + node = new Ext.tree.AsyncTreeNode(nodeConfig); + } else { + node = new Ext.tree.TreeNode(nodeConfig); + } + + return node; + }; + + /** + * @param {Object} parent + * @param {Object} nodeConfig + * @param {Integer} i + */ + categoryLoader.processCategoryTree = function (parent, nodeConfig, i) { + var node, + _node = {}; + + nodeConfig[i].uiProvider = Ext.tree.CheckboxNodeUI; + + _node = Object.clone(nodeConfig[i]); + + if (_node.children && !_node.children.length) { + delete _node.children; + node = new Ext.tree.AsyncTreeNode(_node); + } else { + node = new Ext.tree.TreeNode(nodeConfig[i]); + } + parent.appendChild(node); + node.loader = node.getOwnerTree().loader; + + if (_node.children) { + categoryLoader.buildCategoryTree(node, _node.children); + } + }; + + /** + * @param {Object} parent + * @param {Object} nodeConfig + * @returns {void} + */ + categoryLoader.buildCategoryTree = function (parent, nodeConfig) { + var j = 0; + + if (!nodeConfig) { + return null; + } + + if (parent && nodeConfig && nodeConfig.length) { + for (j = 0; j < nodeConfig.length; j++) { + categoryLoader.processCategoryTree(parent, nodeConfig, j); + } + } + }; + + /** + * + * @param {Object} hash + * @param {Object} node + * @returns {Object} + */ + categoryLoader.buildHashChildren = function (hash, node) { + var j = 0; + + if (node.childNodes.length > 0 || node.loaded === false && node.loading === false) { + hash.children = []; + + for (j = 0, len = node.childNodes.length; j < len; j++) { + hash.children = hash.children ? hash.children : []; + hash.children.push(this.buildHash(node.childNodes[j])); + } + } + + return hash; + }; + + /** + * @param {Object} node + * @returns {Object} + */ + categoryLoader.buildHash = function (node) { + var hash = {}; + + hash = this.toArray(node.attributes); + + return categoryLoader.buildHashChildren(hash, node); + }; + + /** + * @param {Object} attributes + * @returns {Object} + */ + categoryLoader.toArray = function (attributes) { + data = {}; + + for (key in attributes) { + + if (attributes[key]) { + data[key] = attributes[key]; + } + } + + return data; + }; + + categoryLoader.on('beforeload', function (treeLoader, node) { + treeLoader.baseParams.id = node.attributes.id; + }); + + categoryLoader.on('load', function () { + varienWindowOnload(); + }); + + tree = new Ext.tree.TreePanel.Enhanced(options.divId, { + animate: false, + loader: categoryLoader, + enableDD: false, + containerScroll: true, + selModel: new Ext.tree.CheckNodeMultiSelectionModel(), + rootVisible: options.rootVisible, + useAjax: options.useAjax, + currentNodeId: options.currentNodeId, + addNodeTo: false, + rootUIProvider: Ext.tree.CheckboxNodeUI + }); + + tree.on('check', function (node) { + options.jsFormObject.updateElement.value = this.getChecked().join(', '); + varienElementMethods.setHasChanges(node.getUI().checkbox); + }, tree); + + // set the root node + //jscs:disable requireCamelCaseOrUpperCaseIdentifiers + parameters = { + text: options.name, + draggable: false, + checked: options.checked, + uiProvider: Ext.tree.CheckboxNodeUI, + allowDrop: options.allowDrop, + id: options.rootId, + expanded: options.expanded, + category_id: options.categoryId + }; + //jscs:enable requireCamelCaseOrUpperCaseIdentifiers + + tree.loadTree({ + parameters: parameters, data: options.treeJson + }, true); + }); + }; +}); diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js b/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js index 99b1252b8f781..9c66ce577a2fd 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js @@ -5,9 +5,10 @@ define([ 'jquery', + 'mageUtils', 'jquery/ui', 'jquery/jstree/jquery.jstree' -], function ($) { +], function ($, utils) { 'use strict'; $.widget('mage.categoryTree', { @@ -90,7 +91,7 @@ define([ } result = { data: { - title: node.name + ' (' + node['product_count'] + ')' + title: utils.unescape(node.name) + ' (' + node['product_count'] + ')' }, attr: { 'class': node.cls + (!!node.disabled ? ' disabled' : '') //eslint-disable-line no-extra-boolean-cast diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js b/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js index 51ffeaea0fc0c..2f6703cc92eac 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js @@ -54,9 +54,16 @@ define([ if (!_.isEmpty(this.suffixName) || _.isNumber(this.suffixName)) { suffixName = '.' + this.suffixName; } - this.dataScope = 'data.' + this.prefixName + '.' + this.elementName + suffixName; - this.links.value = this.provider + ':' + this.dataScope; + this.exportDataLink = 'data.' + this.prefixName + '.' + this.elementName + suffixName; + this.exports.value = this.provider + ':' + this.exportDataLink; + }, + + /** @inheritdoc */ + destroy: function () { + this._super(); + + this.source.remove(this.exportDataLink); }, /** diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/options.js b/app/code/Magento/Catalog/view/adminhtml/web/js/options.js index 787516a9abf29..6ea005915763c 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/js/options.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/options.js @@ -13,12 +13,16 @@ define([ 'jquery/ui', 'prototype', 'form', - 'validation' + 'validation', + 'mage/translate' ], function (jQuery, mageTemplate, rg) { 'use strict'; return function (config) { - var attributeOption = { + var optionPanel = jQuery('#manage-options-panel'), + optionsValues = [], + editForm = jQuery('#edit_form'), + attributeOption = { table: $('attribute-options-table'), itemCount: 0, totalItems: 0, @@ -150,7 +154,7 @@ define([ attributeOption.remove(event); }); - jQuery('#manage-options-panel').on('render', function () { + optionPanel.on('render', function () { attributeOption.ignoreValidate(); if (attributeOption.rendered) { @@ -176,7 +180,31 @@ define([ }); }); } + editForm.on('submit', function () { + optionPanel.find('input') + .each(function () { + if (this.disabled) { + return; + } + if (this.type === 'checkbox' || this.type === 'radio') { + if (this.checked) { + optionsValues.push(this.name + '=' + jQuery(this).val()); + } + } else { + optionsValues.push(this.name + '=' + jQuery(this).val()); + } + }); + jQuery('') + .attr({ + type: 'hidden', + name: 'serialized_options' + }) + .val(JSON.stringify(optionsValues)) + .prependTo(editForm); + optionPanel.find('table') + .replaceWith(jQuery('
').text(jQuery.mage.__('Sending attribute values as package.'))); + }); window.attributeOption = attributeOption; window.optionDefaultInputType = attributeOption.getOptionInputType(); diff --git a/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html b/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html index d4cfb02611416..9a52dcefa3042 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html +++ b/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html @@ -7,7 +7,7 @@ - getIsSalable()): ?> + getIsSalable()): ?>
@@ -78,7 +78,7 @@
helper('Magento\Wishlist\Helper\Data')->isAllow()) : ?> @@ -89,39 +89,41 @@ - getAttributes() as $_attribute): ?> - - - getItems() as $_item): ?> - - - - escapeHtml($_attribute->getStoreLabel() ? $_attribute->getStoreLabel() : __($_attribute->getFrontendLabel())) ?> - - - - -
- getAttributeCode()) { - case "price": ?> - getProductPrice( - $_item, - '-compare-list-' . $_attribute->getCode() - ) - ?> - - getImage($_item, 'product_small_image')->toHtml(); ?> + getAttributes() as $attribute): ?> + + hasAttributeValueForProducts($attribute)): ?> + + getItems() as $item): ?> + + + + escapeHtml($attribute->getStoreLabel() ? $attribute->getStoreLabel() : __($attribute->getFrontendLabel())) ?> + + + + +
+ getAttributeCode()) { + case "price": ?> + getProductPrice( + $item, + '-compare-list-' . $attribute->getCode() + ) + ?> + + getImage($item, 'product_small_image')->toHtml(); ?> + + productAttribute($item, $block->getProductAttributeValue($item, $attribute), $attribute->getAttributeCode()) ?> - productAttribute($_item, $block->getProductAttributeValue($_item, $_attribute), $_attribute->getAttributeCode()) ?> - -
- - - + } ?> +
+ + + + diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml index 6133d55d676c3..c7abb0525b302 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml @@ -25,10 +25,7 @@

helper('Magento\Catalog\Helper\Image') - ->init($block->getProduct(), 'product_page_image_large') - ->setImageFile($block->getImageFile()) - ->getUrl(); + $imageUrl = $block->getImageUrl(); ?> width="" alt="escapeHtml($block->getCurrentImage()->getLabel()) ?>" title="escapeHtml($block->getCurrentImage()->getLabel()) ?>" id="product-gallery-image" class="image" data-mage-init='{"catalogGallery":{}}'/>
diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml index 0352f7f276630..74a0b2d7cf1a3 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml @@ -13,7 +13,7 @@ getCustomAttributes() ?> src="getImageUrl() ?>" - width="getResizedImageWidth() ?>" - height="getResizedImageHeight() ?>" + max-width="getWidth() ?>" + max-height="getHeight() ?>" alt="stripTags($block->getLabel(), null, true) ?>"/> diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml index cad2b3aaa013b..f7799b30436be 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml @@ -27,12 +27,12 @@ $_helper = $this->helper('Magento\Catalog\Helper\Output'); getMode() == 'grid') { $viewMode = 'grid'; - $image = 'category_page_grid'; + $imageDisplayArea = 'category_page_grid'; $showDescription = false; $templateType = \Magento\Catalog\Block\Product\ReviewRendererInterface::SHORT_VIEW; } else { $viewMode = 'list'; - $image = 'category_page_list'; + $imageDisplayArea = 'category_page_list'; $showDescription = true; $templateType = \Magento\Catalog\Block\Product\ReviewRendererInterface::FULL_VIEW; } @@ -48,7 +48,7 @@ $_helper = $this->helper('Magento\Catalog\Helper\Output');
  • getImage($_product, $image); + $productImage = $block->getImage($_product, $imageDisplayArea); if ($pos != null) { $position = ' style="left:' . $productImage->getWidth() . 'px;' . 'top:' . $productImage->getHeight() . 'px;"'; diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml index 4d7005dfe6cb2..9c5cce7865532 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml @@ -22,6 +22,7 @@ + getBlockHtml('formkey') ?> getChildHtml('form_top') ?> hasOptions()):?> diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml index 5a064b33355a4..1bfa30478df8a 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml @@ -47,21 +47,11 @@ "data": getGalleryImagesJson() ?>, "options": { "nav": "getVar("gallery/nav") ?>", - getVar("gallery/loop"))): ?> - "loop": getVar("gallery/loop") ?>, - - getVar("gallery/keyboard"))): ?> - "keyboard": getVar("gallery/keyboard") ?>, - - getVar("gallery/arrows"))): ?> - "arrows": getVar("gallery/arrows") ?>, - - getVar("gallery/allowfullscreen"))): ?> - "allowfullscreen": getVar("gallery/allowfullscreen") ?>, - - getVar("gallery/caption"))): ?> - "showCaption": getVar("gallery/caption") ?>, - + "loop": getVar("gallery/loop") ? 'true' : 'false' ?>, + "keyboard": getVar("gallery/keyboard") ? 'true' : 'false' ?>, + "arrows": getVar("gallery/arrows") ? 'true' : 'false' ?>, + "allowfullscreen": getVar("gallery/allowfullscreen") ? 'true' : 'false' ?>, + "showCaption": getVar("gallery/caption") ? 'true' : 'false' ?>, "width": "getImageAttribute('product_page_image_medium', 'width') ?>", "thumbwidth": "getImageAttribute('product_page_image_small', 'width') ?>", getImageAttribute('product_page_image_small', 'height') || $block->getImageAttribute('product_page_image_small', 'width')): ?> @@ -76,28 +66,18 @@ "transitionduration": getVar("gallery/transition/duration") ?>, "transition": "getVar("gallery/transition/effect") ?>", - getVar("gallery/navarrows"))): ?> - "navarrows": getVar("gallery/navarrows") ?>, - + "navarrows": getVar("gallery/navarrows") ? 'true' : 'false' ?>, "navtype": "getVar("gallery/navtype") ?>", "navdir": "getVar("gallery/navdir") ?>" }, "fullscreen": { "nav": "getVar("gallery/fullscreen/nav") ?>", - getVar("gallery/fullscreen/loop")): ?> - "loop": getVar("gallery/fullscreen/loop") ?>, - + "loop": getVar("gallery/fullscreen/loop") ? 'true' : 'false' ?>, "navdir": "getVar("gallery/fullscreen/navdir") ?>", - getVar("gallery/transition/navarrows")): ?> - "navarrows": getVar("gallery/fullscreen/navarrows") ?>, - + "navarrows": getVar("gallery/fullscreen/navarrows") ? 'true' : 'false' ?>, "navtype": "getVar("gallery/fullscreen/navtype") ?>", - getVar("gallery/fullscreen/arrows")): ?> - "arrows": getVar("gallery/fullscreen/arrows") ?>, - - getVar("gallery/fullscreen/caption")): ?> - "showCaption": getVar("gallery/fullscreen/caption") ?>, - + "arrows": getVar("gallery/fullscreen/arrows") ? 'true' : 'false' ?>, + "showCaption": getVar("gallery/fullscreen/caption") ? 'true' : 'false' ?>, getVar("gallery/fullscreen/transition/duration")): ?> "transitionduration": getVar("gallery/fullscreen/transition/duration") ?>, diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/options/type/text.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/options/type/text.phtml index 11aedc33c2d42..852e0095f2f66 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/options/type/text.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/options/type/text.phtml @@ -61,8 +61,23 @@ $class = ($_option->getIsRequire()) ? ' required' : ''; cols="25">escapeHtml($block->getDefaultValue()) ?> getMaxCharacters()): ?> -

    - getMaxCharacters() ?>

    +

    + getMaxCharacters()) ?> + +

    + getMaxCharacters()): ?> + + diff --git a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js index b2da91c3b55c1..8fcac2f9f1d65 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js @@ -38,6 +38,11 @@ define([ _bindSubmit: function () { var self = this; + if (this.element.data('catalog-addtocart-initialized')) { + return; + } + + this.element.data('catalog-addtocart-initialized', 1); this.element.on('submit', function (e) { e.preventDefault(); self.submitForm($(this)); diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js b/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js new file mode 100644 index 0000000000000..032b8541939c3 --- /dev/null +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js @@ -0,0 +1,184 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'Magento_Theme/js/model/breadcrumb-list' +], function ($, breadcrumbList) { + 'use strict'; + + return function (widget) { + + $.widget('mage.breadcrumbs', widget, { + options: { + categoryUrlSuffix: '', + useCategoryPathInUrl: false, + product: '', + categoryItemSelector: '.category-item', + menuContainer: '[data-action="navigation"] > ul' + }, + + /** @inheritdoc */ + _render: function () { + this._appendCatalogCrumbs(); + this._super(); + }, + + /** + * Append category and product crumbs. + * + * @private + */ + _appendCatalogCrumbs: function () { + var categoryCrumbs = this._resolveCategoryCrumbs(); + + categoryCrumbs.forEach(function (crumbInfo) { + breadcrumbList.push(crumbInfo); + }); + + if (this.options.product) { + breadcrumbList.push(this._getProductCrumb()); + } + }, + + /** + * Resolve categories crumbs. + * + * @return Array + * @private + */ + _resolveCategoryCrumbs: function () { + var menuItem = this._resolveCategoryMenuItem(), + categoryCrumbs = []; + + if (menuItem !== null && menuItem.length) { + categoryCrumbs.unshift(this._getCategoryCrumb(menuItem)); + + while ((menuItem = this._getParentMenuItem(menuItem)) !== null) { + categoryCrumbs.unshift(this._getCategoryCrumb(menuItem)); + } + } + + return categoryCrumbs; + }, + + /** + * Returns crumb data. + * + * @param {Object} menuItem + * @return {Object} + * @private + */ + _getCategoryCrumb: function (menuItem) { + return { + 'name': 'category', + 'label': menuItem.text(), + 'link': menuItem.attr('href'), + 'title': '' + }; + }, + + /** + * Returns product crumb. + * + * @return {Object} + * @private + */ + _getProductCrumb: function () { + return { + 'name': 'product', + 'label': this.options.product, + 'link': '', + 'title': '' + }; + }, + + /** + * Find parent menu item for current. + * + * @param {Object} menuItem + * @return {Object|null} + * @private + */ + _getParentMenuItem: function (menuItem) { + var classes, + classNav, + parentClass, + parentMenuItem = null; + + if (!menuItem) { + return null; + } + + classes = menuItem.parent().attr('class'); + classNav = classes.match(/(nav\-)[0-9]+(\-[0-9]+)+/gi); + + if (classNav) { + classNav = classNav[0]; + parentClass = classNav.substr(0, classNav.lastIndexOf('-')); + + if (parentClass.lastIndexOf('-') !== -1) { + parentMenuItem = $(this.options.menuContainer).find('.' + parentClass + ' > a'); + parentMenuItem = parentMenuItem.length ? parentMenuItem : null; + } + } + + return parentMenuItem; + }, + + /** + * Returns category menu item. + * + * Tries to resolve category from url or from referrer as fallback and + * find menu item from navigation menu by category url. + * + * @return {Object|null} + * @private + */ + _resolveCategoryMenuItem: function () { + var categoryUrl = this._resolveCategoryUrl(), + menu = $(this.options.menuContainer), + categoryMenuItem = null; + + if (categoryUrl && menu.length) { + categoryMenuItem = menu.find( + this.options.categoryItemSelector + + ' > a[href="' + categoryUrl + '"]' + ); + } + + return categoryMenuItem; + }, + + /** + * Returns category url. + * + * @return {String} + * @private + */ + _resolveCategoryUrl: function () { + var categoryUrl; + + if (this.options.useCategoryPathInUrl) { + // In case category path is used in product url - resolve category url from current url. + categoryUrl = window.location.href.split('?')[0]; + categoryUrl = categoryUrl.substring(0, categoryUrl.lastIndexOf('/')) + + this.options.categoryUrlSuffix; + } else { + // In other case - try to resolve it from referrer (without parameters). + categoryUrl = document.referrer; + + if (categoryUrl.indexOf('?') > 0) { + categoryUrl = categoryUrl.substr(0, categoryUrl.indexOf('?')); + } + } + + return categoryUrl; + } + }); + + return $.mage.breadcrumbs; + }; +}); diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/remaining-characters.js b/app/code/Magento/Catalog/view/frontend/web/js/product/remaining-characters.js new file mode 100644 index 0000000000000..3e29e1ebd4d9c --- /dev/null +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/remaining-characters.js @@ -0,0 +1,62 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'mage/translate', + 'jquery/ui' +], function ($, $t) { + 'use strict'; + + $.widget('mage.remainingCharacters', { + options: { + remainingText: $t('remaining'), + tooManyText: $t('too many'), + errorClass: 'mage-error', + noDisplayClass: 'no-display' + }, + + /** + * Initializes custom option component + * + * @private + */ + _create: function () { + this.note = $(this.options.noteSelector); + this.counter = $(this.options.counterSelector); + + this.updateCharacterCount(); + this.element.on('change keyup paste', this.updateCharacterCount.bind(this)); + }, + + /** + * Updates counter message + */ + updateCharacterCount: function () { + var length = this.element.val().length, + diff = this.options.maxLength - length; + + this.counter.text(this._formatMessage(diff)); + this.counter.toggleClass(this.options.noDisplayClass, length === 0); + this.note.toggleClass(this.options.errorClass, diff < 0); + }, + + /** + * Format remaining characters message + * + * @param {int} diff + * @returns {String} + * @private + */ + _formatMessage: function (diff) { + var count = Math.abs(diff), + qualifier = diff < 0 ? this.options.tooManyText : this.options.remainingText; + + return '(' + count + ' ' + qualifier + ')'; + } + }); + + return $.mage.remainingCharacters; +}); diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js b/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js index 00cb7d2db6885..ab566a70a756d 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js @@ -228,7 +228,7 @@ define([ this.updateRequestConfig.data = queryBuilder.buildQuery(prepareAjaxParams); this.updateRequestConfig.data['store_id'] = store; this.updateRequestConfig.data['currency_code'] = currency; - $.ajax(this.updateRequestConfig).success(function (data) { + $.ajax(this.updateRequestConfig).done(function (data) { this.request = {}; this.providerHandler(getParsedDataFromServer(data)); }.bind(this)); diff --git a/app/code/Magento/CatalogAnalytics/composer.json b/app/code/Magento/CatalogAnalytics/composer.json index 5665d10d8ac54..5c97261d483d8 100644 --- a/app/code/Magento/CatalogAnalytics/composer.json +++ b/app/code/Magento/CatalogAnalytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*" }, diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/DepthCalculator.php b/app/code/Magento/CatalogGraphQl/Model/Category/DepthCalculator.php new file mode 100644 index 0000000000000..baa456c7821ed --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Category/DepthCalculator.php @@ -0,0 +1,34 @@ +selectionSet->selections ?? []; + $depth = count($selections) ? 1 : 0; + $childrenDepth = [0]; + foreach ($selections as $node) { + $childrenDepth[] = $this->calculate($node); + } + + return $depth + max($childrenDepth); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/Hydrator.php b/app/code/Magento/CatalogGraphQl/Model/Category/Hydrator.php new file mode 100644 index 0000000000000..da4ec37c51da4 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Category/Hydrator.php @@ -0,0 +1,56 @@ +flattener = $flattener; + $this->dataObjectProcessor = $dataObjectProcessor; + } + + /** + * Hydrate and flatten category object to flat array + * + * @param CategoryInterface $category + * @return array + */ + public function hydrateCategory(CategoryInterface $category) : array + { + $categoryData = $this->dataObjectProcessor->buildOutputDataArray($category, CategoryInterface::class); + $categoryData['id'] = $category->getId(); + $categoryData['product_count'] = $category->getProductCount(); + $categoryData['children'] = []; + $categoryData['available_sort_by'] = $category->getAvailableSortBy(); + return $this->flattener->flatten($categoryData); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/LevelCalculator.php b/app/code/Magento/CatalogGraphQl/Model/Category/LevelCalculator.php new file mode 100644 index 0000000000000..eb57873850b80 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Category/LevelCalculator.php @@ -0,0 +1,44 @@ +resourceConnection = $resourceConnection; + $this->resourceCategory = $resourceCategory; + } + + /** + * Calculate level data for root category ID specified in GraphQL request + * + * @param int $rootCategoryId + * @return int + */ + public function calculate(int $rootCategoryId) : int + { + $connection = $this->resourceConnection->getConnection(); + $select = $connection->select() + ->from($connection->getTableName('catalog_category_entity'), 'level') + ->where($this->resourceCategory->getLinkField() . " = ?", $rootCategoryId); + return (int) $connection->fetchOne($select); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Config/CategoryAttributeReader.php b/app/code/Magento/CatalogGraphQl/Model/Config/CategoryAttributeReader.php index 0b1e76313b46d..0ca72d9ff9519 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Config/CategoryAttributeReader.php +++ b/app/code/Magento/CatalogGraphQl/Model/Config/CategoryAttributeReader.php @@ -30,7 +30,15 @@ class CategoryAttributeReader implements ReaderInterface 'is_active', 'children', 'level', - 'default_sort_by' + 'default_sort_by', + 'all_children', + 'page_layout', + 'custom_design', + 'custom_design_from', + 'custom_design_to', + 'custom_layout_update', + 'custom_use_parent_settings', + 'custom_apply_to_products', ]; /** diff --git a/app/code/Magento/CatalogGraphQl/Model/Layer/CollectionProvider.php b/app/code/Magento/CatalogGraphQl/Model/Layer/CollectionProvider.php index 1e6fdf0e60e66..86645b0d36fdb 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Layer/CollectionProvider.php +++ b/app/code/Magento/CatalogGraphQl/Model/Layer/CollectionProvider.php @@ -29,6 +29,10 @@ class CollectionProvider implements \Magento\Catalog\Model\Layer\ItemCollectionP */ private $collectionProcessor; + /** + * @param \Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface $collectionProcessor + * @param \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $collectionFactory + */ public function __construct( \Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface $collectionProcessor, \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $collectionFactory diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories.php similarity index 87% rename from app/code/Magento/CatalogGraphQl/Model/Resolver/Category.php rename to app/code/Magento/CatalogGraphQl/Model/Resolver/Categories.php index 52ecdccecdc3f..378e7cb4c3673 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories.php @@ -20,15 +20,10 @@ use Magento\Framework\Reflection\DataObjectProcessor; /** - * Category field resolver, used for GraphQL request processing. + * Resolver for category objects the product is assigned to. */ -class Category implements ResolverInterface +class Categories implements ResolverInterface { - /** - * Product category ids - */ - const PRODUCT_CATEGORY_IDS_KEY = 'category_ids'; - /** * @var Collection */ @@ -89,10 +84,13 @@ public function __construct( */ public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value { - $this->categoryIds = array_merge($this->categoryIds, $value[self::PRODUCT_CATEGORY_IDS_KEY]); + /** @var \Magento\Catalog\Model\Product $product */ + $product = $value['model']; + $categoryIds = $product->getCategoryIds(); + $this->categoryIds = array_merge($this->categoryIds, $categoryIds); $that = $this; - return $this->valueFactory->create(function () use ($that, $value, $info) { + return $this->valueFactory->create(function () use ($that, $categoryIds, $info) { $categories = []; if (empty($that->categoryIds)) { return []; @@ -104,13 +102,13 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value } /** @var CategoryInterface | \Magento\Catalog\Model\Category $item */ foreach ($this->collection as $item) { - if (in_array($item->getId(), $value[$that::PRODUCT_CATEGORY_IDS_KEY])) { + if (in_array($item->getId(), $categoryIds)) { $categories[$item->getId()] = $this->dataObjectProcessor->buildOutputDataArray( $item, CategoryInterface::class ); $categories[$item->getId()] = $this->customAttributesFlattener - ->flaternize($categories[$item->getId()]); + ->flatten($categories[$item->getId()]); $categories[$item->getId()]['product_count'] = $item->getProductCount(); } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php new file mode 100644 index 0000000000000..406b4173e68e1 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php @@ -0,0 +1,107 @@ +productRepository = $productRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->filterQuery = $filterQuery; + $this->valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + $args['filter'] = [ + 'category_id' => [ + 'eq' => $value['id'] + ] + ]; + $searchCriteria = $this->searchCriteriaBuilder->build($field->getName(), $args); + $searchCriteria->setCurrentPage($args['currentPage']); + $searchCriteria->setPageSize($args['pageSize']); + $searchResult = $this->filterQuery->getResult($searchCriteria, $info); + + //possible division by 0 + if ($searchCriteria->getPageSize()) { + $maxPages = ceil($searchResult->getTotalCount() / $searchCriteria->getPageSize()); + } else { + $maxPages = 0; + } + + $currentPage = $searchCriteria->getCurrentPage(); + if ($searchCriteria->getCurrentPage() > $maxPages && $searchResult->getTotalCount() > 0) { + $currentPage = new GraphQlInputException( + __( + 'currentPage value %1 specified is greater than the number of pages available.', + [$maxPages] + ) + ); + } + + $data = [ + 'total_count' => $searchResult->getTotalCount(), + 'items' => $searchResult->getProductsSearchResult(), + 'page_info' => [ + 'page_size' => $searchCriteria->getPageSize(), + 'current_page' => $currentPage + ] + ]; + + $result = function () use ($data) { + return $data; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/SortFields.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/SortFields.php new file mode 100644 index 0000000000000..ca68b29910118 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/SortFields.php @@ -0,0 +1,82 @@ +valueFactory = $valueFactory; + $this->catalogConfig = $catalogConfig; + $this->storeManager = $storeManager; + $this->sortbyAttributeSource = $sortbyAttributeSource; + } + + /** + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + $sortFieldsOptions = $this->sortbyAttributeSource->getAllOptions(); + array_walk( + $sortFieldsOptions, + function (&$option) { + $option['label'] = (string)$option['label']; + } + ); + $data = [ + 'default' => $this->catalogConfig->getProductListDefaultSortBy($this->storeManager->getStore()->getId()), + 'options' => $sortFieldsOptions, + ]; + + $result = function () use ($data) { + return $data; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php index c78237b7bcd45..f631e5ff61d2e 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php @@ -55,11 +55,11 @@ public function __construct( */ private function assertFiltersAreValidAndGetCategoryRootIds(array $args) : int { - if (!isset($args['filter']['root_category_id'])) { - throw new GraphQlInputException(__('"root_category_id" filter should be specified')); + if (!isset($args['id'])) { + throw new GraphQlInputException(__('"id for category should be specified')); } - return (int) $args['filter']['root_category_id']; + return (int) $args['id']; } /** @@ -74,9 +74,11 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value $rootCategoryId = $this->assertFiltersAreValidAndGetCategoryRootIds($args); $categoriesTree = $this->categoryTree->getTree($info, $rootCategoryId); - return [ - 'category_tree' => reset($categoriesTree) - ]; + if (!empty($categoriesTree)) { + return current($categoriesTree); + } else { + return null; + } }); } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CanonicalUrl.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CanonicalUrl.php new file mode 100644 index 0000000000000..d2675848c2d2a --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CanonicalUrl.php @@ -0,0 +1,62 @@ +valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /* @var $product Product */ + $product = $value['model']; + $url = $product->getUrlModel()->getUrl($product, ['_ignore_category' => true]); + $result = function () use ($url) { + return $url; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CategoryTree.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CategoryTree.php index 6d3bb35d654e2..3c01579410638 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CategoryTree.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CategoryTree.php @@ -8,14 +8,15 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider; use GraphQL\Language\AST\FieldNode; +use Magento\CatalogGraphQl\Model\Category\DepthCalculator; +use Magento\CatalogGraphQl\Model\Category\Hydrator; +use Magento\CatalogGraphQl\Model\Category\LevelCalculator; +use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Catalog\Api\Data\CategoryInterface; -use Magento\Catalog\Model\ResourceModel\Category; use Magento\Catalog\Model\ResourceModel\Category\Collection; use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory; use Magento\CatalogGraphQl\Model\AttributesJoiner; -use Magento\Framework\App\ResourceConnection; -use Magento\Framework\Reflection\DataObjectProcessor; /** * Category tree data provider @@ -38,47 +39,47 @@ class CategoryTree private $attributesJoiner; /** - * @var ResourceConnection + * @var DepthCalculator */ - private $resourceConnection; + private $depthCalculator; /** - * @var Category + * @var LevelCalculator */ - private $resourceCategory; + private $levelCalculator; /** - * @var CustomAttributesFlattener + * @var MetadataPool */ - private $customAttributesFlattener; + private $metadata; /** - * @var DataObjectProcessor + * @var Hydrator */ - private $dataObjectProcessor; + private $hydrator; /** * @param CollectionFactory $collectionFactory * @param AttributesJoiner $attributesJoiner - * @param ResourceConnection $resourceConnection - * @param Category $resourceCategory - * @param CustomAttributesFlattener $customAttributesFlattener - * @param DataObjectProcessor $dataObjectProcessor + * @param DepthCalculator $depthCalculator + * @param LevelCalculator $levelCalculator + * @param MetadataPool $metadata + * @param Hydrator $hydrator */ public function __construct( CollectionFactory $collectionFactory, AttributesJoiner $attributesJoiner, - ResourceConnection $resourceConnection, - Category $resourceCategory, - CustomAttributesFlattener $customAttributesFlattener, - DataObjectProcessor $dataObjectProcessor + DepthCalculator $depthCalculator, + LevelCalculator $levelCalculator, + MetadataPool $metadata, + Hydrator $hydrator ) { $this->collectionFactory = $collectionFactory; $this->attributesJoiner = $attributesJoiner; - $this->resourceConnection = $resourceConnection; - $this->resourceCategory = $resourceCategory; - $this->customAttributesFlattener = $customAttributesFlattener; - $this->dataObjectProcessor = $dataObjectProcessor; + $this->depthCalculator = $depthCalculator; + $this->levelCalculator = $levelCalculator; + $this->metadata = $metadata; + $this->hydrator = $hydrator; } /** @@ -91,13 +92,17 @@ public function getTree(ResolveInfo $resolveInfo, int $rootCategoryId) : array $categoryQuery = $resolveInfo->fieldASTs[0]; $collection = $this->collectionFactory->create(); $this->joinAttributesRecursively($collection, $categoryQuery); - $depth = $this->calculateDepth($categoryQuery); - $level = $this->getLevelByRootCategoryId($rootCategoryId); + $depth = $this->depthCalculator->calculate($categoryQuery); + $level = $this->levelCalculator->calculate($rootCategoryId); //Search for desired part of category tree + $collection->addPathFilter(sprintf('.*/%s/[/0-9]*$', $rootCategoryId)); $collection->addFieldToFilter('level', ['gt' => $level]); $collection->addFieldToFilter('level', ['lteq' => $level + $depth - self::DEPTH_OFFSET]); $collection->setOrder('level'); - $collection->getSelect()->orWhere($this->resourceCategory->getLinkField() . ' = ?', $rootCategoryId); + $collection->getSelect()->orWhere( + $this->metadata->getMetadata(CategoryInterface::class)->getLinkField() . ' = ?', + $rootCategoryId + ); return $this->processTree($collection->getIterator()); } @@ -113,7 +118,7 @@ private function processTree(\Iterator $iterator) : array $category = $iterator->current(); $iterator->next(); $nextCategory = $iterator->current(); - $tree[$category->getId()] = $this->hydrateCategory($category); + $tree[$category->getId()] = $this->hydrator->hydrateCategory($category); if ($nextCategory && (int) $nextCategory->getLevel() !== (int) $category->getLevel()) { $tree[$category->getId()]['children'] = $this->processTree($iterator); } @@ -122,36 +127,6 @@ private function processTree(\Iterator $iterator) : array return $tree; } - /** - * Hydrate and flatten category object to flat array - * - * @param CategoryInterface $category - * @return array - */ - private function hydrateCategory(CategoryInterface $category) : array - { - $categoryData = $this->dataObjectProcessor->buildOutputDataArray($category, CategoryInterface::class); - $categoryData['id'] = $category->getId(); - $categoryData['product_count'] = $category->getProductCount(); - $categoryData['all_children'] = $category->getAllChildren(); - $categoryData['children'] = []; - $categoryData['available_sort_by'] = $category->getAvailableSortBy(); - return $this->customAttributesFlattener->flaternize($categoryData); - } - - /** - * @param int $rootCategoryId - * @return int - */ - private function getLevelByRootCategoryId(int $rootCategoryId) : int - { - $connection = $this->resourceConnection->getConnection(); - $select = $connection->select() - ->from($connection->getTableName('catalog_category_entity'), 'level') - ->where($this->resourceCategory->getLinkField() . " = ?", $rootCategoryId); - return (int) $connection->fetchOne($select); - } - /** * @param Collection $collection * @param FieldNode $fieldNode @@ -171,20 +146,4 @@ private function joinAttributesRecursively(Collection $collection, FieldNode $fi $this->joinAttributesRecursively($collection, $node); } } - - /** - * @param FieldNode $fieldNode - * @return int - */ - private function calculateDepth(FieldNode $fieldNode) : int - { - $selections = $fieldNode->selectionSet->selections ?? []; - $depth = count($selections) ? 1 : 0; - $childrenDepth = [0]; - foreach ($selections as $node) { - $childrenDepth[] = $this->calculateDepth($node); - } - - return $depth + max($childrenDepth); - } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CustomAttributesFlattener.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CustomAttributesFlattener.php index e5dfe760372ff..3f7af2610db1f 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CustomAttributesFlattener.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CustomAttributesFlattener.php @@ -13,12 +13,12 @@ class CustomAttributesFlattener { /** - * Graphql is waiting for flat array + * Flatten custom attributes within its enclosing array to normalize key-value pairs. * * @param array $categoryData * @return array */ - public function flaternize(array $categoryData) : array + public function flatten(array $categoryData) : array { if (!isset($categoryData['custom_attributes'])) { return $categoryData; diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php index f2020cbeca88e..2c73d7a079170 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php @@ -78,14 +78,14 @@ public function getList( $this->collectionProcessor->process($collection, $searchCriteria, $attributes); if (!$isChildSearch) { - $visibilityIds - = $isSearch ? $this->visibility->getVisibleInSearchIds() : $this->visibility->getVisibleInCatalogIds(); + $visibilityIds = $isSearch + ? $this->visibility->getVisibleInSearchIds() + : $this->visibility->getVisibleInCatalogIds(); $collection->setVisibility($visibilityIds); } $collection->load(); // Methods that perform extra fetches post-load - $collection->addCategoryIds(); $collection->addMediaGalleryData(); $collection->addOptionsToResult(); diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php index 99b892e868d2c..a547f63b217fe 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php @@ -25,7 +25,7 @@ class ProductEntityAttributesForAst implements FieldEntityAttributesInterface /** * @var array */ - private $additionalAttributes; + private $additionalAttributes = ['min_price', 'max_price', 'category_id']; /** * @param ConfigInterface $config @@ -33,10 +33,10 @@ class ProductEntityAttributesForAst implements FieldEntityAttributesInterface */ public function __construct( ConfigInterface $config, - array $additionalAttributes = ['min_price', 'max_price'] + array $additionalAttributes = [] ) { $this->config = $config; - $this->additionalAttributes = $additionalAttributes; + $this->additionalAttributes = array_merge($this->additionalAttributes, $additionalAttributes); } /** diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php new file mode 100644 index 0000000000000..e3b3588166163 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php @@ -0,0 +1,71 @@ +categoryFactory = $categoryFactory; + $this->categoryResourceModel = $categoryResourceModel; + } + + /** + * Apply filter by 'category_id' to product collection. + * + * For anchor categories, the products from all children categories will be present in the result. + * + * @param Filter $filter + * @param AbstractDb $collection + * @return bool Whether the filter is applied + * @throws LocalizedException + */ + public function apply(Filter $filter, AbstractDb $collection) + { + $conditionType = $filter->getConditionType(); + + if ($conditionType !== 'eq') { + throw new LocalizedException(__("'category_id' only supports 'eq' condition type.")); + } + + $categoryId = $filter->getValue(); + /** @var Collection $collection */ + $category = $this->categoryFactory->create(); + $this->categoryResourceModel->load($category, $categoryId); + $collection->addCategoryFilter($category); + + return true; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Product/CanonicalUrlTest.php b/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Product/CanonicalUrlTest.php new file mode 100644 index 0000000000000..ae01c67eb5224 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Product/CanonicalUrlTest.php @@ -0,0 +1,92 @@ +getMockBuilder(\Magento\Framework\GraphQl\Config\Element\Field::class) + ->disableOriginalConstructor() + ->getMock(); + $mockInfo = $this->getMockBuilder(\Magento\Framework\GraphQl\Schema\Type\ResolveInfo::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockValueFactory->method('create')->with( + $this->callback( + function ($param) { + return $param() === null; + } + ) + ); + + $this->subject->resolve($mockField, '', $mockInfo, [], []); + } + + protected function setUp() + { + parent::setUp(); + $this->objectManager = new ObjectManager($this); + $this->mockStoreManager = $this->getMockBuilder(StoreManagerInterface::class)->getMock(); + $this->mockValueFactory = $this->getMockBuilder(ValueFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockValueFactory->method('create')->willReturn( + $this->objectManager->getObject( + Value::class, + ['callback' => function () { + return ''; + }] + ) + ); + + $mockProductUrlPathGenerator = $this->getMockBuilder(ProductUrlPathGenerator::class) + ->disableOriginalConstructor() + ->getMock(); + $mockProductUrlPathGenerator->method('getUrlPathWithSuffix')->willReturn('product_url.html'); + + $this->subject = $this->objectManager->getObject( + CanonicalUrl::class, + [ + 'valueFactory' => $this->mockValueFactory, + 'storeManager' => $this->mockStoreManager, + 'productUrlPathGenerator' => $mockProductUrlPathGenerator + ] + ); + } +} diff --git a/app/code/Magento/CatalogGraphQl/composer.json b/app/code/Magento/CatalogGraphQl/composer.json index 6188f12162bb1..eb86ac634412e 100644 --- a/app/code/Magento/CatalogGraphQl/composer.json +++ b/app/code/Magento/CatalogGraphQl/composer.json @@ -3,7 +3,7 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-eav": "*", "magento/module-catalog": "*", "magento/module-catalog-inventory": "*", diff --git a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml index 03631d049dafe..68a292ede6b4a 100644 --- a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml @@ -69,7 +69,7 @@ Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductPriceFilter Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductPriceFilter Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductPriceFilter - Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductCategoryFilter + Magento\CatalogGraphQl\Model\Resolver\Products\SearchCriteria\CollectionProcessor\FilterProcessor\CategoryFilter diff --git a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls index f223b1c9eec07..762861de94e67 100644 --- a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls @@ -10,9 +10,9 @@ type Query { sort: ProductSortInput @doc(description: "Specifies which attribute to sort on, and whether to return the results in ascending or descending order.") ): Products @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Products") @doc(description: "The products query searches for products that match the criteria specified in the search and filter attributes") - categories ( - filter: CategoryFilterInput @doc(description: "Filter for categories") - ): Categories + category ( + id: Int @doc(description: "Id of the category") + ): CategoryTree @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoryTree") } @@ -263,9 +263,6 @@ interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\ new_from_date: String @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") new_to_date: String @doc(description: "The end date for new product listings") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") tier_price: Float @doc(description: "The price when tier pricing is in effect and the items purchased threshold has been reached") - custom_layout_update: String @doc(description: "XML code that is applied as a layout update to the product page") - custom_layout: String @doc(description: "The name of a custom layout") - category_ids: [Int] @doc(description: "An array of category IDs the product belongs to") options_container: String @doc(description: "If the product has multiple options, determines where they appear on the product page") image_label: String @doc(description: "The label assigned to a product image") small_image_label: String @doc(description: "The label assigned to a product's small image") @@ -281,7 +278,8 @@ interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\ price: ProductPrices @doc(description: "A ProductPrices object, indicating the price of an item") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Price") gift_message_available: String @doc(description: "Indicates whether a gift message is available") manufacturer: Int @doc(description: "A number representing the product's manufacturer") - categories: [CategoryInterface] @doc(description: "The categories assigned to a product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category") + categories: [CategoryInterface] @doc(description: "The categories assigned to a product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Categories") + canonical_url: String @doc(description: "Canonical URL") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CanonicalUrl") } interface PhysicalProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "PhysicalProductInterface contains attributes specific to tangible products") { @@ -300,11 +298,7 @@ type CustomizableAreaValue @doc(description: "CustomizableAreaValue defines the max_characters: Int @doc(description: "The maximum number of characters that can be entered for this customizable option") } -type Categories @doc(description: "Categories aggregates the category tree of a product") { - category_tree: CategoryTree @doc(description: "Tree of categories") -} - -type CategoryTree implements CategoryInterface @doc(description: "Category Tree") { +type CategoryTree implements CategoryInterface @doc(description: "Category Tree implementation") { children: [CategoryTree] @doc(description: "Child categories tree") @resolve(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoryTree") } @@ -376,13 +370,17 @@ interface CategoryInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model path_in_store: String @doc(description: "Category path in store") url_key: String @doc(description: "The url key assigned to the category") url_path: String @doc(description: "The url path assigned to the category") - is_active: Boolean @doc(description: "Indicates whether the category is enabled") position: Int @doc(description: "The position of the category relative to other categories at the same level in tree") level: Int @doc(description: "Indicates the depth of the category within the tree") created_at: String @doc(description: "Timestamp indicating when the category was created") updated_at: String @doc(description: "Timestamp indicating when the category was updated") product_count: Int @doc(description: "The number of products in the category") default_sort_by: String @doc(description: "The attribute to use for sorting") + products( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + sort: ProductSortInput @doc(description: "Specifies which attribute to sort on, and whether to return the results in ascending or descending order.") + ): CategoryProducts @doc(description: "The list of products assigned to the category") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Products") } type CustomizableRadioOption implements CustomizableOptionInterface @doc(description: "CustomizableRadioOption contains information about a set of radio buttons that are defined as part of a customizable option") { @@ -410,10 +408,13 @@ type Products @doc(description: "The Products object is the top-level object ret page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query") total_count: Int @doc(description: "The number of products returned") filters: [LayerFilter] @doc(description: "Layered navigation filters array") + sort_fields: SortFields @doc(description: "An object that includes the default sort field and all available sort fields") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\SortFields") } -input CategoryFilterInput @doc(description: "Identifies which category attributes to search for and return.") { - root_category_id: Int @doc(description: "Id of the root category in the category tree to use as the starting point of your search.") +type CategoryProducts @doc(description: "The category products object returned in the Category query") { + items: [ProductInterface] @doc(description: "An array of products that are assigned to the category") + page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query") + total_count: Int @doc(description: "The number of products returned") } input ProductFilterInput @doc(description: "ProductFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") { @@ -440,7 +441,7 @@ input ProductFilterInput @doc(description: "ProductFilterInput defines the filte min_price: FilterTypeInput @doc(description:"The numeric minimal price of the product. Do not include the currency code.") max_price: FilterTypeInput @doc(description:"The numeric maximal price of the product. Do not include the currency code.") special_price: FilterTypeInput @doc(description:"The numeric special price of the product. Do not include the currency code.") - category_ids: FilterTypeInput @doc(description: "An array of category IDs the product belongs to") + category_id: FilterTypeInput @doc(description: "Category ID the product belongs to") options_container: FilterTypeInput @doc(description: "If the product has multiple options, determines where they appear on the product page") required_options: FilterTypeInput @doc(description: "Indicates whether the product has required options") has_options: FilterTypeInput @doc(description: "Indicates whether additional attributes have been created for the product") @@ -491,7 +492,6 @@ input ProductSortInput @doc(description: "ProductSortInput specifies the attrib news_from_date: SortEnum @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product") news_to_date: SortEnum @doc(description: "The end date for new product listings") custom_layout_update: SortEnum @doc(description: "XML code that is applied as a layout update to the product page") - category_ids: SortEnum @doc(description: "An array of category IDs the product belongs to") options_container: SortEnum @doc(description: "If the product has multiple options, determines where they appear on the product page") required_options: SortEnum @doc(description: "Indicates whether the product has required options") has_options: SortEnum @doc(description: "Indicates whether additional attributes have been created for the product") @@ -533,3 +533,13 @@ interface LayerFilterItemInterface @typeResolver(class: "Magento\\CatalogGraphQl type LayerFilterItem implements LayerFilterItemInterface { } + +type SortField { + value: String @doc(description: "Attribute code of sort field") + label: String @doc(description: "Label of sort field") +} + +type SortFields @doc(description: "SortFields contains a default value for sort fields and all available sort fields") { + default: String @doc(description: "Default value of sort fields") + options: [SortField] @doc(description: "Available sort fields") +} diff --git a/app/code/Magento/CatalogImportExport/Model/Export/Product.php b/app/code/Magento/CatalogImportExport/Model/Export/Product.php index c4148cd90088a..23aa8d65ddb0d 100644 --- a/app/code/Magento/CatalogImportExport/Model/Export/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Export/Product.php @@ -5,6 +5,7 @@ */ namespace Magento\CatalogImportExport\Model\Export; +use Magento\Catalog\Model\ResourceModel\Product\Option\Collection; use Magento\CatalogImportExport\Model\Import\Product\CategoryProcessor; use Magento\ImportExport\Model\Import; use \Magento\Store\Model\Store; @@ -203,7 +204,7 @@ class Product extends \Magento\ImportExport\Model\Export\Entity\AbstractEntity protected $_itemFactory; /** - * @var \Magento\Catalog\Model\ResourceModel\Product\Option\Collection + * @var Collection */ protected $_optionColFactory; @@ -1349,6 +1350,12 @@ protected function optionRowToCellString($option) } /** + * Collect custom options data for products that will be exported. + * + * Option name and type will be collected for all store views, all other data (which can't be changed on store view + * level will be collected for DEFAULT_STORE_ID only. + * Store view specified data will be saved to the additional store view row. + * * @param int[] $productIds * @return array * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -1359,49 +1366,48 @@ protected function getCustomOptionsData($productIds) $customOptionsData = []; foreach (array_keys($this->_storeIdToCode) as $storeId) { - if (Store::DEFAULT_STORE_ID != $storeId) { - continue; - } $options = $this->_optionColFactory->create(); - /* @var \Magento\Catalog\Model\ResourceModel\Product\Option\Collection $options*/ - $options->reset(); - $options->addOrder('sort_order', 'ASC'); - $options->addTitleToResult($storeId); - $options->addPriceToResult($storeId); - $options->addProductToFilter($productIds); - $options->addValuesToResult($storeId); + /* @var Collection $options*/ + $options->reset() + ->addOrder('sort_order', Collection::SORT_ORDER_ASC) + ->addTitleToResult($storeId) + ->addPriceToResult($storeId) + ->addProductToFilter($productIds) + ->addValuesToResult($storeId); foreach ($options as $option) { $row = []; $productId = $option['product_id']; - $row['name'] = $option['title']; $row['type'] = $option['type']; - $row['required'] = $option['is_require']; - $row['price'] = $option['price']; - $row['price_type'] = ($option['price_type'] == 'percent') ? $option['price_type'] : 'fixed'; - $row['sku'] = $option['sku']; - if ($option['max_characters']) { - $row['max_characters'] = $option['max_characters']; - } - - foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { - if (!isset($option[$fileOptionKey])) { - continue; + if (Store::DEFAULT_STORE_ID === $storeId) { + $row['required'] = $option['is_require']; + $row['price'] = $option['price']; + $row['price_type'] = ($option['price_type'] === 'percent') ? 'percent' : 'fixed'; + $row['sku'] = $option['sku']; + if ($option['max_characters']) { + $row['max_characters'] = $option['max_characters']; } - $row[$fileOptionKey] = $option[$fileOptionKey]; - } + foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { + if (!isset($option[$fileOptionKey])) { + continue; + } + $row[$fileOptionKey] = $option[$fileOptionKey]; + } + } $values = $option->getValues(); if ($values) { foreach ($values as $value) { - $valuePriceType = ($value['price_type'] == 'percent') ? $value['price_type'] : 'fixed'; $row['option_title'] = $value['title']; - $row['price'] = $value['price']; - $row['price_type'] = $valuePriceType; - $row['sku'] = $value['sku']; + if (Store::DEFAULT_STORE_ID === $storeId) { + $row['option_title'] = $value['title']; + $row['price'] = $value['price']; + $row['price_type'] = ($value['price_type'] === 'percent') ? 'percent' : 'fixed'; + $row['sku'] = $value['sku']; + } $customOptionsData[$productId][$storeId][] = $this->optionRowToCellString($row); } } else { diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index 9922a313dc42e..59009cc2d5637 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -10,11 +10,13 @@ use Magento\CatalogImportExport\Model\Import\Product\MediaGalleryProcessor; use Magento\CatalogImportExport\Model\Import\Product\ImageTypeProcessor; use Magento\CatalogImportExport\Model\Import\Product\RowValidatorInterface as ValidatorInterface; +use Magento\CatalogInventory\Api\Data\StockItemInterface; use Magento\CatalogImportExport\Model\StockItemImporterInterface; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filesystem; +use Magento\Framework\Intl\DateTimeFactory; use Magento\Framework\Model\ResourceModel\Db\ObjectRelationProcessor; use Magento\Framework\Model\ResourceModel\Db\TransactionManagerInterface; use Magento\Framework\Stdlib\DateTime; @@ -724,6 +726,11 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity */ private $mediaProcessor; + /** + * @var DateTimeFactory + */ + private $dateTimeFactory; + /** * @param \Magento\Framework\Json\Helper\Data $jsonHelper * @param \Magento\ImportExport\Helper\Data $importExportData @@ -767,7 +774,7 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity * @param ImageTypeProcessor $imageTypeProcessor * @param MediaGalleryProcessor $mediaProcessor * @param StockItemImporterInterface|null $stockItemImporter - * + * @param DateTimeFactory $dateTimeFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -812,7 +819,8 @@ public function __construct( CatalogConfig $catalogConfig = null, ImageTypeProcessor $imageTypeProcessor = null, MediaGalleryProcessor $mediaProcessor = null, - StockItemImporterInterface $stockItemImporter = null + StockItemImporterInterface $stockItemImporter = null, + DateTimeFactory $dateTimeFactory = null ) { $this->_eventManager = $eventManager; $this->stockRegistry = $stockRegistry; @@ -858,16 +866,14 @@ public function __construct( $string, $errorAggregator ); - $this->_optionEntity = isset( - $data['option_entity'] - ) ? $data['option_entity'] : $optionFactory->create( - ['data' => ['product_entity' => $this]] - ); + $this->_optionEntity = $data['option_entity'] ?? + $optionFactory->create(['data' => ['product_entity' => $this]]); $this->_initAttributeSets() ->_initTypeModels() ->_initSkus() ->initImagesArrayKeys(); $this->validator->init($this); + $this->dateTimeFactory = $dateTimeFactory ?? ObjectManager::getInstance()->get(DateTimeFactory::class); } /** @@ -1703,7 +1709,7 @@ protected function _saveProducts() $storeId = !empty($rowData[self::COL_STORE]) ? $this->getStoreIdByCode($rowData[self::COL_STORE]) : Store::DEFAULT_STORE_ID; - if (isset($rowData['_media_is_disabled'])) { + if (isset($rowData['_media_is_disabled']) && strlen(trim($rowData['_media_is_disabled']))) { $disabledImages = array_flip( explode($this->getMultipleValueSeparator(), $rowData['_media_is_disabled']) ); @@ -2151,40 +2157,8 @@ protected function _saveStockItem() $row = []; $sku = $rowData[self::COL_SKU]; if ($this->skuProcessor->getNewSku($sku) !== null) { - $row['product_id'] = $this->skuProcessor->getNewSku($sku)['entity_id']; + $row = $this->formatStockDataForRow($rowData); $productIdsToReindex[] = $row['product_id']; - - $row['website_id'] = $this->stockConfiguration->getDefaultScopeId(); - $row['stock_id'] = $this->stockRegistry->getStock($row['website_id'])->getStockId(); - - $stockItemDo = $this->stockRegistry->getStockItem($row['product_id'], $row['website_id']); - $existStockData = $stockItemDo->getData(); - - $row = array_merge( - $this->defaultStockData, - array_intersect_key($existStockData, $this->defaultStockData), - array_intersect_key($rowData, $this->defaultStockData), - $row - ); - $row['sku'] = $sku; - - if ($this->stockConfiguration->isQty( - $this->skuProcessor->getNewSku($sku)['type_id'] - ) - ) { - $stockItemDo->setData($row); - $row['is_in_stock'] = $this->stockStateProvider->verifyStock($stockItemDo); - if ($this->stockStateProvider->verifyNotification($stockItemDo)) { - $row['low_stock_date'] = gmdate( - 'Y-m-d H:i:s', - (new \DateTime())->getTimestamp() - ); - } - $row['stock_status_changed_auto'] = - (int)!$this->stockStateProvider->verifyStock($stockItemDo); - } else { - $row['qty'] = 0; - } } if (!isset($stockData[$sku])) { @@ -2645,7 +2619,10 @@ private function _setStockUseConfigFieldsValues($rowData) { $useConfigFields = []; foreach ($rowData as $key => $value) { - $useConfigName = self::INVENTORY_USE_CONFIG_PREFIX . $key; + $useConfigName = $key === StockItemInterface::ENABLE_QTY_INCREMENTS + ? StockItemInterface::USE_CONFIG_ENABLE_QTY_INC + : self::INVENTORY_USE_CONFIG_PREFIX . $key; + if (isset($this->defaultStockData[$key]) && isset($this->defaultStockData[$useConfigName]) && !empty($value) @@ -2742,7 +2719,12 @@ protected function checkUrlKeyDuplicates() ); foreach ($urlKeyDuplicates as $entityData) { $rowNum = $this->rowNumbers[$entityData['store_id']][$entityData['request_path']]; - $this->addRowError(ValidatorInterface::ERROR_DUPLICATE_URL_KEY, $rowNum); + $message = sprintf( + $this->retrieveMessageTemplate(ValidatorInterface::ERROR_DUPLICATE_URL_KEY), + $entityData['request_path'], + $entityData['sku'] + ); + $this->addRowError(ValidatorInterface::ERROR_DUPLICATE_URL_KEY, $rowNum, 'url_key', $message); } } } @@ -2875,4 +2857,44 @@ private function getExistingSku($sku) { return $this->_oldSku[strtolower($sku)]; } + + /** + * Format row data to DB compatible values. + * + * @param array $rowData + * @return array + */ + private function formatStockDataForRow(array $rowData): array + { + $sku = $rowData[self::COL_SKU]; + $row['product_id'] = $this->skuProcessor->getNewSku($sku)['entity_id']; + $row['website_id'] = $this->stockConfiguration->getDefaultScopeId(); + $row['stock_id'] = $this->stockRegistry->getStock($row['website_id'])->getStockId(); + + $stockItemDo = $this->stockRegistry->getStockItem($row['product_id'], $row['website_id']); + $existStockData = $stockItemDo->getData(); + + $row = array_merge( + $this->defaultStockData, + array_intersect_key($existStockData, $this->defaultStockData), + array_intersect_key($rowData, $this->defaultStockData), + $row + ); + + if ($this->stockConfiguration->isQty($this->skuProcessor->getNewSku($sku)['type_id'])) { + $stockItemDo->setData($row); + $row['is_in_stock'] = isset($row['is_in_stock']) && $stockItemDo->getBackorders() + ? $row['is_in_stock'] + : $this->stockStateProvider->verifyStock($stockItemDo); + if ($this->stockStateProvider->verifyNotification($stockItemDo)) { + $date = $this->dateTimeFactory->create('now', new \DateTimeZone('UTC')); + $row['low_stock_date'] = $date->format(DateTime::DATETIME_PHP_FORMAT); + } + $row['stock_status_changed_auto'] = (int)!$this->stockStateProvider->verifyStock($stockItemDo); + } else { + $row['qty'] = 0; + } + + return $row; + } } diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php index 2a5b009733d36..adb660dd118f9 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php @@ -12,6 +12,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\Collection as ProductOptionValueCollection; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory as ProductOptionValueCollectionFactory; +use Magento\Store\Model\Store; /** * Entity class which provide possibility to import product custom options @@ -495,7 +496,7 @@ protected function _initStores(array $data) if (isset($data['stores'])) { $this->_storeCodeToId = $data['stores']; } else { - /** @var $store \Magento\Store\Model\Store */ + /** @var $store Store */ foreach ($this->_storeManager->getStores(true) as $store) { $this->_storeCodeToId[$store->getCode()] = $store->getId(); } @@ -759,7 +760,9 @@ protected function _findExistingOptionId(array $newOptionData, array $newOptionT ksort($newOptionTitles); $existingOptions = $this->_oldCustomOptions[$productId]; foreach ($existingOptions as $optionId => $optionData) { - if ($optionData['type'] == $newOptionData['type'] && $optionData['titles'] == $newOptionTitles) { + if ($optionData['type'] == $newOptionData['type'] + && $optionData['titles'][Store::DEFAULT_STORE_ID] == $newOptionTitles[Store::DEFAULT_STORE_ID] + ) { return $optionId; } } @@ -854,7 +857,7 @@ protected function _saveNewOptionData(array $rowData, $rowNumber) $storeCode = $rowData[self::COLUMN_STORE]; $storeId = $this->_storeCodeToId[$storeCode]; } else { - $storeId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $storeId = Store::DEFAULT_STORE_ID; } if (isset($this->_productsSkuToId[$this->_rowProductSku])) { // save in existing data array @@ -1121,17 +1124,44 @@ private function processOptionRow($name, $optionRow) { $result = [ self::COLUMN_TYPE => $name ? $optionRow['type'] : '', - self::COLUMN_IS_REQUIRED => $optionRow['required'], - self::COLUMN_ROW_SKU => $optionRow['sku'], - self::COLUMN_PREFIX . 'sku' => $optionRow['sku'], self::COLUMN_ROW_TITLE => '', self::COLUMN_ROW_PRICE => '' ]; + $result = $this->addPriceData($result, $optionRow); + + if (isset($optionRow['_custom_option_store'])) { + $result[self::COLUMN_STORE] = $optionRow['_custom_option_store']; + } + if (isset($optionRow['required'])) { + $result[self::COLUMN_IS_REQUIRED] = $optionRow['required']; + } + if (isset($optionRow['sku'])) { + $result[self::COLUMN_ROW_SKU] = $optionRow['sku']; + $result[self::COLUMN_PREFIX . 'sku'] = $optionRow['sku']; + } if (isset($optionRow['option_title'])) { $result[self::COLUMN_ROW_TITLE] = $optionRow['option_title']; } + if (isset($optionRow['max_characters'])) { + $result[$this->columnMaxCharacters] = $optionRow['max_characters']; + } + + $result = $this->addFileOptions($result, $optionRow); + + return $result; + } + + /** + * Adds price data. + * + * @param array $result + * @param array $optionRow + * @return array + */ + private function addPriceData(array $result, array $optionRow): array + { if (isset($optionRow['price'])) { $percent_suffix = ''; if (isset($optionRow['price_type']) && $optionRow['price_type'] == 'percent') { @@ -1142,12 +1172,6 @@ private function processOptionRow($name, $optionRow) $result[self::COLUMN_PREFIX . 'price'] = $result[self::COLUMN_ROW_PRICE]; - if (isset($optionRow['max_characters'])) { - $result[$this->columnMaxCharacters] = $optionRow['max_characters']; - } - - $result = $this->addFileOptions($result, $optionRow); - return $result; } @@ -1172,7 +1196,8 @@ private function addFileOptions($result, $optionRow) } /** - * Import data rows + * Import data rows. + * Additional store view data (option titles) will be sought in store view specified import file rows * * @return boolean * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -1186,7 +1211,8 @@ protected function _importData() $this->_tables['catalog_product_option_type_value'] ); $prevOptionId = 0; - + $optionId = null; + $valueId = null; while ($bunch = $this->_dataSourceModel->getNextBunch()) { $products = []; $options = []; @@ -1197,23 +1223,34 @@ protected function _importData() $typeTitles = []; $parentCount = []; $childCount = []; + $optionsToRemove = []; foreach ($bunch as $rowNumber => $rowData) { + if (isset($optionId, $valueId) && empty($rowData[PRODUCT::COL_STORE_VIEW_CODE])) { + $nextOptionId = $optionId; + $nextValueId = $valueId; + } + $optionId = $nextOptionId; + $valueId = $nextValueId; $multiRowData = $this->_getMultiRowFormat($rowData); - + if (!empty($rowData[self::COLUMN_SKU]) && isset($this->_productsSkuToId[$rowData[self::COLUMN_SKU]])) { + $this->_rowProductId = $this->_productsSkuToId[$rowData[self::COLUMN_SKU]]; + if (array_key_exists('custom_options', $rowData) && trim($rowData['custom_options']) === '') { + $optionsToRemove[] = $this->_rowProductId; + } + } foreach ($multiRowData as $optionData) { $combinedData = array_merge($rowData, $optionData); - if (!$this->isRowAllowedToImport($combinedData, $rowNumber)) { - continue; - } - if (!$this->_parseRequiredData($combinedData)) { + if (!$this->isRowAllowedToImport($combinedData, $rowNumber) + || !$this->_parseRequiredData($combinedData) + ) { continue; } $optionData = $this->_collectOptionMainData( $combinedData, $prevOptionId, - $nextOptionId, + $optionId, $products, $prices ); @@ -1223,7 +1260,7 @@ protected function _importData() $this->_collectOptionTypeData( $combinedData, $prevOptionId, - $nextValueId, + $valueId, $typeValues, $typePrices, $typeTitles, @@ -1234,38 +1271,45 @@ protected function _importData() } } - // Save prepared custom options data !!! - if ($this->getBehavior() != \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { - $this->_deleteEntities(array_keys($products)); - } - - if ($this->_isReadyForSaving($options, $titles, $typeValues)) { - if ($this->getBehavior() == \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { - $this->_compareOptionsWithExisting($options, $titles, $prices, $typeValues); - $this->restoreOriginalOptionTypeIds($typeValues, $typePrices, $typeTitles); - } + $this->removeExistingOptions($products, $optionsToRemove); - $this->_saveOptions( - $options - )->_saveTitles( - $titles - )->_savePrices( - $prices - )->_saveSpecificTypeValues( - $typeValues - )->_saveSpecificTypePrices( - $typePrices - )->_saveSpecificTypeTitles( - $typeTitles - )->_updateProducts( - $products - ); - } + $types = [ + 'values' => $typeValues, + 'prices' => $typePrices, + 'titles' => $typeTitles, + ]; + //Save prepared custom options data. + $this->savePreparedCustomOptions( + $products, + $options, + $titles, + $prices, + $types + ); } return true; } + /** + * Remove all existing options if import behaviour is APPEND + * in other case remove options for products with empty "custom_options" row only. + * + * @param array $products + * @param array $optionsToRemove + * + * @return void + */ + private function removeExistingOptions(array $products, array $optionsToRemove): void + { + if ($this->getBehavior() != \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { + $this->_deleteEntities(array_keys($products)); + } elseif (!empty($optionsToRemove)) { + // Remove options for products with empty "custom_options" row + $this->_deleteEntities($optionsToRemove); + } + } + /** * Load data of existed products * @@ -1306,7 +1350,9 @@ protected function _collectOptionMainData( $optionData = null; if ($this->_rowIsMain) { - $optionData = $this->_getOptionData($rowData, $this->_rowProductId, $nextOptionId, $this->_rowType); + $optionData = empty($rowData[Product::COL_STORE_VIEW_CODE]) + ? $this->_getOptionData($rowData, $this->_rowProductId, $nextOptionId, $this->_rowType) + : ''; if (!$this->_isRowHasSpecificType($this->_rowType) && ($priceData = $this->_getPriceData($rowData, $nextOptionId, $this->_rowType)) @@ -1337,6 +1383,7 @@ protected function _collectOptionMainData( * @param array &$childCount * @return void * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ protected function _collectOptionTypeData( array $rowData, @@ -1355,43 +1402,28 @@ protected function _collectOptionTypeData( $typeValues[$prevOptionId][] = $specificTypeData['value']; // ensure default title is set - if (!isset($typeTitles[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID])) { - $typeTitles[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = - $specificTypeData['title']; + if (!isset($typeTitles[$nextValueId][Store::DEFAULT_STORE_ID])) { + $typeTitles[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['title']; } if ($specificTypeData['price']) { if ($this->_isPriceGlobal) { - $typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = - $specificTypeData['price']; + $typePrices[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['price']; } else { // ensure default price is set - if (!isset($typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID])) { - $typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = - $specificTypeData['price']; + if (!isset($typePrices[$nextValueId][Store::DEFAULT_STORE_ID])) { + $typePrices[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['price']; } $typePrices[$nextValueId][$this->_rowStoreId] = $specificTypeData['price']; } } $nextValueId++; - if (isset($parentCount[$prevOptionId])) { - $parentCount[$prevOptionId]++; - } else { - $parentCount[$prevOptionId] = 1; - } } - - if (!isset($childCount[$this->_rowStoreId][$prevOptionId])) { - $childCount[$this->_rowStoreId][$prevOptionId] = 0; - } - $parentValueId = $nextValueId - $parentCount[$prevOptionId] - + $childCount[$this->_rowStoreId][$prevOptionId]; - $specificTypeData = $this->_getSpecificTypeData($rowData, $parentValueId, false); + $specificTypeData = $this->_getSpecificTypeData($rowData, 0, false); //For others stores if ($specificTypeData) { - $typeTitles[$parentValueId][$this->_rowStoreId] = $specificTypeData['title']; - $childCount[$this->_rowStoreId][$prevOptionId]++; + $typeTitles[$nextValueId++][$this->_rowStoreId] = $specificTypeData['title']; } } } @@ -1406,7 +1438,7 @@ protected function _collectOptionTypeData( */ protected function _collectOptionTitle(array $rowData, $prevOptionId, array &$titles) { - $defaultStoreId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $defaultStoreId = Store::DEFAULT_STORE_ID; if (!empty($rowData[self::COLUMN_TITLE])) { if (!isset($titles[$prevOptionId][$defaultStoreId])) { // ensure default title is set @@ -1517,9 +1549,7 @@ private function getExistingOptionTypeId($optionId, $storeId, $optionTypeTitle) */ protected function _parseRequiredData(array $rowData) { - if ($rowData[self::COLUMN_SKU] != '' && isset($this->_productsSkuToId[$rowData[self::COLUMN_SKU]])) { - $this->_rowProductId = $this->_productsSkuToId[$rowData[self::COLUMN_SKU]]; - } elseif (!isset($this->_rowProductId)) { + if ($this->_rowProductId === null) { return false; } @@ -1530,7 +1560,7 @@ protected function _parseRequiredData(array $rowData) } $this->_rowStoreId = $this->_storeCodeToId[$rowData[self::COLUMN_STORE]]; } else { - $this->_rowStoreId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $this->_rowStoreId = Store::DEFAULT_STORE_ID; } // Init option type and set param which tell that row is main if (!empty($rowData[self::COLUMN_TYPE])) { @@ -1644,7 +1674,7 @@ protected function _getPriceData(array $rowData, $optionId, $type) ) { $priceData = [ 'option_id' => $optionId, - 'store_id' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, + 'store_id' => Store::DEFAULT_STORE_ID, 'price_type' => 'fixed', ]; @@ -1971,4 +2001,38 @@ private function getProductIdentifierField() } return $this->productEntityIdentifierField; } + + /** + * Save prepared custom options. + * + * @param array $products + * @param array $options + * @param array $titles + * @param array $prices + * @param array $types + * + * @return void + */ + private function savePreparedCustomOptions( + array $products, + array $options, + array $titles, + array $prices, + array $types + ): void { + if ($this->_isReadyForSaving($options, $titles, $types['values'])) { + if ($this->getBehavior() == \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { + $this->_compareOptionsWithExisting($options, $titles, $prices, $types['values']); + $this->restoreOriginalOptionTypeIds($types['values'], $types['prices'], $types['titles']); + } + + $this->_saveOptions($options) + ->_saveTitles($titles) + ->_savePrices($prices) + ->_saveSpecificTypeValues($types['values']) + ->_saveSpecificTypePrices($types['prices']) + ->_saveSpecificTypeTitles($types['titles']) + ->_updateProducts($products); + } + } } diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php index 939d6b2de67ee..17d084002926a 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php @@ -503,7 +503,7 @@ public function prepareAttributesWithDefaultValueForSave(array $rowData, $withDe if ($attrParams['is_static']) { continue; } - if (isset($rowData[$attrCode]) && strlen($rowData[$attrCode])) { + if (isset($rowData[$attrCode]) && strlen(trim($rowData[$attrCode]))) { if (in_array($attrParams['type'], ['select', 'boolean'])) { $resultAttrs[$attrCode] = $attrParams['options'][strtolower($rowData[$attrCode])]; } elseif ('multiselect' == $attrParams['type']) { diff --git a/app/code/Magento/CatalogImportExport/composer.json b/app/code/Magento/CatalogImportExport/composer.json index 1ecd0432f282c..56307a01e1cb6 100644 --- a/app/code/Magento/CatalogImportExport/composer.json +++ b/app/code/Magento/CatalogImportExport/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "ext-ctype": "*", "magento/framework": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php b/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php index 83defa64df250..c9ae6a96a3671 100644 --- a/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php +++ b/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php @@ -14,6 +14,14 @@ */ interface StockStatusInterface extends ExtensibleDataInterface { + /**#@+ + * Stock Status values. + */ + const STATUS_OUT_OF_STOCK = 0; + + const STATUS_IN_STOCK = 1; + /**#@-*/ + /**#@+ * Stock status object data keys */ diff --git a/app/code/Magento/CatalogInventory/Api/StockConfigurationInterface.php b/app/code/Magento/CatalogInventory/Api/StockConfigurationInterface.php index 767625e9f3489..a23d5030b8242 100644 --- a/app/code/Magento/CatalogInventory/Api/StockConfigurationInterface.php +++ b/app/code/Magento/CatalogInventory/Api/StockConfigurationInterface.php @@ -77,7 +77,7 @@ public function getEnableQtyIncrements($storeId = null); /** * @param int $storeId - * @return int + * @return float */ public function getQtyIncrements($store = null); diff --git a/app/code/Magento/CatalogInventory/Helper/Stock.php b/app/code/Magento/CatalogInventory/Helper/Stock.php index 56ad3742d8a65..494d440eeed89 100644 --- a/app/code/Magento/CatalogInventory/Helper/Stock.php +++ b/app/code/Magento/CatalogInventory/Helper/Stock.php @@ -157,7 +157,7 @@ public function addIsInStockFilterToCollection($collection) $resource = $this->getStockStatusResource(); $resource->addStockDataToCollection( $collection, - !$isShowOutOfStock || $collection->getFlag('require_stock_items') + !$isShowOutOfStock ); $collection->setFlag($stockFlag, true); } diff --git a/app/code/Magento/CatalogInventory/Model/Configuration.php b/app/code/Magento/CatalogInventory/Model/Configuration.php index 06cfde0b7d247..2f0415b40dc01 100644 --- a/app/code/Magento/CatalogInventory/Model/Configuration.php +++ b/app/code/Magento/CatalogInventory/Model/Configuration.php @@ -273,7 +273,7 @@ public function getEnableQtyIncrements($store = null) /** * @param null|string|bool|int|\Magento\Store\Model\Store $store - * @return int + * @return float */ public function getQtyIncrements($store = null) { diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php index 9223fd32e3567..f1dc9715fd247 100644 --- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php +++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php @@ -113,20 +113,20 @@ protected function _construct() } /** - * Lock Stock Item records + * Lock Stock Item records. * * @param int[] $productIds * @param int $websiteId * @return array */ - public function lockProductsStock($productIds, $websiteId) + public function lockProductsStock(array $productIds, int $websiteId) { if (empty($productIds)) { return []; } $itemTable = $this->getTable('cataloginventory_stock_item'); $select = $this->getConnection()->select()->from(['si' => $itemTable]) - ->where('website_id=?', $websiteId) + ->where('website_id = ?', $websiteId) ->where('product_id IN(?)', $productIds) ->forUpdate(true); @@ -136,12 +136,19 @@ public function lockProductsStock($productIds, $websiteId) ->columns( [ 'product_id' => 'entity_id', - 'type_id' => 'type_id' + 'type_id' => 'type_id', ] ); - $this->getConnection()->query($select); + $items = []; - return $this->getConnection()->fetchAll($selectProducts); + foreach ($this->getConnection()->query($select)->fetchAll() as $si) { + $items[$si['product_id']] = $si; + } + foreach ($this->getConnection()->fetchAll($selectProducts) as $p) { + $items[$p['product_id']]['type_id'] = $p['type_id']; + } + + return $items; } /** diff --git a/app/code/Magento/CatalogInventory/Model/Stock/Item.php b/app/code/Magento/CatalogInventory/Model/Stock/Item.php index b4b70041ce148..bcab2c622a5bc 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/Item.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/Item.php @@ -392,7 +392,7 @@ public function getUseConfigQtyIncrements() /** * Retrieve Quantity Increments * - * @return int|false + * @return int|float|false */ public function getQtyIncrements() { @@ -401,7 +401,13 @@ public function getQtyIncrements() if ($this->getUseConfigQtyIncrements()) { $this->qtyIncrements = $this->stockConfiguration->getQtyIncrements($this->getStoreId()); } else { - $this->qtyIncrements = (int) $this->getData(static::QTY_INCREMENTS); + $this->qtyIncrements = $this->getData(static::QTY_INCREMENTS); + } + + if ($this->getIsQtyDecimal()) { // Cast accordingly to decimal qty usage + $this->qtyIncrements = (float) $this->qtyIncrements; + } else { + $this->qtyIncrements = (int) $this->qtyIncrements; } } if ($this->qtyIncrements <= 0) { diff --git a/app/code/Magento/CatalogInventory/Model/Stock/Status.php b/app/code/Magento/CatalogInventory/Model/Stock/Status.php index 9a56c8e8804ec..899056d8f0835 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/Status.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/Status.php @@ -17,14 +17,6 @@ */ class Status extends AbstractExtensibleModel implements StockStatusInterface { - /**#@+ - * Stock Status values - */ - const STATUS_OUT_OF_STOCK = 0; - - const STATUS_IN_STOCK = 1; - /**#@-*/ - /**#@+ * Field name */ diff --git a/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php b/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php index 6928ab9947059..515080d56541c 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php @@ -11,7 +11,7 @@ use Magento\CatalogInventory\Api\Data\StockItemInterface; use Magento\CatalogInventory\Api\Data\StockItemInterfaceFactory; use Magento\CatalogInventory\Api\StockConfigurationInterface; -use Magento\CatalogInventory\Api\StockItemRepositoryInterface as StockItemRepositoryInterface; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; use Magento\CatalogInventory\Model\Indexer\Stock\Processor; use Magento\CatalogInventory\Model\ResourceModel\Stock\Item as StockItemResource; use Magento\CatalogInventory\Model\Spi\StockStateProviderInterface; diff --git a/app/code/Magento/CatalogInventory/Model/StockManagement.php b/app/code/Magento/CatalogInventory/Model/StockManagement.php index 02c09aef0ef5e..b3939f2e5149b 100644 --- a/app/code/Magento/CatalogInventory/Model/StockManagement.php +++ b/app/code/Magento/CatalogInventory/Model/StockManagement.php @@ -50,6 +50,11 @@ class StockManagement implements StockManagementInterface, RegisterProductSaleIn */ private $qtyCounter; + /** + * @var StockRegistryStorage + */ + private $stockRegistryStorage; + /** * @param ResourceStock $stockResource * @param StockRegistryProviderInterface $stockRegistryProvider @@ -57,6 +62,7 @@ class StockManagement implements StockManagementInterface, RegisterProductSaleIn * @param StockConfigurationInterface $stockConfiguration * @param ProductRepositoryInterface $productRepository * @param QtyCounterInterface $qtyCounter + * @param StockRegistryStorage|null $stockRegistryStorage */ public function __construct( ResourceStock $stockResource, @@ -64,7 +70,8 @@ public function __construct( StockState $stockState, StockConfigurationInterface $stockConfiguration, ProductRepositoryInterface $productRepository, - QtyCounterInterface $qtyCounter + QtyCounterInterface $qtyCounter, + StockRegistryStorage $stockRegistryStorage = null ) { $this->stockRegistryProvider = $stockRegistryProvider; $this->stockState = $stockState; @@ -72,11 +79,13 @@ public function __construct( $this->productRepository = $productRepository; $this->qtyCounter = $qtyCounter; $this->resource = $stockResource; + $this->stockRegistryStorage = $stockRegistryStorage ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(StockRegistryStorage::class); } /** * Subtract product qtys from stock. - * Return array of items that require full save + * Return array of items that require full save. * * @param string[] $items * @param int $websiteId @@ -94,9 +103,12 @@ public function registerProductsSale($items, $websiteId = null) $fullSaveItems = $registeredItems = []; foreach ($lockedItems as $lockedItemRecord) { $productId = $lockedItemRecord['product_id']; + $this->stockRegistryStorage->removeStockItem($productId, $websiteId); + /** @var StockItemInterface $stockItem */ $orderedQty = $items[$productId]; $stockItem = $this->stockRegistryProvider->getStockItem($productId, $websiteId); + $stockItem->setQty($lockedItemRecord['qty']); // update data from locked item $canSubtractQty = $stockItem->getItemId() && $this->canSubtractQty($stockItem); if (!$canSubtractQty || !$this->stockConfiguration->isQty($lockedItemRecord['type_id'])) { continue; @@ -104,7 +116,7 @@ public function registerProductsSale($items, $websiteId = null) if (!$stockItem->hasAdminArea() && !$this->stockState->checkQty($productId, $orderedQty, $stockItem->getWebsiteId()) ) { - $this->getResource()->rollBack(); + $this->getResource()->commit(); throw new \Magento\Framework\Exception\LocalizedException( __('Not all of your products are available in the requested quantity.') ); @@ -124,6 +136,7 @@ public function registerProductsSale($items, $websiteId = null) } $this->qtyCounter->correctItemsQty($registeredItems, $websiteId, '-'); $this->getResource()->commit(); + return $fullSaveItems; } diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php index e1a19bf10ecd4..3590c96bd1532 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php @@ -44,7 +44,8 @@ public function testExecuteWithAdapterErrorThrowsException() ] ); - $this->expectException(\Magento\Framework\Exception\LocalizedException::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage($exceptionMessage); $model->execute(); } diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php new file mode 100644 index 0000000000000..c9a148f3d8869 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php @@ -0,0 +1,209 @@ +selectMock = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor() + ->getMock(); + $this->contextMock = $objectManager->getObject(Context::class); + $this->scopeConfigMock = $this->getMockBuilder(Config::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dateTimeMock = $this->getMockBuilder(DateTime::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockConfigurationMock = $this->getMockBuilder(StockConfiguration::class) + ->setMethods(['getIsQtyTypeIds', 'getDefaultScopeId']) + ->disableOriginalConstructor() + ->getMock(); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connectionMock = $this->getMockBuilder(Mysql::class) + ->disableOriginalConstructor() + ->getMock(); + $this->statementMock = $this->getMockForAbstractClass(\Zend_Db_Statement_Interface::class); + $this->stock = $this->getMockBuilder(Stock::class) + ->setMethods(['getTable', 'getConnection']) + ->setConstructorArgs( + [ + 'context' => $this->contextMock, + 'scopeConfig' => $this->scopeConfigMock, + 'dateTime' => $this->dateTimeMock, + 'stockConfiguration' => $this->stockConfigurationMock, + 'storeManager' => $this->storeManagerMock, + ] + )->getMock(); + } + + /** + * Test Save Product Status per website with product ids. + * + * @dataProvider productsDataProvider + * @param int $websiteId + * @param array $productIds + * @param array $products + * @param array $result + * + * @return void + */ + public function testLockProductsStock(int $websiteId, array $productIds, array $products, array $result) + { + $this->selectMock->expects($this->exactly(2)) + ->method('from') + ->withConsecutive( + [$this->identicalTo(['si' => self::ITEM_TABLE])], + [$this->identicalTo(['p' => self::PRODUCT_TABLE]), $this->identicalTo([])] + ) + ->willReturnSelf(); + $this->selectMock->expects($this->exactly(3)) + ->method('where') + ->withConsecutive( + [$this->identicalTo('website_id = ?'), $this->identicalTo($websiteId)], + [$this->identicalTo('product_id IN(?)'), $this->identicalTo($productIds)], + [$this->identicalTo('entity_id IN (?)'), $this->identicalTo($productIds)] + ) + ->willReturnSelf(); + $this->selectMock->expects($this->once()) + ->method('forUpdate') + ->with($this->identicalTo(true)) + ->willReturnSelf(); + $this->selectMock->expects($this->once()) + ->method('columns') + ->with($this->identicalTo(['product_id' => 'entity_id', 'type_id' => 'type_id'])) + ->willReturnSelf(); + $this->connectionMock->expects($this->exactly(2)) + ->method('select') + ->willReturn($this->selectMock); + $this->connectionMock->expects($this->once()) + ->method('query') + ->with($this->identicalTo($this->selectMock)) + ->willReturn($this->statementMock); + $this->statementMock->expects($this->once()) + ->method('fetchAll') + ->willReturn($products); + $this->connectionMock->expects($this->once()) + ->method('fetchAll') + ->with($this->identicalTo($this->selectMock)) + ->willReturn($result); + $this->stock->expects($this->exactly(2)) + ->method('getTable') + ->withConsecutive( + [$this->identicalTo('cataloginventory_stock_item')], + [$this->identicalTo('catalog_product_entity')] + )->will($this->onConsecutiveCalls( + self::ITEM_TABLE, + self::PRODUCT_TABLE + )); + $this->stock->expects($this->exactly(4)) + ->method('getConnection') + ->willReturn($this->connectionMock); + + $lockResult = $this->stock->lockProductsStock($productIds, $websiteId); + + $this->assertEquals($result, $lockResult); + } + + /** + * @return array + */ + public function productsDataProvider(): array + { + return [ + [ + 0, + [1, 2, 3], + [ + 1 => ['product_id' => 1], + 2 => ['product_id' => 2], + 3 => ['product_id' => 3], + ], + [ + 1 => [ + 'product_id' => 1, + 'type_id' => 'simple', + ], + 2 => [ + 'product_id' => 2, + 'type_id' => 'simple', + ], + 3 => [ + 'product_id' => 3, + 'type_id' => 'simple', + ], + ], + ], + ]; + } +} diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php index bbc7823b13e01..e63a573b2f3a4 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php @@ -394,6 +394,7 @@ public function testGetQtyIncrements($config, $expected) $this->setDataArrayValue('qty_increments', $config['qty_increments']); $this->setDataArrayValue('enable_qty_increments', $config['enable_qty_increments']); $this->setDataArrayValue('use_config_qty_increments', $config['use_config_qty_increments']); + $this->setDataArrayValue('is_qty_decimal', $config['is_qty_decimal']); if ($config['use_config_qty_increments']) { $this->stockConfiguration->expects($this->once()) ->method('getQtyIncrements') @@ -415,7 +416,26 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => 1, 'enable_qty_increments' => true, - 'use_config_qty_increments' => true + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false, + ], + 1 + ], + [ + [ + 'qty_increments' => 1.5, + 'enable_qty_increments' => true, + 'use_config_qty_increments' => true, + 'is_qty_decimal' => true, + ], + 1.5 + ], + [ + [ + 'qty_increments' => 1.5, + 'enable_qty_increments' => true, + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false, ], 1 ], @@ -423,7 +443,8 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => -2, 'enable_qty_increments' => true, - 'use_config_qty_increments' => true + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false, ], false ], @@ -431,7 +452,8 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => 3, 'enable_qty_increments' => true, - 'use_config_qty_increments' => false + 'use_config_qty_increments' => false, + 'is_qty_decimal' => false, ], 3 ], diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php new file mode 100644 index 0000000000000..f1d546d17675b --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php @@ -0,0 +1,292 @@ +stockResourceMock = $this->getMockBuilder(ResourceStock::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockRegistryProviderMock = $this->getMockBuilder(StockRegistryProviderInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockStateMock = $this->getMockBuilder(StockState::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockConfigurationMock = $this->getMockBuilder(StockConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->productRepositoryMock = $this->getMockBuilder(ProductRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->qtyCounterMock = $this->getMockBuilder(QtyCounterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockRegistryStorageMock = $this->getMockBuilder(StockRegistryStorage::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockItemInterfaceMock = $this->getMockBuilder(StockItemInterface::class) + ->setMethods(['hasAdminArea','getWebsiteId']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockManagement = $this->getMockBuilder(StockManagement::class) + ->setMethods(['getResource', 'canSubtractQty']) + ->setConstructorArgs( + [ + 'stockResource' => $this->stockResourceMock, + 'stockRegistryProvider' => $this->stockRegistryProviderMock, + 'stockState' => $this->stockStateMock, + 'stockConfiguration' => $this->stockConfigurationMock, + 'productRepository' => $this->productRepositoryMock, + 'qtyCounter' => $this->qtyCounterMock, + 'stockRegistryStorage' => $this->stockRegistryStorageMock, + ] + )->getMock(); + + $this->stockConfigurationMock + ->expects($this->once()) + ->method('getDefaultScopeId') + ->willReturn($this->websiteId); + $this->stockManagement + ->expects($this->any()) + ->method('getResource') + ->willReturn($this->stockResourceMock); + $this->stockRegistryProviderMock + ->expects($this->any()) + ->method('getStockItem') + ->willReturn($this->stockItemInterfaceMock); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('hasAdminArea') + ->willReturn(false); + } + + /** + * @dataProvider productsWithCorrectQtyDataProvider + * + * @param array $items + * @param array $lockedItems + * @param bool $canSubtract + * @param bool $isQty + * @param bool $verifyStock + * + * @return void + */ + public function testRegisterProductsSale( + array $items, + array $lockedItems, + bool $canSubtract, + bool $isQty, + bool $verifyStock = true + ) { + $this->stockResourceMock + ->expects($this->once()) + ->method('beginTransaction'); + $this->stockResourceMock + ->expects($this->once()) + ->method('lockProductsStock') + ->willReturn([$lockedItems]); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getItemId') + ->willReturn($lockedItems['product_id']); + $this->stockManagement + ->expects($this->any()) + ->method('canSubtractQty') + ->willReturn($canSubtract); + $this->stockConfigurationMock + ->expects($this->any()) + ->method('isQty') + ->willReturn($isQty); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($this->websiteId); + $this->stockStateMock + ->expects($this->any()) + ->method('checkQty') + ->willReturn(true); + $this->stockStateMock + ->expects($this->any()) + ->method('verifyStock') + ->willReturn($verifyStock); + $this->stockStateMock + ->expects($this->any()) + ->method('verifyNotification') + ->willReturn(false); + $this->stockResourceMock + ->expects($this->once()) + ->method('commit'); + + $this->stockManagement->registerProductsSale($items, $this->websiteId); + } + + /** + * @dataProvider productsWithIncorrectQtyDataProvider + * + * @param array $items + * @param array $lockedItems + * @expectedException \Magento\Framework\Exception\LocalizedException + * @expectedExceptionMessage Not all of your products are available in the requested quantity. + * + * @return void + */ + public function testRegisterProductsSaleException(array $items, array $lockedItems) + { + $this->stockResourceMock + ->expects($this->once()) + ->method('beginTransaction'); + $this->stockResourceMock + ->expects($this->once()) + ->method('lockProductsStock') + ->willReturn([$lockedItems]); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getItemId') + ->willReturn($lockedItems['product_id']); + $this->stockManagement + ->expects($this->any()) + ->method('canSubtractQty') + ->willReturn(true); + $this->stockConfigurationMock + ->expects($this->any()) + ->method('isQty') + ->willReturn(true); + $this->stockStateMock + ->expects($this->any()) + ->method('checkQty') + ->willReturn(false); + $this->stockResourceMock + ->expects($this->once()) + ->method('commit'); + + $this->stockManagement->registerProductsSale($items, $this->websiteId); + } + + /** + * @return array + */ + public function productsWithCorrectQtyDataProvider(): array + { + return [ + [ + [1 => 3], + [ + 'product_id' => 1, + 'qty' => 10, + 'type_id' => 'simple', + ], + false, + false, + ], + [ + [2 => 4], + [ + 'product_id' => 2, + 'qty' => 10, + 'type_id' => 'simple', + ], + true, + true, + ], + [ + [3 => 5], + [ + 'product_id' => 3, + 'qty' => 10, + 'type_id' => 'simple', + ], + true, + true, + false, + ], + ]; + } + + /** + * @return array + */ + public function productsWithIncorrectQtyDataProvider(): array + { + return [ + [ + [2 => 4], + [ + 'product_id' => 2, + 'qty' => 2, + 'type_id' => 'simple', + ], + ], + ]; + } +} diff --git a/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php b/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php index d15f17530ffbc..7386f133b569a 100644 --- a/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php +++ b/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php @@ -223,6 +223,7 @@ private function prepareMeta() $this->arrayManager->slicePath($pathField, 0, -2) . '/arguments/data/config/sortOrder', $this->meta ) - 1, + 'disabled' => $this->locator->getProduct()->isLockedAttribute($fieldCode), ]; $qty['arguments']['data']['config'] = [ 'component' => 'Magento_CatalogInventory/js/components/qty-validator-changer', diff --git a/app/code/Magento/CatalogInventory/composer.json b/app/code/Magento/CatalogInventory/composer.json index 873cd60832c10..8b55b6f327988 100644 --- a/app/code/Magento/CatalogInventory/composer.json +++ b/app/code/Magento/CatalogInventory/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-config": "*", diff --git a/app/code/Magento/CatalogInventory/i18n/en_US.csv b/app/code/Magento/CatalogInventory/i18n/en_US.csv index 93406163cbe1b..19b73f847b46d 100644 --- a/app/code/Magento/CatalogInventory/i18n/en_US.csv +++ b/app/code/Magento/CatalogInventory/i18n/en_US.csv @@ -55,11 +55,7 @@ Inventory,Inventory "Only X left Threshold","Only X left Threshold" "Display Products Availability in Stock on Storefront","Display Products Availability in Stock on Storefront" "Product Stock Options","Product Stock Options" -" - Please note that these settings apply to individual items in the cart, not to the entire cart. - "," - Please note that these settings apply to individual items in the cart, not to the entire cart. - " +"Please note that these settings apply to individual items in the cart, not to the entire cart.","Please note that these settings apply to individual items in the cart, not to the entire cart." "Manage Stock","Manage Stock" Backorders,Backorders "Maximum Qty Allowed in Shopping Cart","Maximum Qty Allowed in Shopping Cart" diff --git a/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml b/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml index c27a6555e71bd..d82b4a97ddbf6 100644 --- a/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml +++ b/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml @@ -571,7 +571,6 @@ [GLOBAL] - true true diff --git a/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js b/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js index 75d684137a28b..23a33f51af6d4 100644 --- a/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js +++ b/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js @@ -20,6 +20,7 @@ define([ var isDigits = value !== 1; this.validation['validate-integer'] = isDigits; + this.validation['validate-digits'] = isDigits; this.validation['less-than-equals-to'] = isDigits ? 99999999 : 99999999.9999; this.validate(); } diff --git a/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php b/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php index 333ee845798ec..306d3b9a347b4 100644 --- a/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php +++ b/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php @@ -207,7 +207,7 @@ protected function _prepareColumns() public function getGridUrl() { return $this->getUrl( - 'catalog_rule/*/chooser', + '*/*/chooser', ['_current' => true, 'current_grid_id' => $this->getId(), 'collapse' => null] ); } diff --git a/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php b/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php index 3d9dcd05f8fac..d049d74bd2601 100644 --- a/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php +++ b/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php @@ -32,7 +32,7 @@ public function __construct(Context $context, Registry $coreRegistry) /** * Initialize category object in registry * - * @return Category + * @return Category|bool */ protected function _initCategory() { diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php index f2dd8968a903d..1f62200fc6b1b 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php @@ -12,6 +12,7 @@ use Magento\Framework\App\ObjectManager; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\CatalogRule\Model\Indexer\IndexBuilder\ProductLoader; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; /** * @api @@ -136,6 +137,11 @@ class IndexBuilder */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @var ProductLoader */ @@ -160,6 +166,7 @@ class IndexBuilder * @param RuleProductPricesPersistor|null $pricesPersistor * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|null $activeTableSwitcher * @param ProductLoader|null $productLoader + * @param TableSwapper|null $tableSwapper * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -180,7 +187,8 @@ public function __construct( ReindexRuleProductPrice $reindexRuleProductPrice = null, RuleProductPricesPersistor $pricesPersistor = null, \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher = null, - ProductLoader $productLoader = null + ProductLoader $productLoader = null, + TableSwapper $tableSwapper = null ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -218,6 +226,8 @@ public function __construct( $this->productLoader = $productLoader ?? ObjectManager::getInstance()->get( ProductLoader::class ); + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -296,13 +306,6 @@ public function reindexFull() */ protected function doReindexFull() { - $this->connection->truncateTable( - $this->getTable($this->activeTableSwitcher->getAdditionalTableName('catalogrule_product')) - ); - $this->connection->truncateTable( - $this->getTable($this->activeTableSwitcher->getAdditionalTableName('catalogrule_product_price')) - ); - foreach ($this->getAllRules() as $rule) { $this->reindexRuleProduct->execute($rule, $this->batchCount, true); } @@ -310,8 +313,7 @@ protected function doReindexFull() $this->reindexRuleProductPrice->execute($this->batchCount, null, true); $this->reindexRuleGroupWebsite->execute(true); - $this->activeTableSwitcher->switchTable( - $this->connection, + $this->tableSwapper->swapIndexTables( [ $this->getTable('catalogrule_product'), $this->getTable('catalogrule_product_price'), diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php new file mode 100644 index 0000000000000..f99f8c50a7f9a --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php @@ -0,0 +1,125 @@ +resourceConnection = $resource; + } + + /** + * Create temporary table based on given table to use instead of original. + * + * @param string $originalTableName + * + * @return string Created table name. + */ + private function createTemporaryTable(string $originalTableName): string + { + $temporaryTableName = $this->resourceConnection->getTableName( + $originalTableName . '__temp' . $this->generateRandomSuffix() + ); + + $this->resourceConnection->getConnection()->query( + sprintf( + 'create table %s like %s', + $temporaryTableName, + $this->resourceConnection->getTableName($originalTableName) + ) + ); + + return $temporaryTableName; + } + + /** + * Random suffix for temporary tables not to conflict with each other. + * + * @return string + */ + private function generateRandomSuffix(): string + { + return bin2hex(random_bytes(4)); + } + + /** + * @inheritDoc + */ + public function getWorkingTableName(string $originalTable): string + { + $originalTable = $this->resourceConnection->getTableName($originalTable); + if (!array_key_exists($originalTable, $this->temporaryTables)) { + $this->temporaryTables[$originalTable] = $this->createTemporaryTable($originalTable); + } + + return $this->temporaryTables[$originalTable]; + } + + /** + * @inheritDoc + */ + public function swapIndexTables(array $originalTablesNames) + { + $toRename = []; + /** @var string[] $toDrop */ + $toDrop = []; + /** @var string[] $temporaryTablesRenamed */ + $temporaryTablesRenamed = []; + //Renaming temporary tables to original tables' names, dropping old + //tables. + foreach ($originalTablesNames as $tableName) { + $tableName = $this->resourceConnection->getTableName($tableName); + $temporaryOriginalName = $this->resourceConnection->getTableName( + $tableName . $this->generateRandomSuffix() + ); + $temporaryTableName = $this->getWorkingTableName($tableName); + $toRename[] = [ + 'oldName' => $tableName, + 'newName' => $temporaryOriginalName, + ]; + $toRename[] = [ + 'oldName' => $temporaryTableName, + 'newName' => $tableName, + ]; + $toDrop[] = $temporaryOriginalName; + $temporaryTablesRenamed[] = $tableName; + } + + //Swapping tables. + $this->resourceConnection->getConnection()->renameTablesBatch($toRename); + //Cleaning up. + foreach ($temporaryTablesRenamed as $tableName) { + unset($this->temporaryTables[$tableName]); + } + //Removing old ones. + foreach ($toDrop as $tableName) { + $this->resourceConnection->getConnection()->dropTable($tableName); + } + } +} diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php new file mode 100644 index 0000000000000..2f37e680949ae --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php @@ -0,0 +1,33 @@ +priceResourceModel = $priceResourceModel; + } + + /** + * @inheritdoc + */ + public function modifyPrice(IndexTableStructure $priceTable, array $entityIds = []) : void + { + $connection = $this->priceResourceModel->getConnection(); + $select = $connection->select(); + + $select->join( + ['cpiw' => $this->priceResourceModel->getTable('catalog_product_index_website')], + 'cpiw.website_id = i.' . $priceTable->getWebsiteField(), + [] + ); + $select->join( + ['cpp' => $this->priceResourceModel->getMainTable()], + 'cpp.product_id = i.' . $priceTable->getEntityField() + . ' AND cpp.customer_group_id = i.' . $priceTable->getCustomerGroupField() + . ' AND cpp.website_id = i.' . $priceTable->getWebsiteField() + . ' AND cpp.rule_date = cpiw.website_date', + [] + ); + if ($entityIds) { + $select->where('i.entity_id IN (?)', $entityIds); + } + + $finalPrice = $priceTable->getFinalPriceField(); + $finalPriceExpr = $select->getConnection()->getLeastSql([ + $priceTable->getFinalPriceField(), + $select->getConnection()->getIfNullSql('cpp.rule_price', 'i.' . $finalPrice), + ]); + $minPrice = $priceTable->getMinPriceField(); + $minPriceExpr = $select->getConnection()->getLeastSql([ + $priceTable->getMinPriceField(), + $select->getConnection()->getIfNullSql('cpp.rule_price', 'i.' . $minPrice), + ]); + $select->columns([ + $finalPrice => $finalPriceExpr, + $minPrice => $minPriceExpr, + ]); + + $query = $connection->updateFromSelect($select, ['i' => $priceTable->getTableName()]); + $connection->query($query); + } +} diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php index cc5d07b18e0fa..752e6fb6ca75d 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Framework\App\ObjectManager; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; + /** * Reindex information about rule relations with customer groups and websites. */ @@ -27,23 +31,32 @@ class ReindexRuleGroupWebsite private $catalogRuleGroupWebsiteColumnsList = ['rule_id', 'customer_group_id', 'website_id']; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var ActiveTableSwitcher */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @param \Magento\Framework\Stdlib\DateTime\DateTime $dateTime * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper */ public function __construct( \Magento\Framework\Stdlib\DateTime\DateTime $dateTime, \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->dateTime = $dateTime; $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -61,10 +74,10 @@ public function execute($useAdditionalTable = false) $ruleProductTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_group_website') + $this->tableSwapper->getWorkingTableName('catalogrule_group_website') ); $ruleProductTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php index 534061d593123..55a234bb8ae27 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Reindex rule relations with products. */ @@ -17,20 +21,29 @@ class ReindexRuleProduct private $resource; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var ActiveTableSwitcher */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -65,7 +78,7 @@ public function execute( $indexTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php index 853be1888b5b9..0b1264a216257 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Persist product prices to index table. */ @@ -22,23 +26,32 @@ class RuleProductPricesPersistor private $dateFormat; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var ActiveTableSwitcher */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @param \Magento\Framework\Stdlib\DateTime $dateFormat * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper */ public function __construct( \Magento\Framework\Stdlib\DateTime $dateFormat, \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->dateFormat = $dateFormat; $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -59,7 +72,7 @@ public function execute(array $priceData, $useAdditionalTable = false) $indexTable = $this->resource->getTableName('catalogrule_product_price'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product_price') + $this->tableSwapper->getWorkingTableName('catalogrule_product_price') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php index 25d164aeee5c3..6989a33535ad8 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Build select for rule relation with product. */ @@ -32,29 +36,38 @@ class RuleProductsSelectBuilder private $metadataPool; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var ActiveTableSwitcher */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @param \Magento\Framework\App\ResourceConnection $resource * @param \Magento\Eav\Model\Config $eavConfig * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, \Magento\Eav\Model\Config $eavConfig, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Framework\EntityManager\MetadataPool $metadataPool, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->eavConfig = $eavConfig; $this->storeManager = $storeManager; $this->metadataPool = $metadataPool; $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -74,7 +87,7 @@ public function build( $indexTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php new file mode 100644 index 0000000000000..13809a381ecb9 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php @@ -0,0 +1,78 @@ +conditionsToSearchCriteriaMapper = $conditionsToSearchCriteriaMapper; + $this->searchCriteriaProcessor = $searchCriteriaProcessor; + $this->mappableConditionsProcessor = $mappableConditionsProcessor; + } + + /** + * Transforms catalog rule conditions to search criteria + * and applies them on product collection + * + * @param Combine $conditions + * @param ProductCollection $productCollection + * @return ProductCollection + * @throws InputException + */ + public function applyConditionsToCollection( + Combine $conditions, + ProductCollection $productCollection + ): ProductCollection { + // rebuild conditions to have only those that we know how to map them to product collection + $mappableConditions = $this->mappableConditionsProcessor->rebuildConditionsTree($conditions); + + // transform conditions to search criteria + $searchCriteria = $this->conditionsToSearchCriteriaMapper->mapConditionsToSearchCriteria($mappableConditions); + + $mappedProductCollection = clone $productCollection; + + // apply search criteria to new version of product collection + $this->searchCriteriaProcessor->process($searchCriteria, $mappedProductCollection); + + return $mappedProductCollection; + } +} diff --git a/app/code/Magento/CatalogRule/Model/Rule.php b/app/code/Magento/CatalogRule/Model/Rule.php index 715b7a2f3903b..d927d6f4d0c82 100644 --- a/app/code/Magento/CatalogRule/Model/Rule.php +++ b/app/code/Magento/CatalogRule/Model/Rule.php @@ -6,10 +6,34 @@ namespace Magento\CatalogRule\Model; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ProductFactory; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\CatalogRule\Api\Data\RuleExtensionInterface; use Magento\CatalogRule\Api\Data\RuleInterface; +use Magento\CatalogRule\Helper\Data; +use Magento\CatalogRule\Model\Data\Condition\Converter; +use Magento\CatalogRule\Model\Indexer\Rule\RuleProductProcessor; +use Magento\CatalogRule\Model\ResourceModel\Rule as RuleResourceModel; +use Magento\CatalogRule\Model\Rule\Action\CollectionFactory as RuleCollectionFactory; +use Magento\CatalogRule\Model\Rule\Condition\CombineFactory; +use Magento\Customer\Model\Session; use Magento\Framework\Api\AttributeValueFactory; use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Framework\App\Cache\TypeListInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Data\Collection\AbstractDb; +use Magento\Framework\Data\FormFactory; +use Magento\Framework\DataObject; use Magento\Framework\DataObject\IdentityInterface; +use Magento\Framework\Model\Context; +use Magento\Framework\Model\ResourceModel\AbstractResource; +use Magento\Framework\Model\ResourceModel\Iterator; +use Magento\Framework\Registry; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Stdlib\DateTime; +use Magento\Framework\Stdlib\DateTime\TimezoneInterface; +use Magento\Store\Model\StoreManagerInterface; +use Magento\CatalogRule\Model\ResourceModel\Product\ConditionsToCollectionApplier; /** * Catalog Rule data model @@ -136,6 +160,21 @@ class Rule extends \Magento\Rule\Model\AbstractModel implements RuleInterface, I */ protected $ruleConditionConverter; + /** + * @var ConditionsToCollectionApplier + */ + private $conditionsToCollectionApplier; + + /** + * @var array + */ + private $websitesMap; + + /** + * @var RuleResourceModel + */ + private $ruleResourceModel; + /** * Rule constructor * @@ -161,32 +200,36 @@ class Rule extends \Magento\Rule\Model\AbstractModel implements RuleInterface, I * @param ExtensionAttributesFactory|null $extensionFactory * @param AttributeValueFactory|null $customAttributeFactory * @param \Magento\Framework\Serialize\Serializer\Json $serializer + * @param \Magento\CatalogRule\Model\ResourceModel\RuleResourceModel|null $ruleResourceModel + * @param ConditionsToCollectionApplier $conditionsToCollectionApplier * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( - \Magento\Framework\Model\Context $context, - \Magento\Framework\Registry $registry, - \Magento\Framework\Data\FormFactory $formFactory, - \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, - \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory, - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\CatalogRule\Model\Rule\Condition\CombineFactory $combineFactory, - \Magento\CatalogRule\Model\Rule\Action\CollectionFactory $actionCollectionFactory, - \Magento\Catalog\Model\ProductFactory $productFactory, - \Magento\Framework\Model\ResourceModel\Iterator $resourceIterator, - \Magento\Customer\Model\Session $customerSession, - \Magento\CatalogRule\Helper\Data $catalogRuleData, - \Magento\Framework\App\Cache\TypeListInterface $cacheTypesList, - \Magento\Framework\Stdlib\DateTime $dateTime, - \Magento\CatalogRule\Model\Indexer\Rule\RuleProductProcessor $ruleProductProcessor, - \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, - \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, + Context $context, + Registry $registry, + FormFactory $formFactory, + TimezoneInterface $localeDate, + CollectionFactory $productCollectionFactory, + StoreManagerInterface $storeManager, + CombineFactory $combineFactory, + RuleCollectionFactory $actionCollectionFactory, + ProductFactory $productFactory, + Iterator $resourceIterator, + Session $customerSession, + Data $catalogRuleData, + TypeListInterface $cacheTypesList, + DateTime $dateTime, + RuleProductProcessor $ruleProductProcessor, + AbstractResource $resource = null, + AbstractDb $resourceCollection = null, array $relatedCacheTypes = [], array $data = [], ExtensionAttributesFactory $extensionFactory = null, AttributeValueFactory $customAttributeFactory = null, - \Magento\Framework\Serialize\Serializer\Json $serializer = null + Json $serializer = null, + RuleResourceModel $ruleResourceModel = null, + ConditionsToCollectionApplier $conditionsToCollectionApplier = null ) { $this->_productCollectionFactory = $productCollectionFactory; $this->_storeManager = $storeManager; @@ -200,6 +243,10 @@ public function __construct( $this->_relatedCacheTypes = $relatedCacheTypes; $this->dateTime = $dateTime; $this->_ruleProductProcessor = $ruleProductProcessor; + $this->ruleResourceModel = $ruleResourceModel ?: ObjectManager::getInstance()->get(RuleResourceModel::class); + + $this->conditionsToCollectionApplier = $conditionsToCollectionApplier + ?? ObjectManager::getInstance()->get(ConditionsToCollectionApplier::class); parent::__construct( $context, @@ -223,7 +270,7 @@ public function __construct( protected function _construct() { parent::_construct(); - $this->_init(\Magento\CatalogRule\Model\ResourceModel\Rule::class); + $this->_init(RuleResourceModel::class); $this->setIdFieldName('rule_id'); } @@ -255,7 +302,7 @@ public function getActionsInstance() public function getCustomerGroupIds() { if (!$this->hasCustomerGroupIds()) { - $customerGroupIds = $this->_getResource()->getCustomerGroupIds($this->getId()); + $customerGroupIds = $this->ruleResourceModel->getCustomerGroupIds($this->getId()); $this->setData('customer_group_ids', (array)$customerGroupIds); } return $this->_getData('customer_group_ids'); @@ -269,7 +316,7 @@ public function getCustomerGroupIds() public function getNow() { if (!$this->_now) { - return (new \DateTime())->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT); + return (new \DateTime())->format(DateTime::DATETIME_PHP_FORMAT); } return $this->_now; } @@ -306,6 +353,11 @@ public function getMatchingProductIds() } $this->getConditions()->collectValidatedAttributes($productCollection); + if ($this->canPreMapProducts()) { + $productCollection = $this->conditionsToCollectionApplier + ->applyConditionsToCollection($this->getConditions(), $productCollection); + } + $this->_resourceIterator->walk( $productCollection->getSelect(), [[$this, 'callbackValidateProduct']], @@ -320,6 +372,23 @@ public function getMatchingProductIds() return $this->_productIds; } + /** + * Check if we can use mapping for rule conditions + * + * @return bool + */ + private function canPreMapProducts() + { + $conditions = $this->getConditions(); + + // No need to map products if there is no conditions in rule + if (!$conditions || !$conditions->getConditions()) { + return false; + } + + return true; + } + /** * Callback function for product matching * @@ -348,22 +417,25 @@ public function callbackValidateProduct($args) */ protected function _getWebsitesMap() { - $map = []; - $websites = $this->_storeManager->getWebsites(); - foreach ($websites as $website) { - // Continue if website has no store to be able to create catalog rule for website without store - if ($website->getDefaultStore() === null) { - continue; + if ($this->websitesMap === null) { + $this->websitesMap = []; + $websites = $this->_storeManager->getWebsites(); + foreach ($websites as $website) { + // Continue if website has no store to be able to create catalog rule for website without store + if ($website->getDefaultStore() === null) { + continue; + } + $this->websitesMap[$website->getId()] = $website->getDefaultStore()->getId(); } - $map[$website->getId()] = $website->getDefaultStore()->getId(); } - return $map; + + return $this->websitesMap; } /** * {@inheritdoc} */ - public function validateData(\Magento\Framework\DataObject $dataObject) + public function validateData(DataObject $dataObject) { $result = parent::validateData($dataObject); if ($result === true) { @@ -470,7 +542,7 @@ public function calcProductPriceRule(Product $product, $price) */ protected function _getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId) { - return $this->_getResource()->getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId); + return $this->ruleResourceModel->getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId); } /** @@ -516,10 +588,10 @@ protected function _invalidateCache() */ public function afterSave() { - if ($this->isObjectNew()) { - $this->getMatchingProductIds(); - if (!empty($this->_productIds) && is_array($this->_productIds)) { - $this->_getResource()->addCommitCallback([$this, 'reindex']); + if ($this->isObjectNew() && !$this->_ruleProductProcessor->isIndexerScheduled()) { + $productIds = $this->getMatchingProductIds(); + if (!empty($productIds) && is_array($productIds)) { + $this->ruleResourceModel->addCommitCallback([$this, 'reindex']); } } else { $this->_ruleProductProcessor->getIndexer()->invalidate(); @@ -534,7 +606,10 @@ public function afterSave() */ public function reindex() { - $this->_ruleProductProcessor->reindexList($this->_productIds); + $productIds = $this->_productIds ? array_keys(array_filter($this->_productIds, function (array $data) { + return array_filter($data); + })) : []; + $this->_ruleProductProcessor->reindexList($productIds); } /** @@ -778,7 +853,7 @@ public function getExtensionAttributes() * @param \Magento\CatalogRule\Api\Data\RuleExtensionInterface $extensionAttributes * @return $this */ - public function setExtensionAttributes(\Magento\CatalogRule\Api\Data\RuleExtensionInterface $extensionAttributes) + public function setExtensionAttributes(RuleExtensionInterface $extensionAttributes) { return $this->_setExtensionAttributes($extensionAttributes); } @@ -790,8 +865,8 @@ public function setExtensionAttributes(\Magento\CatalogRule\Api\Data\RuleExtensi private function getRuleConditionConverter() { if (null === $this->ruleConditionConverter) { - $this->ruleConditionConverter = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\CatalogRule\Model\Data\Condition\Converter::class); + $this->ruleConditionConverter = ObjectManager::getInstance() + ->get(Converter::class); } return $this->ruleConditionConverter; } diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php new file mode 100644 index 0000000000000..6d343fe149d21 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php @@ -0,0 +1,308 @@ +searchCriteriaBuilderFactory = $searchCriteriaBuilderFactory; + $this->combinedFilterGroupFactory = $combinedFilterGroupFactory; + $this->filterFactory = $filterFactory; + } + + /** + * Maps catalog price rule conditions to search criteria + * + * @param CombinedCondition $conditions + * @return SearchCriteria + * @throws InputException + */ + public function mapConditionsToSearchCriteria(CombinedCondition $conditions): SearchCriteria + { + $filterGroup = $this->mapCombinedConditionToFilterGroup($conditions); + + $searchCriteriaBuilder = $this->searchCriteriaBuilderFactory->create(); + + if ($filterGroup !== null) { + $searchCriteriaBuilder->setFilterGroups([$filterGroup]); + } + + return $searchCriteriaBuilder->create(); + } + + /** + * @param ConditionInterface $condition + * @return null|\Magento\Framework\Api\CombinedFilterGroup|\Magento\Framework\Api\Filter + * @throws InputException + */ + private function mapConditionToFilterGroup(ConditionInterface $condition) + { + if ($condition->getType() === CombinedCondition::class) { + return $this->mapCombinedConditionToFilterGroup($condition); + } elseif ($condition->getType() === SimpleCondition::class) { + return $this->mapSimpleConditionToFilterGroup($condition); + } + + throw new InputException( + __('Undefined condition type "%1" passed in.', $condition->getType()) + ); + } + + /** + * @param Combine $combinedCondition + * @return null|\Magento\Framework\Api\CombinedFilterGroup + * @throws InputException + */ + private function mapCombinedConditionToFilterGroup(CombinedCondition $combinedCondition) + { + $filters = []; + + foreach ($combinedCondition->getConditions() as $condition) { + $filter = $this->mapConditionToFilterGroup($condition); + + if ($filter === null) { + continue; + } + + // This required to solve cases when condition is configured like: + // "If ALL/ANY of these conditions are FALSE" - we need to reverse SQL operator for this "FALSE" + if ((bool)$combinedCondition->getValue() === false) { + $this->reverseSqlOperatorInFilter($filter); + } + + $filters[] = $filter; + } + + if (count($filters) === 0) { + return null; + } + + return $this->createCombinedFilterGroup($filters, $combinedCondition->getAggregator()); + } + + /** + * @param ConditionInterface $productCondition + * @return FilterGroup|Filter + * @throws InputException + */ + private function mapSimpleConditionToFilterGroup(ConditionInterface $productCondition) + { + if (is_array($productCondition->getValue())) { + return $this->processSimpleConditionWithArrayValue($productCondition); + } + + return $this->createFilter( + $productCondition->getAttribute(), + (string) $productCondition->getValue(), + $productCondition->getOperator() + ); + } + + /** + * @param ConditionInterface $productCondition + * @return FilterGroup + * @throws InputException + */ + private function processSimpleConditionWithArrayValue(ConditionInterface $productCondition): FilterGroup + { + $filters = []; + + foreach ($productCondition->getValue() as $subValue) { + $filters[] = $this->createFilter( + $productCondition->getAttribute(), + (string) $subValue, + $productCondition->getOperator() + ); + } + + $combinationMode = $this->getGlueForArrayValues($productCondition->getOperator()); + + return $this->createCombinedFilterGroup($filters, $combinationMode); + } + + /** + * @param string $operator + * @return string + */ + private function getGlueForArrayValues(string $operator): string + { + if (in_array($operator, ['!=', '!{}', '!()'], true)) { + return 'all'; + } + + return 'any'; + } + + /** + * Reverse sql conditions to their corresponding negative analog + * + * @param Filter $filter + * @return void + * @throws InputException + */ + private function reverseSqlOperatorInFilter(Filter $filter) + { + $operatorsMap = [ + 'eq' => 'neq', + 'neq' => 'eq', + 'gteq' => 'lt', + 'lteq' => 'gt', + 'gt' => 'lteq', + 'lt' => 'gteq', + 'like' => 'nlike', + 'nlike' => 'like', + 'in' => 'nin', + 'nin' => 'in', + ]; + + if (!array_key_exists($filter->getConditionType(), $operatorsMap)) { + throw new InputException( + __( + 'Undefined SQL operator "%1" passed in. Valid operators are: %2', + $filter->getConditionType(), + implode(',', array_keys($operatorsMap)) + ) + ); + } + + $filter->setConditionType( + $operatorsMap[$filter->getConditionType()] + ); + } + + /** + * @param array $filters + * @param string $combinationMode + * @return FilterGroup + * @throws InputException + */ + private function createCombinedFilterGroup(array $filters, string $combinationMode): FilterGroup + { + return $this->combinedFilterGroupFactory->create([ + 'data' => [ + FilterGroup::FILTERS => $filters, + FilterGroup::COMBINATION_MODE => $this->mapRuleAggregatorToSQLAggregator($combinationMode) + ] + ]); + } + + /** + * @param string $field + * @param string $value + * @param string $conditionType + * @return Filter + * @throws InputException + */ + private function createFilter(string $field, string $value, string $conditionType): Filter + { + return $this->filterFactory->create([ + 'data' => [ + Filter::KEY_FIELD => $field, + Filter::KEY_VALUE => $value, + Filter::KEY_CONDITION_TYPE => $this->mapRuleOperatorToSQLCondition($conditionType) + ] + ]); + } + + /** + * Maps catalog price rule operators to their corresponding operators in SQL + * + * @param string $ruleOperator + * @return string + * @throws InputException + */ + private function mapRuleOperatorToSQLCondition(string $ruleOperator): string + { + $operatorsMap = [ + '==' => 'eq', // is + '!=' => 'neq', // is not + '>=' => 'gteq', // equals or greater than + '<=' => 'lteq', // equals or less than + '>' => 'gt', // greater than + '<' => 'lt', // less than + '{}' => 'like', // contains + '!{}' => 'nlike', // does not contains + '()' => 'in', // is one of + '!()' => 'nin', // is not one of + ]; + + if (!array_key_exists($ruleOperator, $operatorsMap)) { + throw new InputException( + __( + 'Undefined rule operator "%1" passed in. Valid operators are: %2', + $ruleOperator, + implode(',', array_keys($operatorsMap)) + ) + ); + } + + return $operatorsMap[$ruleOperator]; + } + + /** + * Map rule combine aggregations to corresponding SQL operator + * + * @param string $ruleAggregator + * @return string + * @throws InputException + */ + private function mapRuleAggregatorToSQLAggregator(string $ruleAggregator): string + { + $operatorsMap = [ + 'all' => 'AND', + 'any' => 'OR', + ]; + + if (!array_key_exists(strtolower($ruleAggregator), $operatorsMap)) { + throw new InputException( + __( + 'Undefined rule aggregator "%1" passed in. Valid operators are: %2', + $ruleAggregator, + implode(',', array_keys($operatorsMap)) + ) + ); + } + + return $operatorsMap[$ruleAggregator]; + } +} diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php new file mode 100644 index 0000000000000..63c3f62ad0590 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php @@ -0,0 +1,139 @@ +customConditionProvider = $customConditionProvider; + $this->eavConfig = $eavConfig; + } + + /** + * @param Combine $conditions + * @return Combine + */ + public function rebuildConditionsTree(CombinedCondition $conditions): CombinedCondition + { + return $this->rebuildCombinedCondition($conditions); + } + + /** + * @param Combine $originalConditions + * @return Combine + * @throws InputException + */ + private function rebuildCombinedCondition(CombinedCondition $originalConditions): CombinedCondition + { + $validConditions = []; + $invalidConditions = []; + + foreach ($originalConditions->getConditions() as $condition) { + if ($condition->getType() === CombinedCondition::class) { + $rebuildSubCondition = $this->rebuildCombinedCondition($condition); + + if (count($rebuildSubCondition->getConditions()) > 0) { + $validConditions[] = $rebuildSubCondition; + } else { + $invalidConditions[] = $rebuildSubCondition; + } + + continue; + } + + if ($condition->getType() === SimpleCondition::class) { + if ($this->validateSimpleCondition($condition)) { + $validConditions[] = $condition; + } else { + $invalidConditions[] = $condition; + } + + continue; + } + + throw new InputException( + __('Undefined condition type "%1" passed in.', $condition->getType()) + ); + } + + // if resulted condition group has left no mappable conditions - we can remove it at all + if (count($invalidConditions) > 0 && strtolower($originalConditions->getAggregator()) === 'any') { + $validConditions = []; + } + + $rebuildCondition = clone $originalConditions; + $rebuildCondition->setConditions($validConditions); + + return $rebuildCondition; + } + + /** + * @param Product $originalConditions + * @return bool + */ + private function validateSimpleCondition(SimpleCondition $originalConditions): bool + { + return $this->canUseFieldForMapping($originalConditions->getAttribute()); + } + + /** + * Checks if condition field is mappable + * + * @param string $fieldName + * @return bool + */ + private function canUseFieldForMapping(string $fieldName): bool + { + // We can map field to search criteria if we have custom processor for it + if ($this->customConditionProvider->hasProcessorForField($fieldName)) { + return true; + } + + // Also we can map field to search criteria if it is an EAV attribute + $attribute = $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $fieldName); + + // We have this weird check for getBackendType() to verify that attribute really exists + // because due to eavConfig behavior even if pass non existing attribute code we still receive AbstractAttribute + // getAttributeId() is not sufficient too because some attributes don't have it - e.g. attribute_set_id + if ($attribute && $attribute->getBackendType() !== null) { + return true; + } + + // In any other cases we can't map field to search criteria + return false; + } +} diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexerTableSwapperTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexerTableSwapperTest.php new file mode 100644 index 0000000000000..654a1180d8717 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexerTableSwapperTest.php @@ -0,0 +1,168 @@ +resourceConnectionMock = $this->createMock(ResourceConnection::class); + + $this->adapterInterfaceMock = $this->getMockBuilder(AdapterInterface::class)->getMockForAbstractClass(); + /** @var \Zend_Db_Statement_Interface $statementInterfaceMock */ + $this->statementInterfaceMock = $this->getMockBuilder(\Zend_Db_Statement_Interface::class) + ->getMockForAbstractClass(); + /** @var Table $tableMock */ + $this->tableMock = $this->createMock(Table::class); + $this->resourceConnectionMock->expects($this->any()) + ->method('getConnection') + ->willReturn($this->adapterInterfaceMock); + } + + /** + * @return void + */ + public function testGetWorkingTableNameWithExistingTemporaryTable(): void + { + $model = new IndexerTableSwapper($this->resourceConnectionMock); + $originalTableName = 'catalogrule_product'; + $temporaryTableNames = ['catalogrule_product' => 'catalogrule_product__temp9604']; + $this->setObjectProperty($model, 'temporaryTables', $temporaryTableNames); + + $this->resourceConnectionMock->expects($this->once()) + ->method('getTableName') + ->with($originalTableName) + ->willReturn($originalTableName); + + $this->assertEquals( + $temporaryTableNames[$originalTableName], + $model->getWorkingTableName($originalTableName) + ); + } + + /** + * @return void + */ + public function testGetWorkingTableNameWithoutExistingTemporaryTable(): void + { + $model = new IndexerTableSwapper($this->resourceConnectionMock); + $originalTableName = 'catalogrule_product'; + $temporaryTableName = 'catalogrule_product__temp9604'; + $this->setObjectProperty($model, 'temporaryTables', []); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getTableName') + ->with($originalTableName) + ->willReturn($originalTableName); + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getTableName') + ->with($this->stringStartsWith($originalTableName . '__temp')) + ->willReturn($temporaryTableName); + + $this->assertEquals( + $temporaryTableName, + $model->getWorkingTableName($originalTableName) + ); + } + + /** + * Sets object non-public property. + * + * @param mixed $object + * @param string $propertyName + * @param mixed $value + * + * @return void + */ + private function setObjectProperty($object, string $propertyName, $value): void + { + $reflectionClass = new \ReflectionClass($object); + $reflectionProperty = $reflectionClass->getProperty($propertyName); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($object, $value); + } + + /** + * @return void + */ + public function testSwapIndexTables(): void + { + $model = $this->getMockBuilder(IndexerTableSwapper::class) + ->setMethods(['getWorkingTableName']) + ->setConstructorArgs([$this->resourceConnectionMock]) + ->getMock(); + $originalTableName = 'catalogrule_product'; + $temporaryOriginalTableName = 'catalogrule_product9604'; + $temporaryTableName = 'catalogrule_product__temp9604'; + $toRename = [ + [ + 'oldName' => $originalTableName, + 'newName' => $temporaryOriginalTableName, + ], + [ + 'oldName' => $temporaryTableName, + 'newName' => $originalTableName, + ], + ]; + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getTableName') + ->with($originalTableName) + ->willReturn($originalTableName); + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getTableName') + ->with($this->stringStartsWith($originalTableName)) + ->willReturn($temporaryOriginalTableName); + $model->expects($this->once()) + ->method('getWorkingTableName') + ->with($originalTableName) + ->willReturn($temporaryTableName); + $this->adapterInterfaceMock->expects($this->once()) + ->method('renameTablesBatch') + ->with($toRename) + ->willReturn(true); + $this->adapterInterfaceMock->expects($this->once()) + ->method('dropTable') + ->with($temporaryOriginalTableName) + ->willReturn(true); + + $model->swapIndexTables([$originalTableName]); + } +} diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php index d60a662193e54..f02c2c643f809 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class ReindexRuleGroupWebsiteTest extends \PHPUnit\Framework\TestCase { /** @@ -24,10 +27,15 @@ class ReindexRuleGroupWebsiteTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->dateTimeMock = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime\DateTime::class) @@ -37,13 +45,17 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) + $this->getMockBuilder(ActiveTableSwitcher::class) ->disableOriginalConstructor() ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite( $this->dateTimeMock, $this->resourceMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -55,31 +67,25 @@ public function testExecute() $this->resourceMock->expects($this->at(0))->method('getConnection')->willReturn($connectionMock); $this->dateTimeMock->expects($this->once())->method('gmtTimestamp')->willReturn($timeStamp); - $this->activeTableSwitcherMock->expects($this->at(0)) - ->method('getAdditionalTableName') - ->with('catalogrule_group_website') - ->willReturn('catalogrule_group_website_replica'); - $this->activeTableSwitcherMock->expects($this->at(1)) - ->method('getAdditionalTableName') - ->with('catalogrule_product') - ->willReturn('catalogrule_product_replica'); + $this->tableSwapperMock->expects($this->any()) + ->method('getWorkingTableName') + ->willReturnMap( + [ + ['catalogrule_group_website', 'catalogrule_group_website_replica'], + ['catalogrule_product', 'catalogrule_product_replica'], + ] + ); - $this->resourceMock->expects($this->at(1)) - ->method('getTableName') - ->with('catalogrule_group_website') - ->willReturn('catalogrule_group_website'); - $this->resourceMock->expects($this->at(2)) - ->method('getTableName') - ->with('catalogrule_product') - ->willReturn('catalogrule_product'); - $this->resourceMock->expects($this->at(3)) - ->method('getTableName') - ->with('catalogrule_group_website_replica') - ->willReturn('catalogrule_group_website_replica'); - $this->resourceMock->expects($this->at(4)) + $this->resourceMock->expects($this->any()) ->method('getTableName') - ->with('catalogrule_product_replica') - ->willReturn('catalogrule_product_replica'); + ->willReturnMap( + [ + ['catalogrule_group_website', 'default', 'catalogrule_group_website'], + ['catalogrule_product', 'default', 'catalogrule_product'], + ['catalogrule_group_website_replica', 'default', 'catalogrule_group_website_replica'], + ['catalogrule_product_replica', 'default', 'catalogrule_product_replica'], + ] + ); $selectMock = $this->getMockBuilder(\Magento\Framework\DB\Select::class) ->disableOriginalConstructor() diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php index b829468396bf0..0dbbaee8d2871 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class ReindexRuleProductTest extends \PHPUnit\Framework\TestCase { /** @@ -19,22 +22,30 @@ class ReindexRuleProductTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) - ->disableOriginalConstructor() - ->getMock(); + $this->activeTableSwitcherMock = $this->getMockBuilder(ActiveTableSwitcher::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct( $this->resourceMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -71,8 +82,8 @@ public function testExecute() $ruleMock->expects($this->exactly(2))->method('getWebsiteIds')->willReturn(1); $ruleMock->expects($this->once())->method('getMatchingProductIds')->willReturn($productIds); - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product') ->willReturn('catalogrule_product_replica'); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php index 3efe26971627e..03163aa2d7c45 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class RuleProductPricesPersistorTest extends \PHPUnit\Framework\TestCase { /** @@ -24,10 +27,15 @@ class RuleProductPricesPersistorTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->dateTimeMock = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime::class) @@ -36,14 +44,17 @@ protected function setUp() $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) - ->disableOriginalConstructor() - ->getMock(); + $this->activeTableSwitcherMock = $this->getMockBuilder(ActiveTableSwitcher::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor( $this->dateTimeMock, $this->resourceMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -64,8 +75,8 @@ public function testExecute() ]; $tableName = 'catalogrule_product_price_replica'; - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product_price') ->willReturn($tableName); @@ -120,8 +131,8 @@ public function testExecuteWithException() ]; $tableName = 'catalogrule_product_price_replica'; - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product_price') ->willReturn($tableName); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php index 92b4bb353f046..e43fe41dc2127 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php @@ -7,6 +7,8 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Select; use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; @@ -35,7 +37,7 @@ class RuleProductsSelectBuilderTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; @@ -49,6 +51,11 @@ class RuleProductsSelectBuilderTest extends \PHPUnit\Framework\TestCase */ private $metadataPoolMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->storeManagerMock = $this->getMockBuilder(\Magento\Store\Model\StoreManagerInterface::class) @@ -56,8 +63,7 @@ protected function setUp() $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) + $this->activeTableSwitcherMock = $this->getMockBuilder(ActiveTableSwitcher::class) ->disableOriginalConstructor() ->getMock(); $this->eavConfigMock = $this->getMockBuilder(\Magento\Eav\Model\Config::class) @@ -66,13 +72,17 @@ protected function setUp() $this->metadataPoolMock = $this->getMockBuilder(\Magento\Framework\EntityManager\MetadataPool::class) ->disableOriginalConstructor() ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder( $this->resourceMock, $this->eavConfigMock, $this->storeManagerMock, $this->metadataPoolMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -92,8 +102,8 @@ public function testBuild() $connectionMock = $this->getMockBuilder(AdapterInterface::class)->disableOriginalConstructor()->getMock(); $this->resourceMock->expects($this->at(0))->method('getConnection')->willReturn($connectionMock); - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with($ruleTable) ->willReturn($rplTable); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php new file mode 100644 index 0000000000000..e28c443e46fed --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php @@ -0,0 +1,1037 @@ +eavConfigMock = $this->getMockBuilder(EavConfig::class) + ->disableOriginalConstructor() + ->setMethods(['getAttribute']) + ->getMock(); + + $this->customConditionProcessorBuilderMock = $this->getMockBuilder( + CustomConditionProviderInterface::class + )->disableOriginalConstructor() + ->setMethods(['hasProcessorForField']) + ->getMockForAbstractClass(); + + $this->objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + + $this->mappableConditionProcessor = $this->objectManagerHelper->getObject( + MappableConditionsProcessor::class, + [ + 'customConditionProvider' => $this->customConditionProcessorBuilderMock, + 'eavConfig' => $this->eavConfigMock, + ] + ); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-2 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + * ] + */ + public function testConditionV1() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-2 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * ] + * ] + * ] + */ + public function testConditionV2() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition( + [ + $simpleCondition1 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-2 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => [] + * ] + * ] + */ + public function testConditionV3() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition([], 'all'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + */ + public function testConditionV4() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'any' + ); + + $validSubCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition2 + ], + 'all' + ); + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition1, + $validSubCondition2 + ], + 'any' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + */ + public function testConditionV5() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'all' + ); + + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition2 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when all condition are mappable there must not be any changes to input + */ + public function testConditionV6() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2, + $subCondition1 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($inputCondition, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-2 => [ attribute => field-2 ] + * condition-3 => [ attribute => field-3 ] + * ] + * ] + * ] + * ] + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-4 => [ attribute => field-4 ] + * condition-5 => [ attribute => field-5 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-6 => [ attribute => field-6 ] + * condition-7 => [ attribute => field-7 ] + * ] + * ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-3 and condition-5 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-6 => [ attribute => field-6 ] + * condition-7 => [ attribute => field-7 ] + * ] + * ] + * ] + * ] + * ] + * ] + * ] + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testConditionV7() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + $field3 = 'field-3'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition2, + $simpleCondition3 + ], + 'any' + ); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $subCondition1 + ], + 'all' + ); + + $field4 = 'field-4'; + $field5 = 'field-5'; + $field6 = 'field-6'; + $field7 = 'field-7'; + + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $simpleCondition5 = $this->getMockForSimpleCondition($field5); + $simpleCondition6 = $this->getMockForSimpleCondition($field6); + $simpleCondition7 = $this->getMockForSimpleCondition($field7); + + $subCondition3 = $this->getMockForCombinedCondition( + [ + $simpleCondition4, + $simpleCondition5 + ], + 'any' + ); + $subCondition4 = $this->getMockForCombinedCondition( + [ + $simpleCondition6, + $simpleCondition7 + ], + 'any' + ); + $subCondition5 = $this->getMockForCombinedCondition( + [ + $subCondition3, + $subCondition4 + ], + 'all' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition2, + $subCondition5 + ], + 'any' + ); + + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition1 + ], + 'all' + ); + $validSubCondition4 = $this->getMockForCombinedCondition( + [ + $subCondition4 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition2, + $validSubCondition4 + ], + 'any' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, true], + [$field3, false], + [$field4, true], + [$field5, false], + [$field6, true], + [$field7, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-4 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + */ + public function testConditionV8() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * condition-5 => [ attribute => field-5 ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-4 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + */ + public function testConditionV9() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $field5 = 'field-5'; + $simpleCondition5 = $this->getMockForSimpleCondition($field5); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2, + $simpleCondition5 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, false], + [$field5, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * @expectedException \Magento\Framework\Exception\InputException + * @expectedExceptionMessage Undefined condition type "olo-lo" passed in. + */ + public function testException() + { + $simpleCondition = $this->getMockForSimpleCondition('field'); + $simpleCondition->setType('olo-lo'); + $inputCondition = $this->getMockForCombinedCondition([$simpleCondition], 'any'); + + $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + } + + protected function getMockForCombinedCondition($subConditions, $aggregator) + { + $mock = $this->getMockBuilder(CombinedCondition::class) + ->disableOriginalConstructor() + ->setMethods() + ->getMock(); + + $mock->setConditions($subConditions); + $mock->setAggregator($aggregator); + $mock->setType(CombinedCondition::class); + + return $mock; + } + + protected function getMockForSimpleCondition($attribute) + { + $mock = $this->getMockBuilder(SimpleCondition::class) + ->disableOriginalConstructor() + ->setMethods() + ->getMock(); + + $mock->setAttribute($attribute); + $mock->setType(SimpleCondition::class); + + return $mock; + } +} diff --git a/app/code/Magento/CatalogRule/composer.json b/app/code/Magento/CatalogRule/composer.json index a00e1b5e69a14..5b09765d9ae51 100644 --- a/app/code/Magento/CatalogRule/composer.json +++ b/app/code/Magento/CatalogRule/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -17,7 +17,7 @@ }, "suggest": { "magento/module-import-export": "*", - "magento/module-catalog-rule-sample-data": "Sample Data version:100.3.*" + "magento/module-catalog-rule-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/CatalogRule/etc/db_schema.xml b/app/code/Magento/CatalogRule/etc/db_schema.xml index 883a992d8c730..f4c40a6930cc0 100644 --- a/app/code/Magento/CatalogRule/etc/db_schema.xml +++ b/app/code/Magento/CatalogRule/etc/db_schema.xml @@ -60,7 +60,7 @@ - + @@ -198,7 +198,7 @@ - + diff --git a/app/code/Magento/CatalogRule/etc/db_schema_whitelist.json b/app/code/Magento/CatalogRule/etc/db_schema_whitelist.json index a41d31a08ee29..f5aaece43f179 100644 --- a/app/code/Magento/CatalogRule/etc/db_schema_whitelist.json +++ b/app/code/Magento/CatalogRule/etc/db_schema_whitelist.json @@ -49,7 +49,8 @@ }, "constraint": { "PRIMARY": true, - "IDX_EAA51B56FF092A0DCB795D1CEF812B7B": true + "IDX_EAA51B56FF092A0DCB795D1CEF812B7B": true, + "UNQ_EAA51B56FF092A0DCB795D1CEF812B7B": true } }, "catalogrule_product_price": { @@ -146,7 +147,8 @@ }, "constraint": { "PRIMARY": true, - "UNQ_BDF2B92A4F0B28D7896648B3B8A26089": true + "IDX_EAA51B56FF092A0DCB795D1CEF812B7B": true, + "UNQ_EAA51B56FF092A0DCB795D1CEF812B7B": true } }, "catalogrule_product_price_replica": { @@ -190,4 +192,4 @@ "PRIMARY": true } } -} \ No newline at end of file +} diff --git a/app/code/Magento/CatalogRule/etc/di.xml b/app/code/Magento/CatalogRule/etc/di.xml index 4b368b1cef89a..8ed88dd4f3fdb 100644 --- a/app/code/Magento/CatalogRule/etc/di.xml +++ b/app/code/Magento/CatalogRule/etc/di.xml @@ -126,4 +126,35 @@ + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\ProductCategoryCondition + + + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\DefaultCondition + CatalogRuleCustomConditionProvider + + + + + CatalogRuleAdvancedFilterProcessor + + + + + CatalogRuleCustomConditionProvider + + + + + + + Magento\CatalogRule\Model\Indexer\ProductPriceIndexModifier + + + diff --git a/app/code/Magento/CatalogRule/etc/indexer.xml b/app/code/Magento/CatalogRule/etc/indexer.xml index 08ed456457bfe..e648ea567631c 100644 --- a/app/code/Magento/CatalogRule/etc/indexer.xml +++ b/app/code/Magento/CatalogRule/etc/indexer.xml @@ -14,4 +14,9 @@ Catalog Product Rule Indexed product/rule association + + + + + diff --git a/app/code/Magento/CatalogRuleConfigurable/composer.json b/app/code/Magento/CatalogRuleConfigurable/composer.json index 46475a72e497f..657e6efb4e44c 100644 --- a/app/code/Magento/CatalogRuleConfigurable/composer.json +++ b/app/code/Magento/CatalogRuleConfigurable/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/magento-composer-installer": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php index 6bf735e2141cc..182ecf873d77a 100644 --- a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php @@ -12,7 +12,13 @@ use Magento\Framework\DB\Select; use Magento\Framework\Search\Request\BucketInterface; use Magento\Framework\Search\Request\Dimension; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver as TableResolver; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class DataProvider { /** @@ -32,20 +38,28 @@ class DataProvider */ protected $categoryFactory; + /** + * @var TableResolver + */ + private $tableResolver; + /** * DataProvider constructor. * @param ResourceConnection $resource * @param ScopeResolverInterface $scopeResolver * @param Resolver $layerResolver + * @param TableResolver|null $tableResolver */ public function __construct( ResourceConnection $resource, ScopeResolverInterface $scopeResolver, - Resolver $layerResolver + Resolver $layerResolver, + TableResolver $tableResolver = null ) { $this->resource = $resource; $this->scopeResolver = $scopeResolver; $this->layer = $layerResolver->get(); + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); } /** @@ -69,9 +83,18 @@ public function aroundGetDataSet( $currentScopeId = $this->scopeResolver->getScope($dimensions['scope']->getValue())->getId(); $currentCategory = $this->layer->getCurrentCategory(); + $catalogCategoryProductDimension = new Dimension(\Magento\Store\Model\Store::ENTITY, $currentScopeId); + + $catalogCategoryProductTableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); + $derivedTable = $this->resource->getConnection()->select(); $derivedTable->from( - ['main_table' => $this->resource->getTableName('catalog_category_product_index')], + ['main_table' => $catalogCategoryProductTableName], [ 'value' => 'category_id' ] diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php index d8356fd672576..d51be12f01db5 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php @@ -133,7 +133,7 @@ public function executeByDimension(array $dimensions, \Traversable $entityIds = array_merge($entityIds, $this->fulltextResource->getRelationsByChild($entityIds)) ); $saveHandler->deleteIndex($dimensions, new \ArrayIterator($productIds)); - $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId, $entityIds)); + $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId, $productIds)); } } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php index fcffad31fc84c..5ad2635576857 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php @@ -221,7 +221,7 @@ private function getSelectForSearchableProducts( $lastProductId, $batch ) { - $websiteId = $this->storeManager->getStore($storeId)->getWebsiteId(); + $websiteId = (int)$this->storeManager->getStore($storeId)->getWebsiteId(); $lastProductId = (int) $lastProductId; $select = $this->connection->select() @@ -490,9 +490,9 @@ private function getProductTypeInstance($typeId) public function getProductChildIds($productId, $typeId) { $typeInstance = $this->getProductTypeInstance($typeId); - $relation = $typeInstance->isComposite( - $this->getProductEmulator($typeId) - ) ? $typeInstance->getRelationInfo() : false; + $relation = $typeInstance->isComposite($this->getProductEmulator($typeId)) + ? $typeInstance->getRelationInfo() + : false; if ($relation && $relation->getTable() && $relation->getParentFieldName() && $relation->getChildFieldName()) { $select = $this->connection->select()->from( diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php index a8208404d0451..8a18c1bfcc576 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php @@ -5,6 +5,7 @@ */ namespace Magento\CatalogSearch\Model\Indexer\Fulltext\Action; +use Magento\Catalog\Api\Data\ProductInterface; use Magento\CatalogSearch\Model\Indexer\Fulltext; use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; @@ -307,27 +308,30 @@ protected function getTable($table) /** * Get parents IDs of product IDs to be re-indexed * + * @deprecated as it not used in the class anymore and duplicates another API method + * @see \Magento\CatalogSearch\Model\ResourceModel\Fulltext::getRelationsByChild() + * * @param int[] $entityIds * @return int[] + * @throws \Exception */ protected function getProductIdsFromParents(array $entityIds) { - /** @var \Magento\Framework\EntityManager\EntityMetadataInterface $metadata */ - $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - $fieldForParent = $metadata->getLinkField(); + $connection = $this->connection; + $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); - $select = $this->connection + $select = $connection ->select() ->from(['relation' => $this->getTable('catalog_product_relation')], []) ->distinct(true) ->where('child_id IN (?)', $entityIds) - ->where('parent_id NOT IN (?)', $entityIds) ->join( ['cpe' => $this->getTable('catalog_product_entity')], - 'relation.parent_id = cpe.' . $fieldForParent, + 'relation.parent_id = cpe.' . $linkField, ['cpe.entity_id'] ); - return $this->connection->fetchCol($select); + + return $connection->fetchCol($select); } /** @@ -345,7 +349,7 @@ protected function getProductIdsFromParents(array $entityIds) public function rebuildStoreIndex($storeId, $productIds = null) { if ($productIds !== null) { - $productIds = array_unique(array_merge($productIds, $this->getProductIdsFromParents($productIds))); + $productIds = array_unique($productIds); } // prepare searchable attributes diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Model/Plugin/Category.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Model/Plugin/Category.php new file mode 100644 index 0000000000000..ed841996ea07b --- /dev/null +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Model/Plugin/Category.php @@ -0,0 +1,45 @@ +fulltextIndexerProcessor = $fulltextIndexerProcessor; + } + + /** + * Mark fulltext indexer as invalid post-deletion of category. + * + * @param Resource $subjectCategory + * @param Resource $resultCategory + * @return Resource + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDelete(Resource $subjectCategory, Resource $resultCategory) : Resource + { + $this->fulltextIndexerProcessor->markIndexerAsInvalid(); + + return $resultCategory; + } +} diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php index ffba417eb3ac7..fac8c4d2a47f6 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php @@ -120,7 +120,7 @@ public function processAttributeValue($attribute, $value) * * @param array $index * @param string $separator - * @return string + * @return array */ public function prepareEntityIndex($index, $separator = ' ') { diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php index 7a94d75d2b44b..0835fb66f876a 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php @@ -103,17 +103,20 @@ public function getRelationsByChild($childIds) { $connection = $this->getConnection(); $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); - $select = $connection->select()->from( - ['relation' => $this->getTable('catalog_product_relation')], - [] - )->join( - ['cpe' => $this->getTable('catalog_product_entity')], - 'cpe.' . $linkField . ' = relation.parent_id', - ['cpe.entity_id'] - )->where( - 'relation.child_id IN (?)', - $childIds - )->distinct(true); + $select = $connection + ->select() + ->from( + ['relation' => $this->getTable('catalog_product_relation')], + [] + )->distinct(true) + ->join( + ['cpe' => $this->getTable('catalog_product_entity')], + 'cpe.' . $linkField . ' = relation.parent_id', + ['cpe.entity_id'] + )->where( + 'relation.child_id IN (?)', + $childIds + ); return $connection->fetchCol($select); } diff --git a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php index dadce2ed0240c..66e0457e7fadd 100644 --- a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php +++ b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php @@ -7,6 +7,10 @@ namespace Magento\CatalogSearch\Model\Search\FilterMapper; use Magento\CatalogSearch\Model\Adapter\Mysql\Filter\AliasResolver; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver as TableResolver; +use Magento\Framework\Search\Request\Dimension; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; /** * Strategy which processes exclusions from general rules @@ -34,19 +38,27 @@ class ExclusionStrategy implements FilterStrategyInterface */ private $validFields = ['price', 'category_ids']; + /** + * @var TableResolver + */ + private $tableResolver; + /** * @param \Magento\Framework\App\ResourceConnection $resourceConnection * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param AliasResolver $aliasResolver + * @param TableResolver|null $tableResolver */ public function __construct( \Magento\Framework\App\ResourceConnection $resourceConnection, \Magento\Store\Model\StoreManagerInterface $storeManager, - AliasResolver $aliasResolver + AliasResolver $aliasResolver, + TableResolver $tableResolver = null ) { $this->resourceConnection = $resourceConnection; $this->storeManager = $storeManager; $this->aliasResolver = $aliasResolver; + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); } /** @@ -112,7 +124,18 @@ private function applyCategoryFilter( \Magento\Framework\DB\Select $select ) { $alias = $this->aliasResolver->getAlias($filter); - $tableName = $this->resourceConnection->getTableName('catalog_category_product_index'); + + $catalogCategoryProductDimension = new Dimension( + \Magento\Store\Model\Store::ENTITY, + $this->storeManager->getStore()->getId() + ); + + $tableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); $mainTableAlias = $this->extractTableAliasFromSelect($select); $select->joinInner( diff --git a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/ApplyStockConditionToSelect.php b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/ApplyStockConditionToSelect.php index 1fd4a45c7e9d0..dee8b09a051ec 100644 --- a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/ApplyStockConditionToSelect.php +++ b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/ApplyStockConditionToSelect.php @@ -9,6 +9,7 @@ use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Select; +use Magento\CatalogInventory\Model\Stock\Status; /** * Apply stock condition to select. @@ -43,7 +44,12 @@ public function execute( ) { $select->joinInner( [$stockAlias => $this->resourceConnection->getTableName('cataloginventory_stock_status')], - sprintf('%2$s.product_id = %1$s.source_id', $alias, $stockAlias), + sprintf( + '%2$s.product_id = %1$s.source_id AND %2$s.stock_status = %3$d', + $alias, + $stockAlias, + Status::STATUS_IN_STOCK + ), [] ); } diff --git a/app/code/Magento/CatalogSearch/composer.json b/app/code/Magento/CatalogSearch/composer.json index 3a0b2fb60e1ff..72bf2ec90a582 100644 --- a/app/code/Magento/CatalogSearch/composer.json +++ b/app/code/Magento/CatalogSearch/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml b/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml index d6c72d883fedf..2d41d17889e49 100644 --- a/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml +++ b/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml @@ -31,4 +31,7 @@ Magento\CatalogSearch\Ui\DataProvider\Product\AddFulltextFilterToCollection + + + diff --git a/app/code/Magento/CatalogSearch/etc/webapi_rest/di.xml b/app/code/Magento/CatalogSearch/etc/webapi_rest/di.xml new file mode 100644 index 0000000000000..c7293783dc609 --- /dev/null +++ b/app/code/Magento/CatalogSearch/etc/webapi_rest/di.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/code/Magento/CatalogSearch/etc/webapi_soap/di.xml b/app/code/Magento/CatalogSearch/etc/webapi_soap/di.xml new file mode 100644 index 0000000000000..c7293783dc609 --- /dev/null +++ b/app/code/Magento/CatalogSearch/etc/webapi_soap/di.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php new file mode 100644 index 0000000000000..23d11686e8292 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php @@ -0,0 +1,123 @@ +categoryUrlPathGenerator = $categoryUrlPathGenerator; + $this->categoryUrlRewriteGenerator = $categoryUrlRewriteGenerator; + $this->urlPersist = $urlPersist; + $this->storeViewService = $storeViewService; + } + + /** + * Perform url updating for different stores. + * + * @param CategoryResource $subject + * @param CategoryResource $result + * @param AbstractModel $category + * @return CategoryResource + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterSave( + CategoryResource $subject, + CategoryResource $result, + AbstractModel $category + ): CategoryResource { + $parentCategoryId = $category->getParentId(); + if ($category->isObjectNew() + && !$category->isInRootCategoryList() + && !empty($parentCategoryId) + ) { + foreach ($category->getStoreIds() as $storeId) { + if (!$this->isGlobalScope((int)$storeId) + && $this->storeViewService->doesEntityHaveOverriddenUrlPathForStore( + $storeId, + $parentCategoryId, + Category::ENTITY + ) + ) { + $category->setStoreId($storeId); + $this->updateUrlPathForCategory($category, $subject); + $this->urlPersist->replace($this->categoryUrlRewriteGenerator->generate($category)); + } + } + } + + return $result; + } + + /** + * Check that store id is in global scope. + * + * @param int $storeId + * @return bool + */ + private function isGlobalScope(int $storeId): bool + { + return $storeId === Store::DEFAULT_STORE_ID; + } + + /** + * Updates category url path. + * + * @param Category $category + * @param CategoryResource $categoryResource + * @return void + */ + private function updateUrlPathForCategory(Category $category, CategoryResource $categoryResource): void + { + $category->unsUrlPath(); + $category->setUrlPath($this->categoryUrlPathGenerator->getUrlPath($category)); + $categoryResource->saveAttribute($category, 'url_path'); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php b/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php index 9c5c37b51f0b2..6b838f83d31e4 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php @@ -208,7 +208,7 @@ public function generateForSpecificStoreView($storeId, $productCategories, Produ public function isCategoryProperForGenerating(Category $category, $storeId) { $parentIds = $category->getParentIds(); - if (count($parentIds) >= 2) { + if (is_array($parentIds) && count($parentIds) >= 2) { $rootCategoryId = $parentIds[1]; return $rootCategoryId == $this->storeManager->getStore($storeId)->getRootCategoryId(); } diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php index e2e71ec494ece..5d7e323e8b2d8 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\CatalogUrlRewrite\Observer; use Magento\Catalog\Model\Category; @@ -12,6 +13,9 @@ use Magento\CatalogUrlRewrite\Model\Map\DataProductUrlRewriteDatabaseMap; use Magento\CatalogUrlRewrite\Model\UrlRewriteBunchReplacer; use Magento\Framework\Event\ObserverInterface; +use Magento\Store\Model\ResourceModel\Group\CollectionFactory; +use Magento\Store\Model\ResourceModel\Group\Collection as StoreGroupCollection; +use Magento\Framework\App\ObjectManager; /** * Generates Category Url Rewrites after save and Products Url Rewrites assigned to the category that's being saved @@ -43,12 +47,18 @@ class CategoryProcessUrlRewriteSavingObserver implements ObserverInterface */ private $dataUrlRewriteClassNames; + /** + * @var CollectionFactory + */ + private $storeGroupFactory; + /** * @param CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator * @param UrlRewriteHandler $urlRewriteHandler * @param UrlRewriteBunchReplacer $urlRewriteBunchReplacer * @param DatabaseMapPool $databaseMapPool * @param string[] $dataUrlRewriteClassNames + * @param CollectionFactory|null $storeGroupFactory */ public function __construct( CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator, @@ -56,15 +66,18 @@ public function __construct( UrlRewriteBunchReplacer $urlRewriteBunchReplacer, DatabaseMapPool $databaseMapPool, $dataUrlRewriteClassNames = [ - DataCategoryUrlRewriteDatabaseMap::class, - DataProductUrlRewriteDatabaseMap::class - ] + DataCategoryUrlRewriteDatabaseMap::class, + DataProductUrlRewriteDatabaseMap::class + ], + CollectionFactory $storeGroupFactory = null ) { $this->categoryUrlRewriteGenerator = $categoryUrlRewriteGenerator; $this->urlRewriteHandler = $urlRewriteHandler; $this->urlRewriteBunchReplacer = $urlRewriteBunchReplacer; $this->databaseMapPool = $databaseMapPool; $this->dataUrlRewriteClassNames = $dataUrlRewriteClassNames; + $this->storeGroupFactory = $storeGroupFactory + ?: ObjectManager::getInstance()->get(CollectionFactory::class); } /** @@ -82,6 +95,10 @@ public function execute(\Magento\Framework\Event\Observer $observer) return; } + if (!$category->hasData('store_id')) { + $this->setCategoryStoreId($category); + } + $mapsGenerated = false; if ($category->dataHasChangedFor('url_key') || $category->dataHasChangedFor('is_anchor') @@ -102,6 +119,29 @@ public function execute(\Magento\Framework\Event\Observer $observer) } } + /** + * In case store_id is not set for category then we can assume that it was passed through product import. + * Store group must have only one root category, so receiving category's path and checking if one of it parts + * is the root category for store group, we can set default_store_id value from it to category. + * it prevents urls duplication for different stores + * ("Default Category/category/sub" and "Default Category2/category/sub") + * + * @param Category $category + * @return void + */ + private function setCategoryStoreId($category) + { + /** @var StoreGroupCollection $storeGroupCollection */ + $storeGroupCollection = $this->storeGroupFactory->create(); + + foreach ($storeGroupCollection as $storeGroup) { + /** @var \Magento\Store\Model\Group $storeGroup */ + if (in_array($storeGroup->getRootCategoryId(), explode('/', $category->getPath()))) { + $category->setStoreId($storeGroup->getDefaultStoreId()); + } + } + } + /** * Resets used data maps to free up memory and temporary tables * diff --git a/app/code/Magento/CatalogUrlRewrite/Plugin/Store/Block/Switcher.php b/app/code/Magento/CatalogUrlRewrite/Plugin/Store/Block/Switcher.php new file mode 100644 index 0000000000000..44213c007551c --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Plugin/Store/Block/Switcher.php @@ -0,0 +1,86 @@ +postHelper = $postHelper; + $this->urlFinder = $urlFinder; + $this->request = $request; + } + + /** + * @param \Magento\Store\Block\Switcher $subject + * @param string $result + * @param Store $store + * @param array $data + * @return string + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetTargetStorePostData( + \Magento\Store\Block\Switcher $subject, + string $result, + Store $store, + array $data = [] + ): string { + $data[StoreResolverInterface::PARAM_NAME] = $store->getCode(); + $currentUrl = $store->getCurrentUrl(true); + $baseUrl = $store->getBaseUrl(); + $urlPath = parse_url($currentUrl, PHP_URL_PATH); + $urlToSwitch = $currentUrl; + + //check only catalog pages + if ($this->request->getFrontName() === 'catalog') { + $currentRewrite = $this->urlFinder->findOneByData([ + UrlRewrite::REQUEST_PATH => ltrim($urlPath, '/'), + UrlRewrite::STORE_ID => $store->getId(), + ]); + if (null === $currentRewrite) { + $urlToSwitch = $baseUrl; + } + } + + return $this->postHelper->getPostData($urlToSwitch, $data); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php new file mode 100644 index 0000000000000..1d1f9c7fad91b --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php @@ -0,0 +1,172 @@ +objectManager = new ObjectManager($this); + $this->categoryUrlPathGenerator = $this->getMockBuilder(CategoryUrlPathGenerator::class) + ->disableOriginalConstructor() + ->setMethods(['getUrlPath']) + ->getMock(); + $this->categoryUrlRewriteGenerator = $this->getMockBuilder(CategoryUrlRewriteGenerator::class) + ->disableOriginalConstructor() + ->setMethods(['generate']) + ->getMock(); + $this->categoryResource = $this->getMockBuilder(CategoryResource::class) + ->disableOriginalConstructor() + ->setMethods(['saveAttribute']) + ->getMock(); + $this->category = $this->getMockBuilder(Category::class) + ->disableOriginalConstructor() + ->setMethods( + [ + 'getStoreId', + 'getParentId', + 'isObjectNew', + 'isInRootCategoryList', + 'getStoreIds', + 'setStoreId', + 'unsUrlPath', + 'setUrlPath', + ] + ) + ->getMock(); + $this->storeViewService = $this->getMockBuilder(StoreViewService::class) + ->disableOriginalConstructor() + ->setMethods(['doesEntityHaveOverriddenUrlPathForStore']) + ->getMock(); + $this->urlPersist = $this->getMockBuilder(UrlPersistInterface::class) + ->disableOriginalConstructor() + ->setMethods(['replace']) + ->getMockForAbstractClass(); + + $this->updateUrlPathPlugin = $this->objectManager->getObject( + \Magento\CatalogUrlRewrite\Model\Category\Plugin\Category\UpdateUrlPath::class, + [ + 'categoryUrlPathGenerator' => $this->categoryUrlPathGenerator, + 'categoryUrlRewriteGenerator' => $this->categoryUrlRewriteGenerator, + 'urlPersist' => $this->urlPersist, + 'storeViewService' => $this->storeViewService, + ] + ); + } + + public function testAroundSaveWithoutRootCategory() + { + $this->category->expects($this->atLeastOnce())->method('getParentId')->willReturn(0); + $this->category->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->category->expects($this->atLeastOnce())->method('isInRootCategoryList')->willReturn(false); + $this->category->expects($this->never())->method('getStoreIds'); + + $this->assertEquals( + $this->categoryResource, + $this->updateUrlPathPlugin->afterSave($this->categoryResource, $this->categoryResource, $this->category) + ); + } + + public function testAroundSaveWithRootCategory() + { + $parentId = 1; + $categoryStoreIds = [0,1,2]; + $generatedUrlPath = 'parent_category/child_category'; + + $this->categoryUrlPathGenerator + ->expects($this->once()) + ->method('getUrlPath') + ->with($this->category) + ->willReturn($generatedUrlPath); + $this->category->expects($this->atLeastOnce())->method('getParentId')->willReturn($parentId); + $this->category->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->category->expects($this->atLeastOnce())->method('isInRootCategoryList')->willReturn(false); + $this->category->expects($this->atLeastOnce())->method('getStoreIds')->willReturn($categoryStoreIds); + $this->category->expects($this->once())->method('setStoreId')->with($categoryStoreIds[2])->willReturnSelf(); + $this->category->expects($this->once())->method('unsUrlPath')->willReturnSelf(); + $this->category->expects($this->once())->method('setUrlPath')->with($generatedUrlPath)->willReturnSelf(); + $this->storeViewService->expects($this->exactly(2))->method('doesEntityHaveOverriddenUrlPathForStore') + ->willReturnMap( + [ + [$categoryStoreIds[1], $parentId, 'catalog_category', false], + [$categoryStoreIds[2], $parentId, 'catalog_category', true], + ] + ); + $this->categoryResource + ->expects($this->once()) + ->method('saveAttribute') + ->with($this->category, 'url_path') + ->willReturnSelf(); + $generatedUrlRewrite = $this->getMockBuilder(\Magento\UrlRewrite\Service\V1\Data\UrlRewrite::class) + ->disableOriginalConstructor() + ->getMock(); + $this->categoryUrlRewriteGenerator->expects($this->once())->method('generate')->with($this->category) + ->willReturn([$generatedUrlRewrite]); + $this->urlPersist->expects($this->once())->method('replace')->with([$generatedUrlRewrite])->willReturnSelf(); + + $this->assertEquals( + $this->categoryResource, + $this->updateUrlPathPlugin->afterSave($this->categoryResource, $this->categoryResource, $this->category) + ); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php new file mode 100644 index 0000000000000..afdb548887577 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php @@ -0,0 +1,229 @@ +observer = $this->createPartialMock( + \Magento\Framework\Event\Observer::class, + ['getEvent', 'getData'] + ); + $this->category = $this->createPartialMock(Category::class, [ + 'hasData', + 'getParentId', + 'dataHasChangedFor', + 'getChangedProductIds', + ]); + $this->observer->expects($this->any()) + ->method('getEvent') + ->willReturnSelf(); + $this->observer->expects($this->any()) + ->method('getData') + ->with('category') + ->willReturn($this->category); + + $this->categoryUrlRewriteGeneratorMock = $this->getMockBuilder(CategoryUrlRewriteGenerator::class) + ->disableOriginalConstructor() + ->getMock(); + $this->urlRewriteBunchReplacerMock = $this->getMockBuilder(UrlRewriteBunchReplacer::class) + ->disableOriginalConstructor() + ->getMock(); + $this->urlRewriteHandlerMock = $this->getMockBuilder(UrlRewriteHandler::class) + ->disableOriginalConstructor() + ->getMock(); + $this->databaseMapPoolMock = $this->getMockBuilder(DatabaseMapPool::class) + ->disableOriginalConstructor() + ->getMock(); + $this->storeGroupFactory = $this->getMockBuilder(CollectionFactory::class) + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); + + $this->categoryProcessUrlRewriteSavingObserver = (new ObjectManagerHelper($this))->getObject( + CategoryProcessUrlRewriteSavingObserver::class, + [ + 'categoryUrlRewriteGenerator' => $this->categoryUrlRewriteGeneratorMock, + 'urlRewriteHandler' => $this->urlRewriteHandlerMock, + 'urlRewriteBunchReplacer' => $this->urlRewriteBunchReplacerMock, + 'databaseMapPool' => $this->databaseMapPoolMock, + 'storeGroupFactory' => $this->storeGroupFactory, + ] + ); + } + + public function testExecuteForRootDirectory() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(Category::TREE_ROOT_ID); + $this->category->expects($this->never()) + ->method('hasData'); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } + + public function testExecuteHasStoreId() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(2); + $this->category->expects($this->once()) + ->method('hasData') + ->with('store_id') + ->willReturn(true); + $this->storeGroupFactory->expects($this->never()) + ->method('create'); + $this->category->expects($this->any()) + ->method('dataHasChangedFor') + ->willReturnMap( + [ + ['url_key', false], + ['is_anchor', false], + ] + ); + $this->category->expects($this->once()) + ->method('getChangedProductIds') + ->willReturn([]); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } + + public function testExecuteHasNotChanges() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(2); + $this->category->expects($this->once()) + ->method('hasData') + ->willReturn(false); + $this->storeGroupFactory->expects($this->once()) + ->method('create') + ->willReturn([]); + $this->category->expects($this->any()) + ->method('dataHasChangedFor') + ->willReturnMap( + [ + ['url_key', false], + ['is_anchor', false], + ] + ); + $this->category->expects($this->once()) + ->method('getChangedProductIds') + ->willReturn([]); + $this->databaseMapPoolMock->expects($this->never()) + ->method('resetMap'); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } + + public function testExecuteHasChanges() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(2); + $this->category->expects($this->once()) + ->method('hasData') + ->willReturn(false); + $this->storeGroupFactory->expects($this->once()) + ->method('create') + ->willReturn([]); + $this->category->expects($this->any()) + ->method('dataHasChangedFor') + ->willReturnMap( + [ + ['url_key', true], + ['is_anchor', false], + ] + ); + $this->category->expects($this->any()) + ->method('getChangedProductIds') + ->willReturn([]); + + $result1 = ['test']; + $this->categoryUrlRewriteGeneratorMock->expects($this->once()) + ->method('generate') + ->with($this->category) + ->willReturn($result1); + $this->urlRewriteBunchReplacerMock->expects($this->at(0)) + ->method('doBunchReplace') + ->with($result1) + ->willReturn(null); + + $result2 = ['test2']; + $this->urlRewriteHandlerMock->expects($this->once()) + ->method('generateProductUrlRewrites') + ->with($this->category) + ->willReturn($result2); + $this->urlRewriteBunchReplacerMock->expects($this->at(1)) + ->method('doBunchReplace') + ->with($result2) + ->willReturn(null); + + $this->databaseMapPoolMock->expects($this->any()) + ->method('resetMap'); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/composer.json b/app/code/Magento/CatalogUrlRewrite/composer.json index 5f84c7922dd1b..e373d8c8c1756 100644 --- a/app/code/Magento/CatalogUrlRewrite/composer.json +++ b/app/code/Magento/CatalogUrlRewrite/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/CatalogUrlRewrite/etc/di.xml b/app/code/Magento/CatalogUrlRewrite/etc/di.xml index 2d421417bfdc0..f6426677e8ce8 100644 --- a/app/code/Magento/CatalogUrlRewrite/etc/di.xml +++ b/app/code/Magento/CatalogUrlRewrite/etc/di.xml @@ -19,6 +19,7 @@ + diff --git a/app/code/Magento/CatalogUrlRewrite/etc/frontend/di.xml b/app/code/Magento/CatalogUrlRewrite/etc/frontend/di.xml new file mode 100644 index 0000000000000..3a9122b2f748d --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/etc/frontend/di.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json index 05cdc3dda455d..ab91f745c5d0c 100644 --- a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json @@ -3,7 +3,7 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*" }, "suggest": { diff --git a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php index 373b88049c7b5..9a55f981b7607 100644 --- a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php +++ b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php @@ -160,14 +160,16 @@ public function getCacheKeyInfo() return [ 'CATALOG_PRODUCTS_LIST_WIDGET', - $this->getPriceCurrency()->getCurrencySymbol(), + $this->getPriceCurrency()->getCurrency()->getCode(), $this->_storeManager->getStore()->getId(), $this->_design->getDesignTheme()->getId(), $this->httpContext->getValue(\Magento\Customer\Model\Context::CONTEXT_GROUP), intval($this->getRequest()->getParam($this->getData('page_var_name'), 1)), $this->getProductsPerPage(), $conditions, - $this->json->serialize($this->getRequest()->getParams()) + $this->json->serialize($this->getRequest()->getParams()), + $this->getTemplate(), + $this->getTitle() ]; } diff --git a/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php b/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php index e871ed4359d5c..3039066ad1388 100644 --- a/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php +++ b/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php @@ -87,8 +87,8 @@ protected function setUp() { $this->collectionFactory = $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Product\CollectionFactory::class) - ->setMethods(['create']) - ->disableOriginalConstructor()->getMock(); + ->setMethods(['create']) + ->disableOriginalConstructor()->getMock(); $this->visibility = $this->getMockBuilder(\Magento\Catalog\Model\Product\Visibility::class) ->setMethods(['getVisibleInCatalogIds']) ->disableOriginalConstructor() @@ -144,10 +144,14 @@ public function testGetCacheKeyInfo() $this->productsList->setData('conditions', 'some_serialized_conditions'); $this->productsList->setData('page_var_name', 'page_number'); + $this->productsList->setTemplate('test_template'); + $this->productsList->setData('title', 'test_title'); $this->request->expects($this->once())->method('getParam')->with('page_number')->willReturn(1); $this->request->expects($this->once())->method('getParams')->willReturn('request_params'); - $this->priceCurrency->expects($this->once())->method('getCurrencySymbol')->willReturn('$'); + $currency = $this->createMock(\Magento\Directory\Model\Currency::class); + $currency->expects($this->once())->method('getCode')->willReturn('USD'); + $this->priceCurrency->expects($this->once())->method('getCurrency')->willReturn($currency); $this->serializer->expects($this->any()) ->method('serialize') @@ -157,14 +161,16 @@ public function testGetCacheKeyInfo() $cacheKey = [ 'CATALOG_PRODUCTS_LIST_WIDGET', - '$', + 'USD', 1, 'blank', 'context_group', 1, 5, 'some_serialized_conditions', - json_encode('request_params') + json_encode('request_params'), + 'test_template', + 'test_title' ]; $this->assertEquals($cacheKey, $this->productsList->getCacheKeyInfo()); } @@ -249,9 +255,10 @@ public function testGetPagerHtml() * Test public `createCollection` method and protected `getPageSize` method via `createCollection` * * @param bool $pagerEnable - * @param int $productsCount - * @param int $productsPerPage - * @param int $expectedPageSize + * @param int $productsCount + * @param int $productsPerPage + * @param int $expectedPageSize + * * @dataProvider createCollectionDataProvider */ public function testCreateCollection($pagerEnable, $productsCount, $productsPerPage, $expectedPageSize) @@ -380,6 +387,7 @@ public function testGetIdentities() /** * @param $collection + * * @return \PHPUnit_Framework_MockObject_MockObject */ private function getConditionsForCollection($collection) diff --git a/app/code/Magento/CatalogWidget/composer.json b/app/code/Magento/CatalogWidget/composer.json index 5befbfc67096e..3998e58c99baa 100644 --- a/app/code/Magento/CatalogWidget/composer.json +++ b/app/code/Magento/CatalogWidget/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/CatalogWidget/etc/widget.xml b/app/code/Magento/CatalogWidget/etc/widget.xml index 3d54c314c6622..bcc1b623da02e 100644 --- a/app/code/Magento/CatalogWidget/etc/widget.xml +++ b/app/code/Magento/CatalogWidget/etc/widget.xml @@ -40,7 +40,11 @@ - 86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache. + + If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
    Widget will not show products that begin to match the specified conditions until cache is refreshed.]]> +
    diff --git a/app/code/Magento/CatalogWidget/i18n/en_US.csv b/app/code/Magento/CatalogWidget/i18n/en_US.csv index 9ecde5cb1a062..4cccbdd926282 100644 --- a/app/code/Magento/CatalogWidget/i18n/en_US.csv +++ b/app/code/Magento/CatalogWidget/i18n/en_US.csv @@ -16,5 +16,11 @@ Title,Title Template,Template "Products Grid Template","Products Grid Template" "Cache Lifetime (Seconds)","Cache Lifetime (Seconds)" -"86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache.","86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache." +"Time in seconds between the widget updates. +
    If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
    Widget will not show products that begin to match the specified conditions until cache is refreshed." +, +"Time in seconds between the widget updates. +
    If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
    Widget will not show products that begin to match the specified conditions until cache is refreshed." Conditions,Conditions diff --git a/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php b/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php index 45e885d0dbd46..cad1c100c7e5b 100644 --- a/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php +++ b/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php @@ -9,7 +9,7 @@ * Interface PaymentDetailsInterface * @api */ -interface PaymentDetailsInterface +interface PaymentDetailsInterface extends \Magento\Framework\Api\ExtensibleDataInterface { /**#@+ * Constants defined for keys of array, makes typos less likely diff --git a/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php b/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php index 57ca4b7b2e606..06be39d0f3516 100644 --- a/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php +++ b/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php @@ -611,9 +611,6 @@ public function getActions(AbstractItem $item) */ public function getImage($product, $imageId, $attributes = []) { - return $this->imageBuilder->setProduct($product) - ->setImageId($imageId) - ->setAttributes($attributes) - ->create(); + return $this->imageBuilder->create($product, $imageId, $attributes); } } diff --git a/app/code/Magento/Checkout/Block/Cart/Sidebar.php b/app/code/Magento/Checkout/Block/Cart/Sidebar.php index 5c237eecf0a9f..92ba6bf2bbbb1 100644 --- a/app/code/Magento/Checkout/Block/Cart/Sidebar.php +++ b/app/code/Magento/Checkout/Block/Cart/Sidebar.php @@ -100,9 +100,7 @@ public function getSerializedConfig() */ public function getImageHtmlTemplate() { - return $this->imageHelper->getFrame() - ? 'Magento_Catalog/product/image' - : 'Magento_Catalog/product/image_with_borders'; + return 'Magento_Catalog/product/image_with_borders'; } /** diff --git a/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php b/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php index d93475a4744ca..de996bed02439 100644 --- a/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php +++ b/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php @@ -394,9 +394,9 @@ protected function orderCountryOptions(array $countryOptions) ]]; foreach ($countryOptions as $countryOption) { if (empty($countryOption['value']) || in_array($countryOption['value'], $this->topCountryCodes)) { - array_push($headOptions, $countryOption); + $headOptions[] = $countryOption; } else { - array_push($tailOptions, $countryOption); + $tailOptions[] = $countryOption; } } return array_merge($headOptions, $tailOptions); diff --git a/app/code/Magento/Checkout/Block/Checkout/DirectoryDataProcessor.php b/app/code/Magento/Checkout/Block/Checkout/DirectoryDataProcessor.php index 1d5bb5bb07d81..587dd06d89106 100644 --- a/app/code/Magento/Checkout/Block/Checkout/DirectoryDataProcessor.php +++ b/app/code/Magento/Checkout/Block/Checkout/DirectoryDataProcessor.php @@ -141,9 +141,9 @@ private function orderCountryOptions(array $countryOptions) ]]; foreach ($countryOptions as $countryOption) { if (empty($countryOption['value']) || in_array($countryOption['value'], $topCountryCodes)) { - array_push($headOptions, $countryOption); + $headOptions[] = $countryOption; } else { - array_push($tailOptions, $countryOption); + $tailOptions[] = $countryOption; } } return array_merge($headOptions, $tailOptions); diff --git a/app/code/Magento/Checkout/Block/Registration.php b/app/code/Magento/Checkout/Block/Registration.php index 91ec85c1db0ed..e880230f50a74 100644 --- a/app/code/Magento/Checkout/Block/Registration.php +++ b/app/code/Magento/Checkout/Block/Registration.php @@ -91,7 +91,7 @@ public function getEmailAddress() */ public function getCreateAccountUrl() { - return $this->getUrl('checkout/account/create'); + return $this->getUrl('checkout/account/delegateCreate'); } /** diff --git a/app/code/Magento/Checkout/Controller/Account/Create.php b/app/code/Magento/Checkout/Controller/Account/Create.php index 2ee5d6d5528c3..dae0bb98be453 100644 --- a/app/code/Magento/Checkout/Controller/Account/Create.php +++ b/app/code/Magento/Checkout/Controller/Account/Create.php @@ -9,6 +9,10 @@ use Magento\Framework\Exception\AlreadyExistsException; use Magento\Framework\Exception\NoSuchEntityException; +/** + * @deprecated + * @see DelegateCreate + */ class Create extends \Magento\Framework\App\Action\Action { /** diff --git a/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php b/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php new file mode 100644 index 0000000000000..6c4c8b053e2ae --- /dev/null +++ b/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php @@ -0,0 +1,58 @@ +delegateService = $customerDelegation; + $this->session = $session; + } + + /** + * {@inheritdoc} + */ + public function execute() + { + /** @var string|null $orderId */ + $orderId = $this->session->getLastOrderId(); + if (!$orderId) { + return $this->resultRedirectFactory->create()->setPath('/'); + } + + return $this->delegateService->delegateNew((int)$orderId); + } +} diff --git a/app/code/Magento/Checkout/Controller/Index/Index.php b/app/code/Magento/Checkout/Controller/Index/Index.php index 56575fb6c0607..785c1f1473be6 100644 --- a/app/code/Magento/Checkout/Controller/Index/Index.php +++ b/app/code/Magento/Checkout/Controller/Index/Index.php @@ -4,6 +4,9 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Checkout\Controller\Index; class Index extends \Magento\Checkout\Controller\Onepage @@ -32,11 +35,35 @@ public function execute() return $this->resultRedirectFactory->create()->setPath('checkout/cart'); } - $this->_customerSession->regenerateId(); + // generate session ID only if connection is unsecure according to issues in session_regenerate_id function. + // @see http://php.net/manual/en/function.session-regenerate-id.php + if (!$this->isSecureRequest()) { + $this->_customerSession->regenerateId(); + } $this->_objectManager->get(\Magento\Checkout\Model\Session::class)->setCartWasUpdated(false); $this->getOnepage()->initCheckout(); $resultPage = $this->resultPageFactory->create(); $resultPage->getConfig()->getTitle()->set(__('Checkout')); return $resultPage; } + + /** + * Checks if current request uses SSL and referer also is secure. + * + * @return bool + */ + private function isSecureRequest(): bool + { + $request = $this->getRequest(); + + $referrer = $request->getHeader('referer'); + $secure = false; + + if ($referrer) { + $scheme = parse_url($referrer, PHP_URL_SCHEME); + $secure = $scheme === 'https'; + } + + return $secure && $request->isSecure(); + } } diff --git a/app/code/Magento/Checkout/Helper/Data.php b/app/code/Magento/Checkout/Helper/Data.php index b3c2e17e5d678..636d4aaca21f0 100644 --- a/app/code/Magento/Checkout/Helper/Data.php +++ b/app/code/Magento/Checkout/Helper/Data.php @@ -9,6 +9,7 @@ use Magento\Quote\Model\Quote\Item\AbstractItem; use Magento\Store\Model\Store; use Magento\Store\Model\ScopeInterface; +use Magento\Sales\Api\PaymentFailuresInterface; /** * Checkout default helper @@ -52,6 +53,11 @@ class Data extends \Magento\Framework\App\Helper\AbstractHelper */ protected $priceCurrency; + /** + * @var PaymentFailuresInterface + */ + private $paymentFailures; + /** * @param \Magento\Framework\App\Helper\Context $context * @param \Magento\Store\Model\StoreManagerInterface $storeManager @@ -60,6 +66,7 @@ class Data extends \Magento\Framework\App\Helper\AbstractHelper * @param \Magento\Framework\Mail\Template\TransportBuilder $transportBuilder * @param \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation * @param PriceCurrencyInterface $priceCurrency + * @param PaymentFailuresInterface|null $paymentFailures * @codeCoverageIgnore */ public function __construct( @@ -69,7 +76,8 @@ public function __construct( \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, \Magento\Framework\Mail\Template\TransportBuilder $transportBuilder, \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation, - PriceCurrencyInterface $priceCurrency + PriceCurrencyInterface $priceCurrency, + PaymentFailuresInterface $paymentFailures = null ) { $this->_storeManager = $storeManager; $this->_checkoutSession = $checkoutSession; @@ -77,6 +85,8 @@ public function __construct( $this->_transportBuilder = $transportBuilder; $this->inlineTranslation = $inlineTranslation; $this->priceCurrency = $priceCurrency; + $this->paymentFailures = $paymentFailures ? : \Magento\Framework\App\ObjectManager::getInstance() + ->get(PaymentFailuresInterface::class); parent::__construct($context); } @@ -202,126 +212,13 @@ public function getBaseSubtotalInclTax($item) * @param string $message * @param string $checkoutType * @return $this - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function sendPaymentFailedEmail($checkout, $message, $checkoutType = 'onepage') - { - $this->inlineTranslation->suspend(); - - $template = $this->scopeConfig->getValue( - 'checkout/payment_failed/template', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ); - - $copyTo = $this->_getEmails('checkout/payment_failed/copy_to', $checkout->getStoreId()); - $copyMethod = $this->scopeConfig->getValue( - 'checkout/payment_failed/copy_method', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ); - $bcc = []; - if ($copyTo && $copyMethod == 'bcc') { - $bcc = $copyTo; - } - - $_receiver = $this->scopeConfig->getValue( - 'checkout/payment_failed/receiver', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ); - $sendTo = [ - [ - 'email' => $this->scopeConfig->getValue( - 'trans_email/ident_' . $_receiver . '/email', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ), - 'name' => $this->scopeConfig->getValue( - 'trans_email/ident_' . $_receiver . '/name', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ), - ], - ]; - - if ($copyTo && $copyMethod == 'copy') { - foreach ($copyTo as $email) { - $sendTo[] = ['email' => $email, 'name' => null]; - } - } - $shippingMethod = ''; - if ($shippingInfo = $checkout->getShippingAddress()->getShippingMethod()) { - $data = explode('_', $shippingInfo); - $shippingMethod = $data[0]; - } - - $paymentMethod = ''; - if ($paymentInfo = $checkout->getPayment()) { - $paymentMethod = $paymentInfo->getMethod(); - } - - $items = ''; - foreach ($checkout->getAllVisibleItems() as $_item) { - /* @var $_item \Magento\Quote\Model\Quote\Item */ - $items .= - $_item->getProduct()->getName() . ' x ' . $_item->getQty() . ' ' . $checkout->getStoreCurrencyCode() - . ' ' . $_item->getProduct()->getFinalPrice( - $_item->getQty() - ) . "\n"; - } - $total = $checkout->getStoreCurrencyCode() . ' ' . $checkout->getGrandTotal(); - - foreach ($sendTo as $recipient) { - $transport = $this->_transportBuilder->setTemplateIdentifier( - $template - )->setTemplateOptions( - [ - 'area' => \Magento\Backend\App\Area\FrontNameResolver::AREA_CODE, - 'store' => Store::DEFAULT_STORE_ID - ] - )->setTemplateVars( - [ - 'reason' => $message, - 'checkoutType' => $checkoutType, - 'dateAndTime' => $this->_localeDate->formatDateTime( - new \DateTime(), - \IntlDateFormatter::MEDIUM, - \IntlDateFormatter::MEDIUM - ), - 'customer' => $checkout->getCustomerFirstname() . ' ' . $checkout->getCustomerLastname(), - 'customerEmail' => $checkout->getCustomerEmail(), - 'billingAddress' => $checkout->getBillingAddress(), - 'shippingAddress' => $checkout->getShippingAddress(), - 'shippingMethod' => $this->scopeConfig->getValue( - 'carriers/' . $shippingMethod . '/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ), - 'paymentMethod' => $this->scopeConfig->getValue( - 'payment/' . $paymentMethod . '/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ), - 'items' => nl2br($items), - 'total' => $total, - ] - )->setFrom( - $this->scopeConfig->getValue( - 'checkout/payment_failed/identity', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ) - )->addTo( - $recipient['email'], - $recipient['name'] - )->addBcc( - $bcc - )->getTransport(); - - $transport->sendMessage(); - } - - $this->inlineTranslation->resume(); + public function sendPaymentFailedEmail( + \Magento\Quote\Model\Quote $checkout, + string $message, + string $checkoutType = 'onepage' + ): \Magento\Checkout\Helper\Data { + $this->paymentFailures->handle((int)$checkout->getId(), $message, $checkoutType); return $this; } diff --git a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php index e18940626a338..333226b7d216f 100644 --- a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php +++ b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php @@ -3,11 +3,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Checkout\Model; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\ResourceConnection; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Quote\Model\Quote; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -50,6 +54,11 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa */ private $logger; + /** + * @var ResourceConnection + */ + private $connectionPool; + /** * @param \Magento\Quote\Api\GuestBillingAddressManagementInterface $billingAddressManagement * @param \Magento\Quote\Api\GuestPaymentMethodManagementInterface $paymentMethodManagement @@ -57,6 +66,7 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa * @param \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement * @param \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory * @param CartRepositoryInterface $cartRepository + * @param ResourceConnection|null * @codeCoverageIgnore */ public function __construct( @@ -65,7 +75,8 @@ public function __construct( \Magento\Quote\Api\GuestCartManagementInterface $cartManagement, \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement, \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory, - CartRepositoryInterface $cartRepository + CartRepositoryInterface $cartRepository, + ResourceConnection $connectionPool = null ) { $this->billingAddressManagement = $billingAddressManagement; $this->paymentMethodManagement = $paymentMethodManagement; @@ -73,6 +84,7 @@ public function __construct( $this->paymentInformationManagement = $paymentInformationManagement; $this->quoteIdMaskFactory = $quoteIdMaskFactory; $this->cartRepository = $cartRepository; + $this->connectionPool = $connectionPool ?: ObjectManager::getInstance()->get(ResourceConnection::class); } /** @@ -84,21 +96,35 @@ public function savePaymentInformationAndPlaceOrder( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { - $this->savePaymentInformation($cartId, $email, $paymentMethod, $billingAddress); + $salesConnection = $this->connectionPool->getConnection('sales'); + $checkoutConnection = $this->connectionPool->getConnection('checkout'); + $salesConnection->beginTransaction(); + $checkoutConnection->beginTransaction(); + try { - $orderId = $this->cartManagement->placeOrder($cartId); - } catch (\Magento\Framework\Exception\LocalizedException $e) { - throw new CouldNotSaveException( - __($e->getMessage()), - $e - ); + $this->savePaymentInformation($cartId, $email, $paymentMethod, $billingAddress); + try { + $orderId = $this->cartManagement->placeOrder($cartId); + } catch (\Magento\Framework\Exception\LocalizedException $e) { + throw new CouldNotSaveException( + __($e->getMessage()), + $e + ); + } catch (\Exception $e) { + $this->getLogger()->critical($e); + throw new CouldNotSaveException( + __('An error occurred on the server. Please try to place the order again.'), + $e + ); + } + $salesConnection->commit(); + $checkoutConnection->commit(); } catch (\Exception $e) { - $this->getLogger()->critical($e); - throw new CouldNotSaveException( - __('A server error stopped your order from being placed. Please try to place your order again.'), - $e - ); + $salesConnection->rollBack(); + $checkoutConnection->rollBack(); + throw $e; } + return $orderId; } @@ -111,13 +137,19 @@ public function savePaymentInformation( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { + $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); + /** @var Quote $quote */ + $quote = $this->cartRepository->getActive($quoteIdMask->getQuoteId()); + if ($billingAddress) { $billingAddress->setEmail($email); - $this->billingAddressManagement->assign($cartId, $billingAddress); + $quote->removeAddress($quote->getBillingAddress()->getId()); + $quote->setBillingAddress($billingAddress); + $quote->setDataChanges(true); } else { - $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); - $this->cartRepository->getActive($quoteIdMask->getQuoteId())->getBillingAddress()->setEmail($email); + $quote->getBillingAddress()->setEmail($email); } + $this->limitShippingCarrier($quote); $this->paymentMethodManagement->set($cartId, $paymentMethod); return true; @@ -145,4 +177,22 @@ private function getLogger() } return $this->logger; } + + /** + * Limits shipping rates request by carrier from shipping address. + * + * @param Quote $quote + * + * @return void + * @see \Magento\Shipping\Model\Shipping::collectRates + */ + private function limitShippingCarrier(Quote $quote) : void + { + $shippingAddress = $quote->getShippingAddress(); + if ($shippingAddress && $shippingAddress->getShippingMethod()) { + $shippingDataArray = explode('_', $shippingAddress->getShippingMethod()); + $shippingCarrier = array_shift($shippingDataArray); + $shippingAddress->setLimitCarrier($shippingCarrier); + } + } } diff --git a/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php b/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php index d963fa2d76e6b..9c9c5fd33bd07 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php @@ -5,6 +5,8 @@ */ namespace Magento\Checkout\Test\Unit\Block\Cart\Item; +use Magento\Catalog\Block\Product\Image; +use Magento\Catalog\Model\Product; use Magento\Checkout\Block\Cart\Item\Renderer; use Magento\Quote\Model\Quote\Item; @@ -64,13 +66,13 @@ public function testGetProductForThumbnail() /** * Initialize product. * - * @return \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject + * @return Product|\PHPUnit_Framework_MockObject_MockObject */ protected function _initProduct() { - /** @var \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject $product */ + /** @var Product|\PHPUnit_Framework_MockObject_MockObject $product */ $product = $this->createPartialMock( - \Magento\Catalog\Model\Product::class, + Product::class, ['getName', '__wakeup', 'getIdentities'] ); $product->expects($this->any())->method('getName')->will($this->returnValue('Parent Product')); @@ -106,7 +108,7 @@ public function testGetIdentitiesFromEmptyItem() public function testGetProductPriceHtml() { $priceHtml = 'some price html'; - $product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) + $product = $this->getMockBuilder(Product::class) ->disableOriginalConstructor() ->getMock(); @@ -193,34 +195,17 @@ public function testGetImage() { $imageId = 'test_image_id'; $attributes = []; + $product = $this->createMock(Product::class); + $imageMock = $this->createMock(Image::class); - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $imageMock = $this->getMockBuilder(\Magento\Catalog\Block\Product\Image::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageBuilder->expects($this->once()) - ->method('setProduct') - ->with($productMock) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setImageId') - ->with($imageId) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setAttributes') - ->with($attributes) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) + $this->imageBuilder->expects(self::once()) ->method('create') + ->with($product, $imageId, $attributes) ->willReturn($imageMock); - $this->assertInstanceOf( - \Magento\Catalog\Block\Product\Image::class, - $this->_renderer->getImage($productMock, $imageId, $attributes) + static::assertInstanceOf( + Image::class, + $this->_renderer->getImage($product, $imageId, $attributes) ); } } diff --git a/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php b/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php index 88751b899d7c9..1c5224d007ec8 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php @@ -163,7 +163,6 @@ public function testGetConfig() ->willReturnMap($valueMap); $this->storeManagerMock->expects($this->exactly(2))->method('getStore')->willReturn($storeMock); $storeMock->expects($this->once())->method('getBaseUrl')->willReturn($baseUrl); - $this->imageHelper->expects($this->once())->method('getFrame')->willReturn(false); $this->scopeConfigMock->expects($this->at(0)) ->method('getValue') diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php b/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php index 8d105f25465e4..04723c5894f8f 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php @@ -1,14 +1,23 @@ objectManager = new ObjectManager($this); - $this->objectManagerMock = $this->basicMock(\Magento\Framework\ObjectManagerInterface::class); - $this->dataMock = $this->basicMock(\Magento\Checkout\Helper\Data::class); - $this->quoteMock = $this->createPartialMock( - \Magento\Quote\Model\Quote::class, + $this->objectManagerMock = $this->basicMock(ObjectManagerInterface::class); + $this->data = $this->basicMock(Data::class); + $this->quote = $this->createPartialMock( + Quote::class, ['getHasError', 'hasItems', 'validateMinimumAmount', 'hasError'] ); $this->contextMock = $this->basicMock(\Magento\Framework\App\Action\Context::class); - $this->sessionMock = $this->basicMock(\Magento\Customer\Model\Session::class); + $this->session = $this->basicMock(Session::class); $this->onepageMock = $this->basicMock(\Magento\Checkout\Model\Type\Onepage::class); $this->layoutMock = $this->basicMock(\Magento\Framework\View\Layout::class); - $this->requestMock = $this->basicMock(\Magento\Framework\App\RequestInterface::class); + $this->request = $this->getMockBuilder(Http::class) + ->disableOriginalConstructor() + ->setMethods(['isSecure', 'getHeader']) + ->getMock(); $this->responseMock = $this->basicMock(\Magento\Framework\App\ResponseInterface::class); $this->redirectMock = $this->basicMock(\Magento\Framework\App\Response\RedirectInterface::class); - $this->resultPageMock = $this->basicMock(\Magento\Framework\View\Result\Page::class); + $this->resultPage = $this->basicMock(Page::class); $this->pageConfigMock = $this->basicMock(\Magento\Framework\View\Page\Config::class); $this->titleMock = $this->basicMock(\Magento\Framework\View\Page\Title::class); $this->url = $this->createMock(\Magento\Framework\UrlInterface::class); @@ -130,7 +142,7 @@ protected function setUp() ->getMock(); $resultPageFactoryMock->expects($this->any()) ->method('create') - ->willReturn($this->resultPageMock); + ->willReturn($this->resultPage); $resultRedirectFactoryMock = $this->getMockBuilder(\Magento\Framework\Controller\Result\RedirectFactory::class) ->disableOriginalConstructor() @@ -141,21 +153,21 @@ protected function setUp() ->willReturn($this->resultRedirectMock); // stubs - $this->basicStub($this->onepageMock, 'getQuote')->willReturn($this->quoteMock); - $this->basicStub($this->resultPageMock, 'getLayout')->willReturn($this->layoutMock); + $this->basicStub($this->onepageMock, 'getQuote')->willReturn($this->quote); + $this->basicStub($this->resultPage, 'getLayout')->willReturn($this->layoutMock); $this->basicStub($this->layoutMock, 'getBlock') ->willReturn($this->basicMock(\Magento\Theme\Block\Html\Header::class)); - $this->basicStub($this->resultPageMock, 'getConfig')->willReturn($this->pageConfigMock); + $this->basicStub($this->resultPage, 'getConfig')->willReturn($this->pageConfigMock); $this->basicStub($this->pageConfigMock, 'getTitle')->willReturn($this->titleMock); $this->basicStub($this->titleMock, 'set')->willReturn($this->titleMock); // objectManagerMock $objectManagerReturns = [ - [\Magento\Checkout\Helper\Data::class, $this->dataMock], + [Data::class, $this->data], [\Magento\Checkout\Model\Type\Onepage::class, $this->onepageMock], [\Magento\Checkout\Model\Session::class, $this->basicMock(\Magento\Checkout\Model\Session::class)], - [\Magento\Customer\Model\Session::class, $this->basicMock(\Magento\Customer\Model\Session::class)], + [Session::class, $this->basicMock(Session::class)], ]; $this->objectManagerMock->expects($this->any()) @@ -165,7 +177,7 @@ protected function setUp() ->willReturn($this->basicMock(\Magento\Framework\UrlInterface::class)); // context stubs $this->basicStub($this->contextMock, 'getObjectManager')->willReturn($this->objectManagerMock); - $this->basicStub($this->contextMock, 'getRequest')->willReturn($this->requestMock); + $this->basicStub($this->contextMock, 'getRequest')->willReturn($this->request); $this->basicStub($this->contextMock, 'getResponse')->willReturn($this->responseMock); $this->basicStub($this->contextMock, 'getMessageManager') ->willReturn($this->basicMock(\Magento\Framework\Message\ManagerInterface::class)); @@ -175,33 +187,82 @@ protected function setUp() // SUT $this->model = $this->objectManager->getObject( - \Magento\Checkout\Controller\Index\Index::class, + Index::class, [ 'context' => $this->contextMock, - 'customerSession' => $this->sessionMock, + 'customerSession' => $this->session, 'resultPageFactory' => $resultPageFactoryMock, 'resultRedirectFactory' => $resultRedirectFactoryMock ] ); } - public function testRegenerateSessionIdOnExecute() + /** + * Checks a case when session should be or not regenerated during the request. + * + * @param bool $secure + * @param string $referer + * @param InvokedCount $expectedCall + * @dataProvider sessionRegenerationDataProvider + */ + public function testRegenerateSessionIdOnExecute(bool $secure, string $referer, InvokedCount $expectedCall) + { + $this->data->method('canOnepageCheckout') + ->willReturn(true); + $this->quote->method('hasItems') + ->willReturn(true); + $this->quote->method('getHasError') + ->willReturn(false); + $this->quote->method('validateMinimumAmount') + ->willReturn(true); + $this->session->method('isLoggedIn') + ->willReturn(true); + $this->request->method('isSecure') + ->willReturn($secure); + $this->request->method('getHeader') + ->with('referer') + ->willReturn($referer); + + $this->session->expects($expectedCall) + ->method('regenerateId'); + $this->assertSame($this->resultPage, $this->model->execute()); + } + + /** + * Gets list of variations for generating new session. + * + * @return array + */ + public function sessionRegenerationDataProvider(): array { - //Stubs to control execution flow - $this->basicStub($this->dataMock, 'canOnepageCheckout')->willReturn(true); - $this->basicStub($this->quoteMock, 'hasItems')->willReturn(true); - $this->basicStub($this->quoteMock, 'getHasError')->willReturn(false); - $this->basicStub($this->quoteMock, 'validateMinimumAmount')->willReturn(true); - $this->basicStub($this->sessionMock, 'isLoggedIn')->willReturn(true); - - //Expected outcomes - $this->sessionMock->expects($this->once())->method('regenerateId'); - $this->assertSame($this->resultPageMock, $this->model->execute()); + return [ + [ + 'secure' => false, + 'referer' => 'https://test.domain.com/', + 'expectedCall' => self::once() + ], + [ + 'secure' => true, + 'referer' => false, + 'expectedCall' => self::once() + ], + [ + 'secure' => true, + 'referer' => 'http://test.domain.com/', + 'expectedCall' => self::once() + ], + // This is the only case in which session regeneration can be skipped + [ + 'secure' => true, + 'referer' => 'https://test.domain.com/', + 'expectedCall' => self::never() + ], + ]; } public function testOnepageCheckoutNotAvailable() { - $this->basicStub($this->dataMock, 'canOnepageCheckout')->willReturn(false); + $this->basicStub($this->data, 'canOnepageCheckout')->willReturn(false); $expectedPath = 'checkout/cart'; $this->resultRedirectMock->expects($this->once()) @@ -214,7 +275,7 @@ public function testOnepageCheckoutNotAvailable() public function testInvalidQuote() { - $this->basicStub($this->quoteMock, 'hasError')->willReturn(true); + $this->basicStub($this->quote, 'hasError')->willReturn(true); $expectedPath = 'checkout/cart'; $this->resultRedirectMock->expects($this->once()) @@ -226,23 +287,22 @@ public function testInvalidQuote() } /** - * @param \PHPUnit_Framework_MockObject_MockObject $mock + * @param MockObject $mock * @param string $method * - * @return \PHPUnit\Framework\MockObject_Builder_InvocationMocker + * @return InvocationMocker */ - private function basicStub($mock, $method) + private function basicStub($mock, $method): InvocationMocker { - return $mock->expects($this->any()) - ->method($method) - ->withAnyParameters(); + return $mock->method($method) + ->withAnyParameters(); } /** * @param string $className - * @return \PHPUnit_Framework_MockObject_MockObject + * @return MockObject */ - private function basicMock($className) + private function basicMock(string $className): MockObject { return $this->getMockBuilder($className) ->disableOriginalConstructor() diff --git a/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php b/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php index c403156dc13e9..53132ffaa748b 100644 --- a/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php @@ -6,8 +6,7 @@ namespace Magento\Checkout\Test\Unit\Helper; -use \Magento\Checkout\Helper\Data; - +use Magento\Checkout\Helper\Data; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Store\Model\ScopeInterface; @@ -24,38 +23,36 @@ class DataTest extends \PHPUnit\Framework\TestCase /** * @var Data */ - private $_helper; + private $helper; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - private $_transportBuilder; + private $transportBuilder; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - private $_translator; + private $translator; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $_checkoutSession; + private $checkoutSession; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $_scopeConfig; + private $scopeConfig; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $_collectionFactory; + private $eventManager; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @inheritdoc */ - protected $_eventManager; - protected function setUp() { $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -63,191 +60,88 @@ protected function setUp() $arguments = $objectManagerHelper->getConstructArguments($className); /** @var \Magento\Framework\App\Helper\Context $context */ $context = $arguments['context']; - $this->_translator = $arguments['inlineTranslation']; - $this->_eventManager = $context->getEventManager(); - $this->_scopeConfig = $context->getScopeConfig(); - $this->_scopeConfig->expects($this->any()) + $this->translator = $arguments['inlineTranslation']; + $this->eventManager = $context->getEventManager(); + $this->scopeConfig = $context->getScopeConfig(); + $this->scopeConfig->expects($this->any()) ->method('getValue') - ->will( - $this->returnValueMap( + ->willReturnMap( + [ + [ + 'checkout/payment_failed/template', + ScopeInterface::SCOPE_STORE, + 8, + 'fixture_email_template_payment_failed', + ], + [ + 'checkout/payment_failed/receiver', + ScopeInterface::SCOPE_STORE, + 8, + 'sysadmin', + ], + [ + 'trans_email/ident_sysadmin/email', + ScopeInterface::SCOPE_STORE, + 8, + 'sysadmin@example.com', + ], + [ + 'trans_email/ident_sysadmin/name', + ScopeInterface::SCOPE_STORE, + 8, + 'System Administrator', + ], + [ + 'checkout/payment_failed/identity', + ScopeInterface::SCOPE_STORE, + 8, + 'noreply@example.com', + ], + [ + 'carriers/ground/title', + ScopeInterface::SCOPE_STORE, + null, + 'Ground Shipping', + ], [ - [ - 'checkout/payment_failed/template', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'fixture_email_template_payment_failed' - ], - [ - 'checkout/payment_failed/receiver', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'sysadmin' - ], - [ - 'trans_email/ident_sysadmin/email', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'sysadmin@example.com' - ], - [ - 'trans_email/ident_sysadmin/name', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'System Administrator' - ], - [ - 'checkout/payment_failed/identity', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'noreply@example.com' - ], - [ - 'carriers/ground/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - null, - 'Ground Shipping' - ], - [ - 'payment/fixture-payment-method/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - null, - 'Check Money Order' - ], - [ - 'checkout/options/onepage_checkout_enabled', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - null, - 'One Page Checkout' - ] - ] - ) + 'payment/fixture-payment-method/title', + ScopeInterface::SCOPE_STORE, + null, + 'Check Money Order', + ], + [ + 'checkout/options/onepage_checkout_enabled', + ScopeInterface::SCOPE_STORE, + null, + 'One Page Checkout', + ], + ] ); - $this->_checkoutSession = $arguments['checkoutSession']; + $this->checkoutSession = $arguments['checkoutSession']; $arguments['localeDate']->expects($this->any()) ->method('formatDateTime') ->willReturn('Oct 02, 2013'); - $this->_transportBuilder = $arguments['transportBuilder']; + $this->transportBuilder = $arguments['transportBuilder']; $this->priceCurrency = $arguments['priceCurrency']; - $this->_helper = $objectManagerHelper->getObject($className, $arguments); + $this->helper = $objectManagerHelper->getObject($className, $arguments); } /** * @return void - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testSendPaymentFailedEmail() { - $shippingAddress = new \Magento\Framework\DataObject(['shipping_method' => 'ground_transportation']); - $billingAddress = new \Magento\Framework\DataObject(['street' => 'Fixture St']); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setTemplateOptions' - )->with( - [ - 'area' => \Magento\Backend\App\Area\FrontNameResolver::AREA_CODE, - 'store' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, - ] - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setTemplateIdentifier' - )->with( - 'fixture_email_template_payment_failed' - )->will( - $this->returnSelf() - ); + $quoteMock = $this->getMockBuilder(\Magento\Quote\Model\Quote::class) + ->setMethods(['getId']) + ->disableOriginalConstructor() + ->getMock(); + $quoteMock->expects($this->any())->method('getId')->willReturn(1); - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setFrom' - )->with( - 'noreply@example.com' - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'addTo' - )->with( - 'sysadmin@example.com', - 'System Administrator' - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setTemplateVars' - )->with( - [ - 'reason' => 'test message', - 'checkoutType' => 'onepage', - 'dateAndTime' => 'Oct 02, 2013', - 'customer' => 'John Doe', - 'customerEmail' => 'john.doe@example.com', - 'billingAddress' => $billingAddress, - 'shippingAddress' => $shippingAddress, - 'shippingMethod' => 'Ground Shipping', - 'paymentMethod' => 'Check Money Order', - 'items' => "Product One x 2 USD 10
    \nProduct Two x 3 USD 60
    \n", - 'total' => 'USD 70' - ] - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects($this->once())->method('addBcc')->will($this->returnSelf()); - $this->_transportBuilder->expects( - $this->once() - )->method( - 'getTransport' - )->will( - $this->returnValue($this->createMock(\Magento\Framework\Mail\TransportInterface::class)) - ); - - $this->_translator->expects($this->at(1))->method('suspend'); - $this->_translator->expects($this->at(1))->method('resume'); - - $productOne = $this->createMock(\Magento\Catalog\Model\Product::class); - $productOne->expects($this->once())->method('getName')->will($this->returnValue('Product One')); - $productOne->expects($this->once())->method('getFinalPrice')->with(2)->will($this->returnValue(10)); - - $productTwo = $this->createMock(\Magento\Catalog\Model\Product::class); - $productTwo->expects($this->once())->method('getName')->will($this->returnValue('Product Two')); - $productTwo->expects($this->once())->method('getFinalPrice')->with(3)->will($this->returnValue(60)); - - $quote = new \Magento\Framework\DataObject( - [ - 'store_id' => 8, - 'store_currency_code' => 'USD', - 'grand_total' => 70, - 'customer_firstname' => 'John', - 'customer_lastname' => 'Doe', - 'customer_email' => 'john.doe@example.com', - 'billing_address' => $billingAddress, - 'shipping_address' => $shippingAddress, - 'payment' => new \Magento\Framework\DataObject(['method' => 'fixture-payment-method']), - 'all_visible_items' => [ - new \Magento\Framework\DataObject(['product' => $productOne, 'qty' => 2]), - new \Magento\Framework\DataObject(['product' => $productTwo, 'qty' => 3]) - ] - ] - ); - $this->assertSame($this->_helper, $this->_helper->sendPaymentFailedEmail($quote, 'test message')); + $this->assertSame($this->helper, $this->helper->sendPaymentFailedEmail($quoteMock, 'test message')); } /** @@ -255,14 +149,14 @@ public function testSendPaymentFailedEmail() */ public function testGetCheckout() { - $this->assertEquals($this->_checkoutSession, $this->_helper->getCheckout()); + $this->assertEquals($this->checkoutSession, $this->helper->getCheckout()); } public function testGetQuote() { $quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); - $this->_checkoutSession->expects($this->once())->method('getQuote')->will($this->returnValue($quoteMock)); - $this->assertEquals($quoteMock, $this->_helper->getQuote()); + $this->checkoutSession->expects($this->once())->method('getQuote')->will($this->returnValue($quoteMock)); + $this->assertEquals($quoteMock, $this->helper->getQuote()); } public function testFormatPrice() @@ -270,26 +164,26 @@ public function testFormatPrice() $price = 5.5; $quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); $storeMock = $this->createPartialMock(\Magento\Store\Model\Store::class, ['formatPrice', '__wakeup']); - $this->_checkoutSession->expects($this->once())->method('getQuote')->will($this->returnValue($quoteMock)); + $this->checkoutSession->expects($this->once())->method('getQuote')->will($this->returnValue($quoteMock)); $quoteMock->expects($this->once())->method('getStore')->will($this->returnValue($storeMock)); $this->priceCurrency->expects($this->once())->method('format')->will($this->returnValue('5.5')); - $this->assertEquals('5.5', $this->_helper->formatPrice($price)); + $this->assertEquals('5.5', $this->helper->formatPrice($price)); } public function testConvertPrice() { $price = 5.5; $this->priceCurrency->expects($this->once())->method('convertAndFormat')->willReturn($price); - $this->assertEquals(5.5, $this->_helper->convertPrice($price)); + $this->assertEquals(5.5, $this->helper->convertPrice($price)); } public function testCanOnepageCheckout() { - $this->_scopeConfig->expects($this->once())->method('getValue')->with( + $this->scopeConfig->expects($this->once())->method('getValue')->with( 'checkout/options/onepage_checkout_enabled', 'store' )->will($this->returnValue(true)); - $this->assertTrue($this->_helper->canOnepageCheckout()); + $this->assertTrue($this->helper->canOnepageCheckout()); } public function testIsContextCheckout() @@ -310,18 +204,18 @@ public function testIsContextCheckout() public function testIsCustomerMustBeLogged() { - $this->_scopeConfig->expects($this->once())->method('isSetFlag')->with( + $this->scopeConfig->expects($this->once())->method('isSetFlag')->with( 'checkout/options/customer_must_be_logged', \Magento\Store\Model\ScopeInterface::SCOPE_STORE )->will($this->returnValue(true)); - $this->assertTrue($this->_helper->isCustomerMustBeLogged()); + $this->assertTrue($this->helper->isCustomerMustBeLogged()); } public function testGetPriceInclTax() { $itemMock = $this->createPartialMock(\Magento\Framework\DataObject::class, ['getPriceInclTax']); $itemMock->expects($this->exactly(2))->method('getPriceInclTax')->will($this->returnValue(5.5)); - $this->assertEquals(5.5, $this->_helper->getPriceInclTax($itemMock)); + $this->assertEquals(5.5, $this->helper->getPriceInclTax($itemMock)); } public function testGetPriceInclTaxWithoutTax() @@ -362,7 +256,7 @@ public function testGetSubtotalInclTax() $expected = 5.5; $itemMock = $this->createPartialMock(\Magento\Framework\DataObject::class, ['getRowTotalInclTax']); $itemMock->expects($this->exactly(2))->method('getRowTotalInclTax')->will($this->returnValue($rowTotalInclTax)); - $this->assertEquals($expected, $this->_helper->getSubtotalInclTax($itemMock)); + $this->assertEquals($expected, $this->helper->getSubtotalInclTax($itemMock)); } public function testGetSubtotalInclTaxNegative() @@ -380,7 +274,7 @@ public function testGetSubtotalInclTaxNegative() $itemMock->expects($this->once()) ->method('getDiscountTaxCompensation')->will($this->returnValue($discountTaxCompensation)); $itemMock->expects($this->once())->method('getRowTotal')->will($this->returnValue($rowTotal)); - $this->assertEquals($expected, $this->_helper->getSubtotalInclTax($itemMock)); + $this->assertEquals($expected, $this->helper->getSubtotalInclTax($itemMock)); } public function testGetBasePriceInclTaxWithoutQty() @@ -427,7 +321,7 @@ public function testGetBaseSubtotalInclTax() $itemMock->expects($this->once())->method('getBaseTaxAmount'); $itemMock->expects($this->once())->method('getBaseDiscountTaxCompensation'); $itemMock->expects($this->once())->method('getBaseRowTotal'); - $this->_helper->getBaseSubtotalInclTax($itemMock); + $this->helper->getBaseSubtotalInclTax($itemMock); } public function testIsAllowedGuestCheckoutWithoutStore() @@ -435,9 +329,9 @@ public function testIsAllowedGuestCheckoutWithoutStore() $quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); $store = null; $quoteMock->expects($this->once())->method('getStoreId')->will($this->returnValue(1)); - $this->_scopeConfig->expects($this->once()) + $this->scopeConfig->expects($this->once()) ->method('isSetFlag') ->will($this->returnValue(true)); - $this->assertTrue($this->_helper->isAllowedGuestCheckout($quoteMock, $store)); + $this->assertTrue($this->helper->isAllowedGuestCheckout($quoteMock, $store)); } } diff --git a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php index ba6bba6d6333d..853ae0157e64a 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php @@ -3,9 +3,16 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Checkout\Test\Unit\Model; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Address; +use Magento\Quote\Model\QuoteIdMask; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -46,6 +53,11 @@ class GuestPaymentInformationManagementTest extends \PHPUnit\Framework\TestCase */ private $loggerMock; + /** + * @var ResourceConnection|\PHPUnit_Framework_MockObject_MockObject + */ + private $resourceConnectionMock; + protected function setUp() { $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -63,6 +75,10 @@ protected function setUp() ['create'] ); $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + $this->resourceConnectionMock = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->model = $objectManager->getObject( \Magento\Checkout\Model\GuestPaymentInformationManagement::class, [ @@ -70,7 +86,8 @@ protected function setUp() 'paymentMethodManagement' => $this->paymentMethodManagementMock, 'cartManagement' => $this->cartManagementMock, 'cartRepository' => $this->cartRepositoryMock, - 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock + 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock, + 'connectionPool' => $this->resourceConnectionMock, ] ); $objectManager->setBackwardCompatibleProperty($this->model, 'logger', $this->loggerMock); @@ -83,12 +100,30 @@ public function testSavePaymentInformationAndPlaceOrder() $email = 'email@magento.com'; $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('commit'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('commit'); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); @@ -108,13 +143,32 @@ public function testSavePaymentInformationAndPlaceOrderException() $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('rollback'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('rollback'); + $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $exception = new \Exception(__('DB exception')); + $exception = new \Magento\Framework\Exception\CouldNotSaveException(__('DB exception')); $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock); @@ -130,11 +184,9 @@ public function testSavePaymentInformation() $email = 'email@magento.com'; $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); $this->assertTrue($this->model->savePaymentInformation($cartId, $email, $paymentMock, $billingAddressMock)); @@ -146,13 +198,13 @@ public function testSavePaymentInformationWithoutBillingAddress() $email = 'email@magento.com'; $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); - $quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); + $quoteMock = $this->createMock(Quote::class); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); $this->billingAddressManagementMock->expects($this->never())->method('assign'); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $quoteIdMaskMock = $this->createPartialMock(\Magento\Quote\Model\QuoteIdMask::class, ['getQuoteId', 'load']); + $quoteIdMaskMock = $this->createPartialMock(QuoteIdMask::class, ['getQuoteId', 'load']); $this->quoteIdMaskFactoryMock->expects($this->once())->method('create')->willReturn($quoteIdMaskMock); $quoteIdMaskMock->expects($this->once())->method('load')->with($cartId, 'masked_id')->willReturnSelf(); $quoteIdMaskMock->expects($this->once())->method('getQuoteId')->willReturn($cartId); @@ -173,11 +225,38 @@ public function testSavePaymentInformationAndPlaceOrderWithLocalizedException() $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $quoteMock = $this->createMock(Quote::class); + $quoteMock->method('getBillingAddress')->willReturn($billingAddressMock); + $this->cartRepositoryMock->method('getActive')->with($cartId)->willReturn($quoteMock); + + $quoteIdMask = $this->createPartialMock(QuoteIdMask::class, ['getQuoteId', 'load']); + $this->quoteIdMaskFactoryMock->method('create')->willReturn($quoteIdMask); + $quoteIdMask->method('load')->with($cartId, 'masked_id')->willReturnSelf(); + $quoteIdMask->method('getQuoteId')->willReturn($cartId); + $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('rollback'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('rollback'); + $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); $phrase = new \Magento\Framework\Phrase(__('DB exception')); $exception = new \Magento\Framework\Exception\LocalizedException($phrase); @@ -186,4 +265,57 @@ public function testSavePaymentInformationAndPlaceOrderWithLocalizedException() $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock); } + + /** + * @param int $cartId + * @param \PHPUnit_Framework_MockObject_MockObject $billingAddressMock + * @return void + */ + private function getMockForAssignBillingAddress( + int $cartId, + \PHPUnit_Framework_MockObject_MockObject $billingAddressMock + ) : void { + $quoteIdMask = $this->createPartialMock(QuoteIdMask::class, ['getQuoteId', 'load']); + $this->quoteIdMaskFactoryMock->method('create') + ->willReturn($quoteIdMask); + $quoteIdMask->method('load') + ->with($cartId, 'masked_id') + ->willReturnSelf(); + $quoteIdMask->method('getQuoteId') + ->willReturn($cartId); + + $billingAddressId = 1; + $quote = $this->createMock(Quote::class); + $quoteBillingAddress = $this->createMock(Address::class); + $quoteShippingAddress = $this->createPartialMock( + Address::class, + ['setLimitCarrier', 'getShippingMethod'] + ); + $this->cartRepositoryMock->method('getActive') + ->with($cartId) + ->willReturn($quote); + $quote->expects($this->once()) + ->method('getBillingAddress') + ->willReturn($quoteBillingAddress); + $quote->expects($this->once()) + ->method('getShippingAddress') + ->willReturn($quoteShippingAddress); + $quoteBillingAddress->expects($this->once()) + ->method('getId') + ->willReturn($billingAddressId); + $quote->expects($this->once()) + ->method('removeAddress') + ->with($billingAddressId); + $quote->expects($this->once()) + ->method('setBillingAddress') + ->with($billingAddressMock); + $quote->expects($this->once()) + ->method('setDataChanges') + ->willReturnSelf(); + $quoteShippingAddress->method('getShippingMethod') + ->willReturn('flatrate_flatrate'); + $quoteShippingAddress->expects($this->once()) + ->method('setLimitCarrier') + ->with('flatrate'); + } } diff --git a/app/code/Magento/Checkout/composer.json b/app/code/Magento/Checkout/composer.json index ba07f55306e7b..540565345bd9b 100644 --- a/app/code/Magento/Checkout/composer.json +++ b/app/code/Magento/Checkout/composer.json @@ -5,9 +5,8 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", - "magento/module-backend": "*", "magento/module-catalog": "*", "magento/module-catalog-inventory": "*", "magento/module-config": "*", diff --git a/app/code/Magento/Checkout/etc/adminhtml/routes.xml b/app/code/Magento/Checkout/etc/adminhtml/routes.xml new file mode 100644 index 0000000000000..e537861059870 --- /dev/null +++ b/app/code/Magento/Checkout/etc/adminhtml/routes.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/Checkout/etc/webapi.xml b/app/code/Magento/Checkout/etc/webapi.xml index 7b435db200f19..26c601a4e9f38 100644 --- a/app/code/Magento/Checkout/etc/webapi.xml +++ b/app/code/Magento/Checkout/etc/webapi.xml @@ -104,7 +104,7 @@ - + diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml index af652e6f556b9..e6d0260cf2305 100644 --- a/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml +++ b/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml @@ -25,7 +25,7 @@ getIsNeedToDisplaySideBar()): ?> -
    activeIndex + 1) { code = steps()[activeIndex + 1].code; steps()[activeIndex + 1].isVisible(true); - window.location = window.checkoutConfig.checkoutUrl + '#' + code; + this.setHash(code); document.body.scrollTop = document.documentElement.scrollTop = 0; } } diff --git a/app/code/Magento/Checkout/view/frontend/web/js/proceed-to-checkout.js b/app/code/Magento/Checkout/view/frontend/web/js/proceed-to-checkout.js index f0679c657ab90..0bb0a53ce0a6b 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/proceed-to-checkout.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/proceed-to-checkout.js @@ -22,6 +22,7 @@ define([ return false; } + $(element).attr('disabled', true); location.href = config.checkoutUrl; }); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/shopping-cart.js b/app/code/Magento/Checkout/view/frontend/web/js/shopping-cart.js index 399321bd2f67d..3ea49cd981d90 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/shopping-cart.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/shopping-cart.js @@ -12,7 +12,7 @@ define([ $.widget('mage.shoppingCart', { /** @inheritdoc */ _create: function () { - var items, i; + var items, i, reload; $(this.options.emptyCartButton).on('click', $.proxy(function () { $(this.options.emptyCartButton).attr('name', 'update_cart_action_temp'); @@ -36,6 +36,27 @@ define([ $(this.options.continueShoppingButton).on('click', $.proxy(function () { location.href = this.options.continueShoppingUrl; }, this)); + + $(document).on('ajax:removeFromCart', $.proxy(function () { + reload = true; + $('div.block.block-minicart').on('dropdowndialogclose', $.proxy(function () { + if (reload === true) { + location.reload(); + reload = false; + } + $('div.block.block-minicart').off('dropdowndialogclose'); + })); + }, this)); + $(document).on('ajax:updateItemQty', $.proxy(function () { + reload = true; + $('div.block.block-minicart').on('dropdowndialogclose', $.proxy(function () { + if (reload === true) { + location.reload(); + reload = false; + } + $('div.block.block-minicart').off('dropdowndialogclose'); + })); + }, this)); } }); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js b/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js index 13a2b524e5186..3fb8743e951c8 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js @@ -105,6 +105,13 @@ define([ self._showItemButton($(event.target)); }; + /** + * @param {jQuery.Event} event + */ + events['change ' + this.options.item.qty] = function (event) { + self._showItemButton($(event.target)); + }; + /** * @param {jQuery.Event} event */ @@ -213,6 +220,7 @@ define([ */ _updateItemQtyAfter: function (elem) { this._hideItemButton(elem); + $(document).trigger('ajax:updateItemQty'); }, /** diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js b/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js index d7a81decbadef..9af5201c267e8 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js @@ -7,10 +7,12 @@ require([ var selectors = { qtySelector: '#product_addtocart_form [name="qty"]', - productIdSelector: '#product_addtocart_form [name="product"]' + productIdSelector: '#product_addtocart_form [name="product"]', + itemIdSelector: '#product_addtocart_form [name="item"]' }, cartData = customerData.get('cart'), productId = $(selectors.productIdSelector).val(), + itemId = $(selectors.itemIdSelector).val(), productQty, productQtyInput, @@ -40,8 +42,10 @@ require([ return; } product = data.items.find(function (item) { - return item['product_id'] === productId || - item['item_id'] === productId; + if (item['item_id'] === itemId) { + return item['product_id'] === productId || + item['item_id'] === productId; + } }); if (!product) { diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js b/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js index 72cf4e3d479c3..683a18d0e4ead 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js @@ -25,6 +25,11 @@ define([ initialize: function () { this._super(); window.addEventListener('hashchange', _.bind(stepNavigator.handleHash, stepNavigator)); + + if (!window.location.hash) { + stepNavigator.setHash(stepNavigator.steps().sort(stepNavigator.sortItems)[0].code); + } + stepNavigator.handleHash(); }, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js b/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js index c715b5c4d45ce..a7b3e18c06088 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js @@ -38,7 +38,16 @@ define([ }, /** - * Create new user account + * @return String + */ + getUrl: function () { + return this.registrationUrl; + }, + + /** + * Create new user account. + * + * @deprecated */ createAccount: function () { this.creationStarted(true); diff --git a/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html b/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html index 8bf1a87d34e6e..2daca51a2f5da 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html @@ -30,8 +30,12 @@ - - + + + + + +
    diff --git a/app/code/Magento/Checkout/view/frontend/web/template/registration.html b/app/code/Magento/Checkout/view/frontend/web/template/registration.html index 256fc1968abfc..ea94726e5443e 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/registration.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/registration.html @@ -11,11 +11,8 @@

    :

    -
    - + +
    - - -

    - + diff --git a/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php b/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php index ed9ecc642e16b..4a35a58a41ff9 100644 --- a/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php +++ b/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php @@ -5,27 +5,42 @@ */ namespace Magento\CheckoutAgreements\Block\Adminhtml\Agreement; +use Magento\Framework\App\ObjectManager; +use Magento\CheckoutAgreements\Model\ResourceModel\Agreement\Grid\CollectionFactory as GridCollectionFactory; + class Grid extends \Magento\Backend\Block\Widget\Grid\Extended { /** * @var \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory + * @deprecated */ protected $_collectionFactory; + /** + * @param GridCollectionFactory + */ + private $gridCollectionFactory; + /** * @param \Magento\Backend\Block\Template\Context $context * @param \Magento\Backend\Helper\Data $backendHelper * @param \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory $collectionFactory * @param array $data + * @param GridCollectionFactory $gridColFactory * @codeCoverageIgnore */ public function __construct( \Magento\Backend\Block\Template\Context $context, \Magento\Backend\Helper\Data $backendHelper, \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory $collectionFactory, - array $data = [] + array $data = [], + GridCollectionFactory $gridColFactory = null ) { + $this->_collectionFactory = $collectionFactory; + $this->gridCollectionFactory = $gridColFactory + ? : ObjectManager::getInstance()->get(GridCollectionFactory::class); + parent::__construct($context, $backendHelper, $data); } @@ -47,7 +62,7 @@ protected function _construct() */ protected function _prepareCollection() { - $this->setCollection($this->_collectionFactory->create()); + $this->setCollection($this->gridCollectionFactory->create()); return parent::_prepareCollection(); } diff --git a/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php b/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php new file mode 100644 index 0000000000000..70794d24a64eb --- /dev/null +++ b/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php @@ -0,0 +1,78 @@ +isLoaded()) { + return $this; + } + + parent::load($printQuery, $logQuery); + + $this->addStoresToResult(); + + return $this; + } + + /** + * @return void + */ + private function addStoresToResult() + { + $stores = $this->getStoresForAgreements(); + + if (!empty($stores)) { + $storesByAgreementId = []; + + foreach ($stores as $storeData) { + $storesByAgreementId[$storeData['agreement_id']][] = $storeData['store_id']; + } + + foreach ($this as $item) { + $agreementId = $item->getData('agreement_id'); + + if (!isset($storesByAgreementId[$agreementId])) { + continue; + } + + $item->setData('stores', $storesByAgreementId[$agreementId]); + } + } + } + + /** + * @return array + */ + private function getStoresForAgreements() + { + $agreementId = $this->getColumnValues('agreement_id'); + + if (!empty($agreementId)) { + $select = $this->getConnection()->select()->from( + ['agreement_store' => 'checkout_agreement_store'] + )->where( + 'agreement_store.agreement_id IN (?)', + $agreementId + ); + + return $this->getConnection()->fetchAll($select); + } + + return []; + } +} diff --git a/app/code/Magento/CheckoutAgreements/composer.json b/app/code/Magento/CheckoutAgreements/composer.json index 091e97d784bce..7408bf5cab3fe 100644 --- a/app/code/Magento/CheckoutAgreements/composer.json +++ b/app/code/Magento/CheckoutAgreements/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-checkout": "*", diff --git a/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml b/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml index 1249ea44b991e..5a708f49a7034 100644 --- a/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml +++ b/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml @@ -7,7 +7,7 @@ --> - + diff --git a/app/code/Magento/Cms/Helper/Wysiwyg/Images.php b/app/code/Magento/Cms/Helper/Wysiwyg/Images.php index 4cddc23239b18..cd3473c6bab87 100644 --- a/app/code/Magento/Cms/Helper/Wysiwyg/Images.php +++ b/app/code/Magento/Cms/Helper/Wysiwyg/Images.php @@ -9,8 +9,6 @@ /** * Wysiwyg Images Helper. - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Images extends \Magento\Framework\App\Helper\AbstractHelper { diff --git a/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php b/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php index dee37c8e901ec..2ff2aa3f82ba8 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Cms\Model\Wysiwyg; /** @@ -27,7 +29,7 @@ public function __construct(\Magento\Framework\View\Asset\Repository $assetRepo) /** * {@inheritdoc} */ - public function getConfig($config) + public function getConfig(\Magento\Framework\DataObject $config) : \Magento\Framework\DataObject { $config->addData([ 'tinymce4' => [ diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php b/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php index 2301cf9950ecc..822f9ce2b1cb5 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Cms\Model\Wysiwyg\Gallery; class DefaultConfigProvider implements \Magento\Framework\Data\Wysiwyg\ConfigProviderInterface @@ -49,7 +51,7 @@ public function __construct( /** * {@inheritdoc} */ - public function getConfig($config) + public function getConfig(\Magento\Framework\DataObject $config) : \Magento\Framework\DataObject { $pluginData = (array) $config->getData('plugins'); $imageData = [ diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php index 0c8ff7d0b2b78..4b7cd239a66f5 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php @@ -739,7 +739,7 @@ protected function _validatePath($path) */ protected function _sanitizePath($path) { - return rtrim(preg_replace('~[/\\\]+~', '/', $this->_directory->getDriver()->getRealPath($path)), '/'); + return rtrim(preg_replace('~[/\\\]+~', '/', $this->_directory->getDriver()->getRealPathSafety($path)), '/'); } /** diff --git a/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php b/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php index c03629188798b..b0f7260d209ea 100644 --- a/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php +++ b/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Cms\Model; class WysiwygDefaultConfig implements \Magento\Framework\Data\Wysiwyg\ConfigProviderInterface @@ -10,7 +12,7 @@ class WysiwygDefaultConfig implements \Magento\Framework\Data\Wysiwyg\ConfigProv /** * {@inheritdoc} */ - public function getConfig($config) + public function getConfig(\Magento\Framework\DataObject $config) : \Magento\Framework\DataObject { return $config; } diff --git a/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php b/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php index 2174792d929a2..0c2c62ac62191 100644 --- a/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php +++ b/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php @@ -379,10 +379,8 @@ public function testGetCurrentPath($pathId, $expectedPath, $isExist) public function testGetCurrentPathThrowException() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - 'The directory PATH/wysiwyg is not writable by server.' - ); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage('The directory PATH is not writable by server.'); $this->directoryWriteMock->expects($this->once()) ->method('isExist') diff --git a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php index 805f6d042f94a..25134451d5a56 100644 --- a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php +++ b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php @@ -114,16 +114,9 @@ class StorageTest extends \PHPUnit\Framework\TestCase protected function setUp() { $this->filesystemMock = $this->createMock(\Magento\Framework\Filesystem::class); - $this->driverMock = $this->getMockForAbstractClass( - \Magento\Framework\Filesystem\DriverInterface::class, - [], - '', - false, - false, - true, - ['getRealPath'] - ); - $this->driverMock->expects($this->any())->method('getRealPath')->will($this->returnArgument(0)); + $this->driverMock = $this->getMockBuilder(\Magento\Framework\Filesystem\DriverInterface::class) + ->setMethods(['getRealPathSafety']) + ->getMockForAbstractClass(); $this->directoryMock = $this->createPartialMock( \Magento\Framework\Filesystem\Directory\Write::class, @@ -239,10 +232,11 @@ public function testGetResizeHeight() */ public function testDeleteDirectoryOverRoot() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage( sprintf('Directory %s is not under storage root path.', self::INVALID_DIRECTORY_OVER_ROOT) ); + $this->driverMock->expects($this->atLeastOnce())->method('getRealPathSafety')->will($this->returnArgument(0)); $this->imagesStorage->deleteDirectory(self::INVALID_DIRECTORY_OVER_ROOT); } @@ -251,10 +245,9 @@ public function testDeleteDirectoryOverRoot() */ public function testDeleteRootDirectory() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - sprintf('We can\'t delete root directory %s right now.', self::STORAGE_ROOT_DIR) - ); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage(sprintf('We can\'t delete root directory %s right now.', self::STORAGE_ROOT_DIR)); + $this->driverMock->expects($this->atLeastOnce())->method('getRealPathSafety')->will($this->returnArgument(0)); $this->imagesStorage->deleteDirectory(self::STORAGE_ROOT_DIR); } diff --git a/app/code/Magento/Cms/composer.json b/app/code/Magento/Cms/composer.json index b0051df455329..f051271c05051 100644 --- a/app/code/Magento/Cms/composer.json +++ b/app/code/Magento/Cms/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -18,7 +18,7 @@ "magento/module-widget": "*" }, "suggest": { - "magento/module-cms-sample-data": "Sample Data version:100.3.*" + "magento/module-cms-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Cms/view/adminhtml/web/js/folder-tree.js b/app/code/Magento/Cms/view/adminhtml/web/js/folder-tree.js index 62b5648f4a278..9f082b2ce32ce 100644 --- a/app/code/Magento/Cms/view/adminhtml/web/js/folder-tree.js +++ b/app/code/Magento/Cms/view/adminhtml/web/js/folder-tree.js @@ -94,7 +94,7 @@ define([ lastExistentFolderEl = folderEl; - if (path.length > 1) { + if (path.length) { tree.jstree('open_node', folderEl, recursiveOpen); } else { tree.jstree('open_node', folderEl, function () { diff --git a/app/code/Magento/CmsUrlRewrite/composer.json b/app/code/Magento/CmsUrlRewrite/composer.json index e89260f12a4ec..d4d2942a786f2 100644 --- a/app/code/Magento/CmsUrlRewrite/composer.json +++ b/app/code/Magento/CmsUrlRewrite/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-cms": "*", "magento/module-store": "*", diff --git a/app/code/Magento/CmsUrlRewriteGraphQl/Model/Resolver/UrlRewrite/HomePageUrlLocator.php b/app/code/Magento/CmsUrlRewriteGraphQl/Model/Resolver/UrlRewrite/HomePageUrlLocator.php new file mode 100644 index 0000000000000..6cc669e46d080 --- /dev/null +++ b/app/code/Magento/CmsUrlRewriteGraphQl/Model/Resolver/UrlRewrite/HomePageUrlLocator.php @@ -0,0 +1,47 @@ +scopeConfig = $scopeConfig; + } + + /** + * @inheritdoc + */ + public function locateUrl($urlKey): ?string + { + if ($urlKey === '/') { + $homePageUrl = $this->scopeConfig->getValue( + Page::XML_PATH_HOME_PAGE, + ScopeInterface::SCOPE_STORE + ); + return $homePageUrl; + } + return null; + } +} diff --git a/app/code/Magento/CmsUrlRewriteGraphQl/composer.json b/app/code/Magento/CmsUrlRewriteGraphQl/composer.json index 5bc26df5318ab..c57e4cdc92a83 100644 --- a/app/code/Magento/CmsUrlRewriteGraphQl/composer.json +++ b/app/code/Magento/CmsUrlRewriteGraphQl/composer.json @@ -3,14 +3,15 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "*" - + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-url-rewrite-graph-ql": "*", + "magento/module-store": "*", + "magento/module-cms": "*" }, "suggest": { "magento/module-cms-url-rewrite": "*", - "magento/module-catalog-graph-ql": "*", - "magento/module-url-rewrite-graph-ql": "*" + "magento/module-catalog-graph-ql": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/CmsUrlRewriteGraphQl/etc/di.xml b/app/code/Magento/CmsUrlRewriteGraphQl/etc/di.xml new file mode 100644 index 0000000000000..d384c898acb62 --- /dev/null +++ b/app/code/Magento/CmsUrlRewriteGraphQl/etc/di.xml @@ -0,0 +1,16 @@ + + + + + + + Magento\CmsUrlRewriteGraphQl\Model\Resolver\UrlRewrite\HomePageUrlLocator + + + + diff --git a/app/code/Magento/Config/Block/System/Config/Form.php b/app/code/Magento/Config/Block/System/Config/Form.php index c17df229cf549..81e39a83296d7 100644 --- a/app/code/Magento/Config/Block/System/Config/Form.php +++ b/app/code/Magento/Config/Block/System/Config/Form.php @@ -709,7 +709,7 @@ protected function _getAdditionalElementTypes() } /** - * Temporary moved those $this->getRequest()->getParam('blabla') from the code accross this block + * Temporary moved those $this->getRequest()->getParam('blabla') from the code across this block * to getBlala() methods to be later set from controller with setters */ diff --git a/app/code/Magento/Config/Model/Config/Importer.php b/app/code/Magento/Config/Model/Config/Importer.php index e65a90c593e84..a54af2ead5048 100644 --- a/app/code/Magento/Config/Model/Config/Importer.php +++ b/app/code/Magento/Config/Model/Config/Importer.php @@ -124,7 +124,7 @@ public function import(array $data) $this->scopeConfig->clean(); } - $this->state->emulateAreaCode(Area::AREA_ADMINHTML, function () use ($changedData, $data) { + $this->state->emulateAreaCode(Area::AREA_ADMINHTML, function () use ($changedData) { $this->scope->setCurrentScope(Area::AREA_ADMINHTML); // Invoke saving of new values. diff --git a/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php b/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php index 92bc61b3d65e5..252042a41cc1d 100644 --- a/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php +++ b/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php @@ -11,7 +11,8 @@ * Defines status of visibility of form elements on Stores > Settings > Configuration page * in Admin Panel in Production mode. * @api - * @since 100.2.0 + * @deprecated class location was changed + * @see \Magento\Config\Model\Config\Structure\ElementVisibility\ConcealInProduction */ class ConcealInProductionConfigList implements ElementVisibilityInterface { @@ -42,64 +43,32 @@ class ConcealInProductionConfigList implements ElementVisibilityInterface */ private $state; - /** - * - * The list of form element paths which ignore visibility status. - * - * E.g. - * - * ```php - * [ - * 'general/country/default' => '', - * ]; - * ``` - * - * It means that: - * - field 'default' in group Country Options (in section General) will be showed, even if all group(section) - * will be hidden. - * - * @var array - */ - private $exemptions = []; - /** * @param State $state The object that has information about the state of the system * @param array $configs The list of form element paths with concrete visibility status. - * @param array $exemptions The list of form element paths which ignore visibility status. */ - public function __construct(State $state, array $configs = [], array $exemptions = []) + public function __construct(State $state, array $configs = []) { $this->state = $state; $this->configs = $configs; - $this->exemptions = $exemptions; } /** * @inheritdoc - * @since 100.2.0 + * @deprecated */ public function isHidden($path) { - $result = false; $path = $this->normalizePath($path); - if ($this->state->getMode() === State::MODE_PRODUCTION - && preg_match('/(?(?
    .*?)\/.*?)\/.*?/', $path, $match)) { - $group = $match['group']; - $section = $match['section']; - $exemptions = array_keys($this->exemptions); - foreach ($this->configs as $configPath => $value) { - if ($value === static::HIDDEN && strpos($path, $configPath) !==false) { - $result = empty(array_intersect([$section, $group, $path], $exemptions)); - } - } - } - return $result; + return $this->state->getMode() === State::MODE_PRODUCTION + && !empty($this->configs[$path]) + && $this->configs[$path] === static::HIDDEN; } /** * @inheritdoc - * @since 100.2.0 + * @deprecated */ public function isDisabled($path) { diff --git a/app/code/Magento/Config/Model/Config/Structure/ElementVisibility/ConcealInProduction.php b/app/code/Magento/Config/Model/Config/Structure/ElementVisibility/ConcealInProduction.php new file mode 100755 index 0000000000000..d5ded9292864a --- /dev/null +++ b/app/code/Magento/Config/Model/Config/Structure/ElementVisibility/ConcealInProduction.php @@ -0,0 +1,138 @@ + Settings > Configuration page + * in Admin Panel in Production mode. + * @api + */ +class ConcealInProduction implements ElementVisibilityInterface +{ + /** + * The list of form element paths with concrete visibility status. + * + * E.g. + * + * ```php + * [ + * 'general/locale/code' => ElementVisibilityInterface::DISABLED, + * 'general/country' => ElementVisibilityInterface::HIDDEN, + * ]; + * ``` + * + * It means that: + * - field Locale (in group Locale Options in section General) will be disabled + * - group Country Options (in section General) will be hidden + * + * @var array + */ + private $configs = []; + + /** + * The object that has information about the state of the system. + * + * @var State + */ + private $state; + + /** + * + * The list of form element paths which ignore visibility status. + * + * E.g. + * + * ```php + * [ + * 'general/country/default' => '', + * ]; + * ``` + * + * It means that: + * - field 'default' in group Country Options (in section General) will be showed, even if all group(section) + * will be hidden. + * + * @var array + */ + private $exemptions = []; + + /** + * @param State $state The object that has information about the state of the system + * @param array $configs The list of form element paths with concrete visibility status. + * @param array $exemptions The list of form element paths which ignore visibility status. + */ + public function __construct(State $state, array $configs = [], array $exemptions = []) + { + $this->state = $state; + $this->configs = $configs; + $this->exemptions = $exemptions; + } + + /** + * @inheritdoc + * @since 100.2.0 + */ + public function isHidden($path) + { + $path = $this->normalizePath($path); + if ($this->state->getMode() === State::MODE_PRODUCTION + && preg_match('/(?(?
    .*?)\/.*?)\/.*?/', $path, $match)) { + $group = $match['group']; + $section = $match['section']; + $exemptions = array_keys($this->exemptions); + $checkedItems = []; + foreach ([$path, $group, $section] as $itemPath) { + $checkedItems[] = $itemPath; + if (!empty($this->configs[$itemPath])) { + return $this->configs[$itemPath] === static::HIDDEN + && empty(array_intersect($checkedItems, $exemptions)); + } + } + } + + return false; + } + + /** + * @inheritdoc + * @since 100.2.0 + */ + public function isDisabled($path) + { + $path = $this->normalizePath($path); + if ($this->state->getMode() === State::MODE_PRODUCTION) { + while (true) { + if (!empty($this->configs[$path])) { + return $this->configs[$path] === static::DISABLED; + } + + $position = strripos($path, '/'); + if ($position === false) { + break; + } + $path = substr($path, 0, $position); + } + } + + return false; + } + + /** + * Returns normalized path. + * + * @param string $path The path to be normalized + * @return string The normalized path + */ + private function normalizePath($path) + { + return trim($path, '/'); + } +} diff --git a/app/code/Magento/Config/Model/Config/Structure/ElementVisibility/ConcealInProductionWithoutScdOnDemand.php b/app/code/Magento/Config/Model/Config/Structure/ElementVisibility/ConcealInProductionWithoutScdOnDemand.php new file mode 100755 index 0000000000000..29148a244dcc6 --- /dev/null +++ b/app/code/Magento/Config/Model/Config/Structure/ElementVisibility/ConcealInProductionWithoutScdOnDemand.php @@ -0,0 +1,72 @@ + Settings > Configuration page + * when Constants::CONFIG_PATH_SCD_ON_DEMAND_IN_PRODUCTION is enabled + * otherwise rule from Magento\Config\Model\Config\Structure\ElementVisibility\ConcealInProduction is used + * @see \Magento\Config\Model\Config\Structure\ElementVisibility\ConcealInProduction + * + * @api + */ +class ConcealInProductionWithoutScdOnDemand implements ElementVisibilityInterface +{ + /** + * @var ConcealInProduction Element visibility rules in the Production mode + */ + private $concealInProduction; + + /** + * @var DeploymentConfig The application deployment configuration + */ + private $deploymentConfig; + + /** + * @param ConcealInProductionFactory $concealInProductionFactory + * @param DeploymentConfig $deploymentConfig Deployment configuration reader + * @param array $configs The list of form element paths with concrete visibility status. + * @param array $exemptions The list of form element paths which ignore visibility status. + */ + public function __construct( + ConcealInProductionFactory $concealInProductionFactory, + DeploymentConfig $deploymentConfig, + array $configs = [], + array $exemptions = [] + ) { + $this->concealInProduction = $concealInProductionFactory + ->create(['configs' => $configs, 'exemptions' => $exemptions]); + $this->deploymentConfig = $deploymentConfig; + } + + /** + * @inheritdoc + */ + public function isHidden($path): bool + { + if (!$this->deploymentConfig->getConfigData(Constants::CONFIG_PATH_SCD_ON_DEMAND_IN_PRODUCTION)) { + return $this->concealInProduction->isHidden($path); + } + return false; + } + + /** + * @inheritdoc + */ + public function isDisabled($path): bool + { + if (!$this->deploymentConfig->getConfigData(Constants::CONFIG_PATH_SCD_ON_DEMAND_IN_PRODUCTION)) { + return $this->concealInProduction->isDisabled($path); + } + return false; + } +} diff --git a/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/ImageTest.php b/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/ImageTest.php index 1679ac75ad02c..8a005a52ab614 100644 --- a/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/ImageTest.php +++ b/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/ImageTest.php @@ -72,7 +72,7 @@ public function testGetElementHtmlWithValue() 'showInWebsite' => '1', 'showInStore' => '1', 'label' => null, - 'backend_model' => \Magento\BackendModelConfig\Backend\Image::class, + 'backend_model' => \Magento\Config\Model\Config\Backend\Image::class, 'upload_dir' => [ 'config' => 'system/filesystem/media', 'scope_info' => '1', diff --git a/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/RegexceptionsTest.php b/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/RegexceptionsTest.php index 4f53f1072e035..0b4d5f7ef15f7 100644 --- a/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/RegexceptionsTest.php +++ b/app/code/Magento/Config/Test/Unit/Block/System/Config/Form/Field/RegexceptionsTest.php @@ -128,7 +128,8 @@ public function testRenderCellTemplateWrongColumnName() $this->object->addColumn($wrongColumnName, $this->cellParameters); - $this->expectException('\Exception', 'Wrong column name specified.'); + $this->expectException('\Exception'); + $this->expectExceptionMessage('Wrong column name specified.'); $this->object->renderCellTemplate($columnName); } diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php index fa78d5dde652c..ba74b93d9ad76 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php @@ -8,6 +8,11 @@ use Magento\Config\Model\Config\Structure\ConcealInProductionConfigList; use Magento\Framework\App\State; +/** + * @deprecated Original class has changed the location + * @see \Magento\Config\Model\Config\Structure\ElementVisibility\ConcealInProduction + * @see \Magento\Config\Test\Unit\Model\Config\Structure\ElementVisibility\ConcealInProductionTest + */ class ConcealInProductionConfigListTest extends \PHPUnit\Framework\TestCase { /** @@ -33,13 +38,9 @@ protected function setUp() 'third/path' => 'no', 'third/path/field' => ConcealInProductionConfigList::DISABLED, 'first/path/field' => 'no', - 'fourth' => ConcealInProductionConfigList::HIDDEN, - ]; - $exemptions = [ - 'fourth/path/value' => '', ]; - $this->model = new ConcealInProductionConfigList($this->stateMock, $configs, $exemptions); + $this->model = new ConcealInProductionConfigList($this->stateMock, $configs); } /** @@ -47,6 +48,8 @@ protected function setUp() * @param string $mageMode * @param bool $expectedResult * @dataProvider disabledDataProvider + * + * @deprecated */ public function testIsDisabled($path, $mageMode, $expectedResult) { @@ -58,6 +61,8 @@ public function testIsDisabled($path, $mageMode, $expectedResult) /** * @return array + * + * @deprecated */ public function disabledDataProvider() { @@ -82,6 +87,8 @@ public function disabledDataProvider() * @param string $mageMode * @param bool $expectedResult * @dataProvider hiddenDataProvider + * + * @deprecated */ public function testIsHidden($path, $mageMode, $expectedResult) { @@ -93,6 +100,8 @@ public function testIsHidden($path, $mageMode, $expectedResult) /** * @return array + * + * @deprecated */ public function hiddenDataProvider() { @@ -100,10 +109,8 @@ public function hiddenDataProvider() ['first/path', State::MODE_PRODUCTION, false], ['first/path', State::MODE_DEFAULT, false], ['some/path', State::MODE_PRODUCTION, false], - ['second/path/field', State::MODE_PRODUCTION, true], + ['second/path', State::MODE_PRODUCTION, true], ['second/path', State::MODE_DEVELOPER, false], - ['fourth/path/value', State::MODE_PRODUCTION, false], - ['fourth/path/test', State::MODE_PRODUCTION, true], ]; } } diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ElementVisibility/ConcealInProductionTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ElementVisibility/ConcealInProductionTest.php new file mode 100644 index 0000000000000..5fc689f911c1c --- /dev/null +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ElementVisibility/ConcealInProductionTest.php @@ -0,0 +1,106 @@ +stateMock = $this->getMockBuilder(State::class) + ->disableOriginalConstructor() + ->getMock(); + + $configs = [ + 'section1/group1/field1' => ElementVisibilityInterface::DISABLED, + 'section1/group1' => ElementVisibilityInterface::HIDDEN, + 'section1' => ElementVisibilityInterface::DISABLED, + 'section1/group2' => 'no', + 'section2/group1' => ElementVisibilityInterface::DISABLED, + 'section2/group2' => ElementVisibilityInterface::HIDDEN, + 'section3' => ElementVisibilityInterface::HIDDEN, + 'section3/group1/field1' => 'no', + ]; + $exemptions = [ + 'section1/group1/field3' => '', + 'section1/group2/field1' => '', + 'section2/group2/field1' => '', + 'section3/group2' => '', + ]; + + $this->model = new ConcealInProduction($this->stateMock, $configs, $exemptions); + } + + /** + * @param string $path + * @param string $mageMode + * @param bool $isDisabled + * @param bool $isHidden + * @dataProvider disabledDataProvider + */ + public function testCheckVisibility(string $path, string $mageMode, bool $isHidden, bool $isDisabled): void + { + $this->stateMock->expects($this->any()) + ->method('getMode') + ->willReturn($mageMode); + + $this->assertSame($isHidden, $this->model->isHidden($path)); + $this->assertSame($isDisabled, $this->model->isDisabled($path)); + } + + /** + * @return array + */ + public function disabledDataProvider(): array + { + return [ + //visibility of field 'section1/group1/field1' should be applied + ['section1/group1/field1', State::MODE_PRODUCTION, false, true], + ['section1/group1/field1', State::MODE_DEFAULT, false, false], + ['section1/group1/field1', State::MODE_DEVELOPER, false, false], + //visibility of group 'section1/group1' should be applied + ['section1/group1/field2', State::MODE_PRODUCTION, true, false], + ['section1/group1/field2', State::MODE_DEFAULT, false, false], + ['section1/group1/field2', State::MODE_DEVELOPER, false, false], + //exemption should be applied for section1/group2/field1 + ['section1/group2/field1', State::MODE_PRODUCTION, false, false], + ['section1/group2/field1', State::MODE_DEFAULT, false, false], + ['section1/group2/field1', State::MODE_DEVELOPER, false, false], + //as 'section1/group2' has neither Disable nor Hidden rule, this field should be visible + ['section1/group2/field2', State::MODE_PRODUCTION, false, false], + //exemption should be applied for section1/group1/field3 + ['section1/group1/field3', State::MODE_PRODUCTION, false, false], + //visibility of group 'section2/group1' should be applied + ['section2/group1/field1', State::MODE_PRODUCTION, false, true], + //exemption should be applied for section2/group2/field1 + ['section2/group2/field1', State::MODE_PRODUCTION, false, false], + //any rule should not be applied + ['section2/group3/field1', State::MODE_PRODUCTION, false, false], + //any rule should not be applied + ['section3/group1/field1', State::MODE_PRODUCTION, false, false], + //visibility of section 'section3' should be applied + ['section3/group1/field2', State::MODE_PRODUCTION, true, false], + //exception from 'section3/group2' should be applied + ['section3/group2/field1', State::MODE_PRODUCTION, false, false], + + ]; + } +} diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ElementVisibility/ConcealInProductionWithoutScdOnDemandTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ElementVisibility/ConcealInProductionWithoutScdOnDemandTest.php new file mode 100644 index 0000000000000..9d69a587f695d --- /dev/null +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ElementVisibility/ConcealInProductionWithoutScdOnDemandTest.php @@ -0,0 +1,148 @@ +createMock(ConcealInProductionFactory::class); + + $this->concealInProductionMock = $this->createMock(ConcealInProduction::class); + + $this->deploymentConfigMock = $this->createMock(\Magento\Framework\App\DeploymentConfig::class); + + $configs = [ + 'section1/group1/field1' => ElementVisibilityInterface::DISABLED, + 'section1/group1' => ElementVisibilityInterface::HIDDEN, + 'section1' => ElementVisibilityInterface::DISABLED, + 'section1/group2' => 'no', + 'section2/group1' => ElementVisibilityInterface::DISABLED, + 'section2/group2' => ElementVisibilityInterface::HIDDEN, + 'section3' => ElementVisibilityInterface::HIDDEN, + 'section3/group1/field1' => 'no', + ]; + $exemptions = [ + 'section1/group1/field3' => '', + 'section1/group2/field1' => '', + 'section2/group2/field1' => '', + 'section3/group2' => '', + ]; + + $concealInProductionFactoryMock->expects($this->any()) + ->method('create') + ->with(['configs' => $configs, 'exemptions' => $exemptions]) + ->willReturn($this->concealInProductionMock); + + $this->model = new ConcealInProductionWithoutScdOnDemand( + $concealInProductionFactoryMock, + $this->deploymentConfigMock, + $configs, + $exemptions + ); + } + + public function testIsHiddenScdOnDemandEnabled(): void + { + $path = 'section1/group1/field1'; + $this->deploymentConfigMock->expects($this->once()) + ->method('getConfigData') + ->with(Constants::CONFIG_PATH_SCD_ON_DEMAND_IN_PRODUCTION) + ->willReturn(true); + $this->concealInProductionMock->expects($this->never()) + ->method('isHidden'); + + $this->assertFalse($this->model->isHidden($path)); + } + + public function testIsDisabledScdOnDemandEnabled(): void + { + $path = 'section1/group1/field1'; + $this->deploymentConfigMock->expects($this->once()) + ->method('getConfigData') + ->with(Constants::CONFIG_PATH_SCD_ON_DEMAND_IN_PRODUCTION) + ->willReturn(true); + $this->concealInProductionMock->expects($this->never()) + ->method('isDisabled'); + + $this->assertFalse($this->model->isDisabled($path)); + } + + /** + * @param bool $isHidden + * + * @dataProvider visibilityDataProvider + */ + public function testIsHiddenScdOnDemandDisabled(bool $isHidden): void + { + $path = 'section1/group1/field1'; + $this->deploymentConfigMock->expects($this->once()) + ->method('getConfigData') + ->with(Constants::CONFIG_PATH_SCD_ON_DEMAND_IN_PRODUCTION) + ->willReturn(false); + $this->concealInProductionMock->expects($this->once()) + ->method('isHidden') + ->with($path) + ->willReturn($isHidden); + + $this->assertSame($isHidden, $this->model->isHidden($path)); + } + + /** + * @param bool $isDisabled + * + * @dataProvider visibilityDataProvider + */ + public function testIsDisabledScdOnDemandDisabled(bool $isDisabled): void + { + $path = 'section1/group1/field1'; + $this->deploymentConfigMock->expects($this->once()) + ->method('getConfigData') + ->with(Constants::CONFIG_PATH_SCD_ON_DEMAND_IN_PRODUCTION) + ->willReturn(false); + $this->concealInProductionMock->expects($this->once()) + ->method('isDisabled') + ->with($path) + ->willReturn($isDisabled); + + $this->assertSame($isDisabled, $this->model->isDisabled($path)); + } + + /** + * @return array + */ + public function visibilityDataProvider(): array + { + return [ + [true], + [false], + ]; + } +} diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/ExtendsTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/ExtendsTest.php index 95e8246c6a3d3..df20db4a1d92a 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/ExtendsTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/ExtendsTest.php @@ -31,10 +31,8 @@ public function testMap($sourceData, $resultData) public function testMapWithBadPath() { - $this->expectException( - 'InvalidArgumentException', - 'Invalid path in extends attribute of config/system/sections/section1 node' - ); + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Invalid path in extends attribute of config/system/sections/section1 node'); $sourceData = [ 'config' => [ 'system' => ['sections' => ['section1' => ['extends' => 'nonExistentSection2']]], diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/Helper/RelativePathConverterTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/Helper/RelativePathConverterTest.php index c671a5326c4de..058f9a380a27d 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/Helper/RelativePathConverterTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Mapper/Helper/RelativePathConverterTest.php @@ -24,7 +24,8 @@ public function testConvertWithInvalidRelativePath() $exceptionMessage = sprintf('Invalid relative path %s in %s node', $relativePath, $nodePath); - $this->expectException('InvalidArgumentException', $exceptionMessage); + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage($exceptionMessage); $this->_sut->convert($nodePath, $relativePath); } @@ -35,7 +36,8 @@ public function testConvertWithInvalidRelativePath() */ public function testConvertWithInvalidArguments($nodePath, $relativePath) { - $this->expectException('InvalidArgumentException', 'Invalid arguments'); + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Invalid arguments'); $this->_sut->convert($nodePath, $relativePath); } diff --git a/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php b/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php index 2832e8e54e5f6..2ddbbd5ffe1e8 100644 --- a/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php @@ -280,7 +280,8 @@ public function testSetDataByPathEmpty() public function testSetDataByPathWrongDepth($path, $expectedException) { $expectedException = 'Allowed depth of configuration is 3 (
    //). ' . $expectedException; - $this->expectException('\UnexpectedValueException', $expectedException); + $this->expectException('\UnexpectedValueException'); + $this->expectExceptionMessage($expectedException); $value = 'value'; $this->_model->setDataByPath($path, $value); } diff --git a/app/code/Magento/Config/composer.json b/app/code/Magento/Config/composer.json index 06205569610e0..57c067d2cae27 100644 --- a/app/code/Magento/Config/composer.json +++ b/app/code/Magento/Config/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-cron": "*", diff --git a/app/code/Magento/Config/etc/adminhtml/di.xml b/app/code/Magento/Config/etc/adminhtml/di.xml index c21c06c7f3e1f..5e54f177776ba 100644 --- a/app/code/Magento/Config/etc/adminhtml/di.xml +++ b/app/code/Magento/Config/etc/adminhtml/di.xml @@ -15,6 +15,8 @@ Magento\Config\Model\Config\Structure\ConcealInProductionConfigList + Magento\Config\Model\Config\Structure\ElementVisibility\ConcealInProduction + Magento\Config\Model\Config\Structure\ElementVisibility\ConcealInProductionWithoutScdOnDemand diff --git a/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml b/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml index 93af2cfa653f8..cf235d368b9bc 100644 --- a/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml +++ b/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml @@ -21,7 +21,7 @@ $_colspan = $block->isAddAfter() ? 2 : 1; getColumns() as $columnName => $column): ?> - Action + diff --git a/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php b/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php index 2c904883c8710..151bf5aa9263e 100644 --- a/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php @@ -11,6 +11,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\CatalogImportExport\Model\Import\Product as ImportProduct; use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Exception\LocalizedException; /** * Importing configurable products @@ -32,6 +33,8 @@ class Configurable extends \Magento\CatalogImportExport\Model\Import\Product\Typ const ERROR_DUPLICATED_VARIATIONS = 'duplicatedVariations'; + const ERROR_UNIDENTIFIABLE_VARIATION = 'unidentifiableVariation'; + /** * Validation failure message template definitions * @@ -42,6 +45,7 @@ class Configurable extends \Magento\CatalogImportExport\Model\Import\Product\Typ self::ERROR_INVALID_OPTION_VALUE => 'Invalid option value for attribute "%s"', self::ERROR_INVALID_WEBSITE => 'Invalid website code for super attribute', self::ERROR_DUPLICATED_VARIATIONS => 'SKU %s contains duplicated variations', + self::ERROR_UNIDENTIFIABLE_VARIATION => 'Configurable variation "%s" is unidentifiable', ]; /** @@ -469,14 +473,22 @@ protected function _processSuperData() * @param array $rowData * * @return array + * @throws LocalizedException * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function _parseVariations($rowData) { $additionalRows = []; - if (!isset($rowData['configurable_variations'])) { + if (empty($rowData['configurable_variations'])) { return $additionalRows; + } elseif (!empty($rowData['store_view_code'])) { + throw new LocalizedException( + __( + 'Product with assigned super attributes should not have specified "%1" value', + 'store_view_code' + ) + ); } $variations = explode(ImportProduct::PSEUDO_MULTI_LINE_SEPARATOR, $rowData['configurable_variations']); foreach ($variations as $variation) { @@ -488,7 +500,8 @@ protected function _parseVariations($rowData) $nameAndValue = explode(ImportProduct::PAIR_NAME_VALUE_SEPARATOR, $nameAndValue); if (!empty($nameAndValue)) { $value = isset($nameAndValue[1]) ? trim($nameAndValue[1]) : ''; - $fieldName = trim($nameAndValue[0]); + // Ignoring field names' case. + $fieldName = strtolower(trim($nameAndValue[0])); if ($fieldName) { $fieldAndValuePairs[$fieldName] = $value; } @@ -509,8 +522,18 @@ protected function _parseVariations($rowData) $additionalRow = []; $position += 1; } + } else { + throw new LocalizedException( + __( + sprintf( + $this->_messageTemplates[self::ERROR_UNIDENTIFIABLE_VARIATION], + $variation + ) + ) + ); } } + return $additionalRows; } @@ -606,7 +629,7 @@ protected function _insertData() } /** - * Get new supper attribute id. + * Get new super attribute id. * * @return int */ @@ -821,7 +844,14 @@ protected function configurableInBunch($bunch) public function isRowValid(array $rowData, $rowNum, $isNewProduct = true) { $error = false; - $dataWithExtraVirtualRows = $this->_parseVariations($rowData); + try { + $dataWithExtraVirtualRows = $this->_parseVariations($rowData); + } catch (LocalizedException $exception) { + $this->_entityModel->addRowError($exception->getMessage(), $rowNum); + + return false; + } + $skus = []; $rowData['price'] = isset($rowData['price']) && $rowData['price'] ? $rowData['price'] : '0.00'; if (!empty($dataWithExtraVirtualRows)) { @@ -845,6 +875,7 @@ public function isRowValid(array $rowData, $rowNum, $isNewProduct = true) } $error |= !parent::isRowValid($option, $rowNum, $isNewProduct); } + return !$error; } diff --git a/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php b/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php index f6912fe8b6d6c..8cdb5531a3cab 100644 --- a/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php +++ b/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php @@ -560,15 +560,56 @@ public function testIsRowValid() '_type' => 'configurable', '_product_websites' => 'website_1', ]; + // Checking that variations' field names are case-insensitive with this + // product. + $caseInsensitiveSKU = 'configurableskuI22CaseInsensitive'; + $caseInsensitiveProduct = [ + 'sku' => $caseInsensitiveSKU, + 'store_view_code' => null, + 'attribute_set_code' => 'Default', + 'product_type' => 'configurable', + 'name' => 'Configurable Product 21', + 'product_websites' => 'website_1', + 'configurable_variation_labels' => 'testattr2=Select Color, testattr3=Select Size', + 'configurable_variations' => 'SKU=testconf2-attr2val1-testattr3v1,' + . 'testattr2=attr2val1,' + . 'testattr3=testattr3v1,' + . 'display=1|sku=testconf2-attr2val1-testattr3v2,' + . 'testattr2=attr2val1,' + . 'testattr3=testattr3v2,' + . 'display=0', + '_store' => null, + '_attribute_set' => 'Default', + '_type' => 'configurable', + '_product_websites' => 'website_1', + ]; $bunch[] = $badProduct; + $bunch[] = $caseInsensitiveProduct; // Set _attributes to avoid error in Magento\CatalogImportExport\Model\Import\Product\Type\AbstractType. $this->setPropertyValue($this->configurable, '_attributes', [ $badProduct[\Magento\CatalogImportExport\Model\Import\Product::COL_ATTR_SET] => [], ]); + // Avoiding errors about attributes not being super + $this->setPropertyValue( + $this->configurable, + '_superAttributes', + [ + 'testattr2' => ['options' => ['attr2val1' => 1]], + 'testattr3' => [ + 'options' => [ + 'testattr3v2' => 1, + 'testattr3v1' => 1, + ], + ], + ] + ); foreach ($bunch as $rowData) { $result = $this->configurable->isRowValid($rowData, 0, !isset($this->_oldSku[$rowData['sku']])); $this->assertNotNull($result); + if ($rowData['sku'] === $caseInsensitiveSKU) { + $this->assertTrue($result); + } } } diff --git a/app/code/Magento/ConfigurableImportExport/composer.json b/app/code/Magento/ConfigurableImportExport/composer.json index 87cb83ec5fdb9..c1aab3e7a148f 100644 --- a/app/code/Magento/ConfigurableImportExport/composer.json +++ b/app/code/Magento/ConfigurableImportExport/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-catalog-import-export": "*", diff --git a/app/code/Magento/ConfigurableProduct/Helper/Data.php b/app/code/Magento/ConfigurableProduct/Helper/Data.php index 1de82eaad3196..674bd3703fa82 100644 --- a/app/code/Magento/ConfigurableProduct/Helper/Data.php +++ b/app/code/Magento/ConfigurableProduct/Helper/Data.php @@ -6,7 +6,11 @@ namespace Magento\ConfigurableProduct\Helper; -use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Image\UrlBuilder; +use Magento\Framework\App\ObjectManager; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product\Image; /** * Class Data @@ -17,50 +21,48 @@ class Data { /** - * Catalog Image Helper - * - * @var \Magento\Catalog\Helper\Image + * @var ImageHelper */ protected $imageHelper; /** - * @param \Magento\Catalog\Helper\Image $imageHelper + * @var UrlBuilder + */ + private $imageUrlBuilder; + + /** + * @param ImageHelper $imageHelper + * @param UrlBuilder $urlBuilder */ - public function __construct(\Magento\Catalog\Helper\Image $imageHelper) + public function __construct(ImageHelper $imageHelper, UrlBuilder $urlBuilder = null) { $this->imageHelper = $imageHelper; + $this->imageUrlBuilder = $urlBuilder ?? ObjectManager::getInstance()->get(UrlBuilder::class); } /** * Retrieve collection of gallery images * - * @param \Magento\Catalog\Api\Data\ProductInterface $product - * @return \Magento\Catalog\Model\Product\Image[]|null + * @param ProductInterface $product + * @return Image[]|null */ - public function getGalleryImages(\Magento\Catalog\Api\Data\ProductInterface $product) + public function getGalleryImages(ProductInterface $product) { $images = $product->getMediaGalleryImages(); if ($images instanceof \Magento\Framework\Data\Collection) { + /** @var $image Image */ foreach ($images as $image) { - /** @var $image \Magento\Catalog\Model\Product\Image */ - $image->setData( - 'small_image_url', - $this->imageHelper->init($product, 'product_page_image_small') - ->setImageFile($image->getFile()) - ->getUrl() - ); - $image->setData( - 'medium_image_url', - $this->imageHelper->init($product, 'product_page_image_medium_no_frame') - ->setImageFile($image->getFile()) - ->getUrl() - ); - $image->setData( - 'large_image_url', - $this->imageHelper->init($product, 'product_page_image_large_no_frame') - ->setImageFile($image->getFile()) - ->getUrl() - ); + $smallImageUrl = $this->imageUrlBuilder + ->getUrl($image->getFile(), 'product_page_image_small'); + $image->setData('small_image_url', $smallImageUrl); + + $mediumImageUrl = $this->imageUrlBuilder + ->getUrl($image->getFile(), 'product_page_image_medium'); + $image->setData('medium_image_url', $mediumImageUrl); + + $largeImageUrl = $this->imageUrlBuilder + ->getUrl($image->getFile(), 'product_page_image_large'); + $image->setData('large_image_url', $largeImageUrl); } } diff --git a/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php b/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php new file mode 100644 index 0000000000000..0c3fc6fba6005 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php @@ -0,0 +1,56 @@ +configurableType = $configurableType; + $this->productRepository = $productRepository; + } + + /** + * Add parent identities to product identities + * + * @param Product $subject + * @param array $identities + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetIdentities(Product $subject, array $identities): array + { + foreach ($this->configurableType->getParentIdsByChild($subject->getId()) as $parentId) { + $parentProduct = $this->productRepository->getById($parentId); + $identities = array_merge($identities, $parentProduct->getIdentities()); + } + + return array_unique($identities); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php deleted file mode 100644 index ac42e320f3ad9..0000000000000 --- a/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php +++ /dev/null @@ -1,51 +0,0 @@ -catalogProductTypeConfigurable = $catalogProductTypeConfigurable; - } - - /** - * {@inheritdoc} - */ - public function getTags($object) - { - if (!is_object($object)) { - throw new \InvalidArgumentException('Provided argument is not an object'); - } - - if (!($object instanceof \Magento\Catalog\Model\Product)) { - throw new \InvalidArgumentException('Provided argument must be a product'); - } - - $result = $object->getIdentities(); - - foreach ($this->catalogProductTypeConfigurable->getParentIdsByChild($object->getId()) as $parentId) { - $result[] = \Magento\Catalog\Model\Product::CACHE_TAG . '_' . $parentId; - } - return $result; - } -} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php b/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php index d42d4ccafdd01..1c470808824a8 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php +++ b/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php @@ -10,6 +10,8 @@ use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable as ResourceModelConfigurable; use Magento\Framework\EntityManager\Operation\ExtensionInterface; +use Magento\ConfigurableProduct\Api\Data\OptionInterface; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute; /** * Class SaveHandler @@ -76,35 +78,66 @@ public function execute($entity, $arguments = []) } /** - * Save attributes for configurable product + * Save only newly created attributes for configurable product. * * @param ProductInterface $product * @param array $attributes * @return array */ - private function saveConfigurableProductAttributes(ProductInterface $product, array $attributes) + private function saveConfigurableProductAttributes(ProductInterface $product, array $attributes): array { $ids = []; + $existingAttributeIds = []; + foreach ($this->optionRepository->getList($product->getSku()) as $option) { + $existingAttributeIds[$option->getAttributeId()] = $option; + } /** @var \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute $attribute */ foreach ($attributes as $attribute) { - $attribute->setId(null); - $ids[] = $this->optionRepository->save($product->getSku(), $attribute); + if (!in_array($attribute->getAttributeId(), array_keys($existingAttributeIds)) + || $this->isOptionChanged($existingAttributeIds[$attribute->getAttributeId()], $attribute) + ) { + $attribute->setId(null); + $ids[] = $this->optionRepository->save($product->getSku(), $attribute); + } } return $ids; } /** - * Remove product attributes + * Remove product attributes which no longer used. * * @param ProductInterface $product * @return void */ - private function deleteConfigurableProductAttributes(ProductInterface $product) + private function deleteConfigurableProductAttributes(ProductInterface $product): void + { + $newAttributeIds = []; + foreach ($product->getExtensionAttributes()->getConfigurableProductOptions() as $option) { + $newAttributeIds[$option->getAttributeId()] = $option; + } + foreach ($this->optionRepository->getList($product->getSku()) as $option) { + if (!in_array($option->getAttributeId(), array_keys($newAttributeIds)) + || $this->isOptionChanged($option, $newAttributeIds[$option->getAttributeId()]) + ) { + $this->optionRepository->deleteById($product->getSku(), $option->getId()); + } + } + } + + /** + * Check if existing option is changed. + * + * @param OptionInterface $option + * @param Attribute $attribute + * @return bool + */ + private function isOptionChanged(OptionInterface $option, Attribute $attribute): bool { - $list = $this->optionRepository->getList($product->getSku()); - foreach ($list as $item) { - $this->optionRepository->deleteById($product->getSku(), $item->getId()); + if ($option->getLabel() == $attribute->getLabel() && $option->getPosition() == $attribute->getPosition()) { + return false; } + + return true; } } diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php index 326310cc3c802..087931ebe5dcc 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php @@ -1,7 +1,5 @@ storeResolver = $storeResolver ?: \Magento\Framework\App\ObjectManager::getInstance()->get( - StoreResolverInterface::class - ); - } - /** * @param null|int|array $entityIds * @return \Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\Configurable @@ -58,6 +25,7 @@ protected function reindex($entityIds = null) $this->_applyConfigurableOption($entityIds); $this->_movePriceDataToIndexTable($entityIds); } + return $this; } @@ -109,67 +77,49 @@ protected function _prepareConfigurableOptionPriceTable() * * @param array|null $entityIds * @return \Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\Configurable - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ protected function _applyConfigurableOption($entityIds = null) { $metadata = $this->getMetadataPool()->getMetadata(ProductInterface::class); $connection = $this->getConnection(); - $coaTable = $this->_getConfigurableOptionAggregateTable(); $copTable = $this->_getConfigurableOptionPriceTable(); + $finalPriceTable = $this->_getDefaultFinalPriceTable(); $linkField = $metadata->getLinkField(); - $this->_prepareConfigurableOptionAggregateTable(); $this->_prepareConfigurableOptionPriceTable(); - $subSelect = $this->getSelect(); - $subSelect->join( + $select = $connection->select()->from( + ['i' => $this->getIdxTable()], + [] + )->join( ['l' => $this->getTable('catalog_product_super_link')], - 'l.product_id = e.entity_id', + 'l.product_id = i.entity_id', [] )->join( ['le' => $this->getTable('catalog_product_entity')], 'le.' . $linkField . ' = l.parent_id', - ['parent_id' => 'entity_id'] - ); - - if ($entityIds !== null) { - $subSelect->where('le.entity_id IN (?)', $entityIds); - } - - $select = $connection->select(); - $select - ->from(['sub' => new \Zend_Db_Expr('(' . (string)$subSelect . ')')], '') - ->columns([ - 'sub.parent_id', - 'sub.entity_id', - 'sub.customer_group_id', - 'sub.website_id', - 'sub.price', - 'sub.tier_price' - ]); - - $query = $select->insertFromSelect($coaTable); - $connection->query($query); - - $select = $connection->select()->from( - [$coaTable], + [] + )->columns( [ - 'parent_id', + 'le.entity_id', 'customer_group_id', 'website_id', - 'MIN(price)', - 'MAX(price)', + 'MIN(final_price)', + 'MAX(final_price)', 'MIN(tier_price)', + ] )->group( - ['parent_id', 'customer_group_id', 'website_id'] + ['le.entity_id', 'customer_group_id', 'website_id'] ); + if ($entityIds !== null) { + $select->where('le.entity_id IN (?)', $entityIds); + } $query = $select->insertFromSelect($copTable); $connection->query($query); - $table = ['i' => $this->_getDefaultFinalPriceTable()]; + $table = ['i' => $finalPriceTable]; $select = $connection->select()->join( ['io' => $copTable], 'i.entity_id = io.entity_id AND i.customer_group_id = io.customer_group_id' . @@ -188,7 +138,6 @@ protected function _applyConfigurableOption($entityIds = null) $query = $select->crossUpdateFromSelect($table); $connection->query($query); - $connection->delete($coaTable); $connection->delete($copTable); return $this; diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php index 95afba984d57d..ccff85dd9717f 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php @@ -189,7 +189,6 @@ public function getChildrenIds($parentId, $required = true) */ public function getParentIdsByChild($childId) { - $parentIds = []; $select = $this->getConnection() ->select() ->from(['l' => $this->getMainTable()], []) @@ -198,10 +197,7 @@ public function getParentIdsByChild($childId) 'e.' . $this->optionProvider->getProductEntityLinkField() . ' = l.parent_id', ['e.entity_id'] )->where('l.product_id IN(?)', $childId); - - foreach ($this->getConnection()->fetchAll($select) as $row) { - $parentIds[] = $row['entity_id']; - } + $parentIds = $this->getConnection()->fetchCol($select); return $parentIds; } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php index 92b7cace509bf..cd9fb419981a1 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php @@ -6,6 +6,9 @@ namespace Magento\ConfigurableProduct\Test\Unit\Helper; +use Magento\Catalog\Model\Product\Image\UrlBuilder; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; + class DataTest extends \PHPUnit\Framework\TestCase { /** @@ -23,12 +26,27 @@ class DataTest extends \PHPUnit\Framework\TestCase */ protected $_productMock; + /** + * @var UrlBuilder|\PHPUnit_Framework_MockObject_MockObject + */ + protected $imageUrlBuilder; + protected function setUp() { + $objectManager = new ObjectManager($this); + $this->imageUrlBuilder = $this->getMockBuilder(UrlBuilder::class) + ->disableOriginalConstructor() + ->getMock(); $this->_imageHelperMock = $this->createMock(\Magento\Catalog\Helper\Image::class); $this->_productMock = $this->createMock(\Magento\Catalog\Model\Product::class); - $this->_model = new \Magento\ConfigurableProduct\Helper\Data($this->_imageHelperMock); + $this->_model = $objectManager->getObject( + \Magento\ConfigurableProduct\Helper\Data::class, + [ + '_imageHelper' => $this->_imageHelperMock + ] + ); + $objectManager->setBackwardCompatibleProperty($this->_model, 'imageUrlBuilder', $this->imageUrlBuilder); } public function testGetAllowAttributes() @@ -196,25 +214,38 @@ public function testGetGalleryImages() ->method('getMediaGalleryImages') ->willReturn($this->getImagesCollection()); - $this->_imageHelperMock->expects($this->exactly(3)) - ->method('init') - ->willReturnMap([ - [$productMock, 'product_page_image_small', [], $this->_imageHelperMock], - [$productMock, 'product_page_image_medium_no_frame', [], $this->_imageHelperMock], - [$productMock, 'product_page_image_large_no_frame', [], $this->_imageHelperMock], - ]) - ->willReturnSelf(); - $this->_imageHelperMock->expects($this->exactly(3)) + $this->imageUrlBuilder->expects($this->exactly(3)) + ->method('getUrl') + ->withConsecutive( + [ + self::identicalTo('test_file'), + self::identicalTo('product_page_image_small') + ], + [ + self::identicalTo('test_file'), + self::identicalTo('product_page_image_medium') + ], + [ + self::identicalTo('test_file'), + self::identicalTo('product_page_image_large') + ] + ) + ->will(self::onConsecutiveCalls( + 'testSmallImageUrl', + 'testMediumImageUrl', + 'testLargeImageUrl' + )); + $this->_imageHelperMock->expects(self::never()) ->method('setImageFile') ->with('test_file') ->willReturnSelf(); - $this->_imageHelperMock->expects($this->at(0)) + $this->_imageHelperMock->expects(self::never()) ->method('getUrl') ->willReturn('product_page_image_small_url'); - $this->_imageHelperMock->expects($this->at(1)) + $this->_imageHelperMock->expects(self::never()) ->method('getUrl') ->willReturn('product_page_image_medium_url'); - $this->_imageHelperMock->expects($this->at(2)) + $this->_imageHelperMock->expects(self::never()) ->method('getUrl') ->willReturn('product_page_image_large_url'); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php index 8f3f3979b8669..7ea55c51a5bb3 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php @@ -357,7 +357,8 @@ public function testGetListNotConfigurableProduct() */ public function testValidateNewOptionData($attributeId, $label, $optionValues, $msg) { - $this->expectException(\Magento\Framework\Exception\InputException::class, $msg); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage($msg); $optionValueMock = $this->getMockBuilder(\Magento\ConfigurableProduct\Api\Data\OptionValueInterface::class) ->setMethods(['getValueIndex', 'getPricingValue', 'getIsPercent']) ->getMockForAbstractClass(); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php new file mode 100644 index 0000000000000..d29f163ee1129 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php @@ -0,0 +1,77 @@ +configurableTypeMock = $this->getMockBuilder(Configurable::class) + ->disableOriginalConstructor() + ->getMock(); + $this->productRepositoryMock = $this->getMockBuilder(ProductRepositoryInterface::class) + ->getMock(); + + $this->plugin = new ProductIdentitiesExtender($this->configurableTypeMock, $this->productRepositoryMock); + } + + public function testAfterGetIdentities() + { + $productId = 1; + $productIdentity = 'cache_tag_1'; + $productMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + $parentProductId = 2; + $parentProductIdentity = 'cache_tag_2'; + $parentProductMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + + $productMock->expects($this->once()) + ->method('getId') + ->willReturn($productId); + $this->configurableTypeMock->expects($this->once()) + ->method('getParentIdsByChild') + ->with($productId) + ->willReturn([$parentProductId]); + $this->productRepositoryMock->expects($this->once()) + ->method('getById') + ->with($parentProductId) + ->willReturn($parentProductMock); + $parentProductMock->expects($this->once()) + ->method('getIdentities') + ->willReturn([$parentProductIdentity]); + + $productIdentities = $this->plugin->afterGetIdentities($productMock, [$productIdentity]); + $this->assertEquals([$productIdentity, $parentProductIdentity], $productIdentities); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php deleted file mode 100644 index 5b4a8d5b8a975..0000000000000 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php +++ /dev/null @@ -1,64 +0,0 @@ -typeResource = $this->createMock( - \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable::class - ); - - $this->model = new Configurable($this->typeResource); - } - - public function testGetWithScalar() - { - $this->expectException(\InvalidArgumentException::class, 'Provided argument is not an object'); - $this->model->getTags('scalar'); - } - - public function testGetTagsWithObject() - { - $this->expectException(\InvalidArgumentException::class, 'Provided argument must be a product'); - $this->model->getTags(new \stdClass()); - } - - public function testGetTagsWithVariation() - { - $product = $this->createMock(\Magento\Catalog\Model\Product::class); - - $identities = ['id1', 'id2']; - - $product->expects($this->once()) - ->method('getIdentities') - ->willReturn($identities); - - $parentId = 4; - $this->typeResource->expects($this->once()) - ->method('getParentIdsByChild') - ->willReturn([$parentId]); - - $expected = array_merge($identities, [\Magento\Catalog\Model\Product::CACHE_TAG . '_' . $parentId]); - - $this->assertEquals($expected, $this->model->getTags($product)); - } -} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php index 6fda5b867ccef..851595422f596 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php @@ -88,6 +88,7 @@ public function testExecuteWithInvalidProductType() public function testExecuteWithEmptyExtensionAttributes() { $sku = 'test'; + $configurableProductLinks = [1, 2, 3]; $product = $this->getMockBuilder(Product::class) ->disableOriginalConstructor() ->setMethods(['getTypeId', 'getExtensionAttributes', 'getSku']) @@ -105,16 +106,16 @@ public function testExecuteWithEmptyExtensionAttributes() ->disableOriginalConstructor() ->getMockForAbstractClass(); - $product->expects(static::once()) + $product->expects(static::atLeastOnce()) ->method('getExtensionAttributes') ->willReturn($extensionAttributes); - $extensionAttributes->expects(static::exactly(2)) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductOptions') ->willReturn([]); - $extensionAttributes->expects(static::once()) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductLinks') - ->willReturn([]); + ->willReturn($configurableProductLinks); $this->optionRepository->expects(static::once()) ->method('getList') @@ -133,7 +134,10 @@ public function testExecuteWithEmptyExtensionAttributes() public function testExecute() { $sku = 'config-1'; - $id = 25; + $idOld = 25; + $idNew = 26; + $attributeIdOld = 11; + $attributeIdNew = 22; $configurableProductLinks = [1, 2, 3]; $product = $this->getMockBuilder(Product::class) @@ -143,7 +147,7 @@ public function testExecute() $product->expects(static::once()) ->method('getTypeId') ->willReturn(ConfigurableModel::TYPE_CODE); - $product->expects(static::exactly(3)) + $product->expects(static::exactly(4)) ->method('getSku') ->willReturn($sku); @@ -156,30 +160,36 @@ public function testExecute() ->method('getExtensionAttributes') ->willReturn($extensionAttributes); - $attribute = $this->getMockBuilder(Attribute::class) + $attributeNew = $this->getMockBuilder(Attribute::class) ->disableOriginalConstructor() ->setMethods(['getAttributeId', 'loadByProductAndAttribute', 'setId', 'getId']) ->getMock(); - $this->processSaveOptions($attribute, $sku, $id); - - $option = $this->getMockForAbstractClass(OptionInterface::class); - $option->expects(static::once()) + $attributeNew->expects(static::atLeastOnce()) + ->method('getAttributeId') + ->willReturn($attributeIdNew); + $this->processSaveOptions($attributeNew, $sku, $idNew); + + $optionOld = $this->getMockForAbstractClass(OptionInterface::class); + $optionOld->expects(static::atLeastOnce()) + ->method('getAttributeId') + ->willReturn($attributeIdOld); + $optionOld->expects(static::atLeastOnce()) ->method('getId') - ->willReturn($id); + ->willReturn($idOld); - $list = [$option]; - $this->optionRepository->expects(static::once()) + $list = [$optionOld]; + $this->optionRepository->expects(static::atLeastOnce()) ->method('getList') ->with($sku) ->willReturn($list); $this->optionRepository->expects(static::once()) ->method('deleteById') - ->with($sku, $id); + ->with($sku, $idOld); $configurableAttributes = [ - $attribute + $attributeNew ]; - $extensionAttributes->expects(static::exactly(2)) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductOptions') ->willReturn($configurableAttributes); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CustomOptionsTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CustomOptionsTest.php index e2e9fe9b2b1f9..659f7346faba4 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CustomOptionsTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CustomOptionsTest.php @@ -10,6 +10,14 @@ class CustomOptionsTest extends AbstractModifierTest { + protected function setUp() + { + parent::setUp(); + $this->arrayManagerMock->expects($this->any()) + ->method('merge') + ->willReturnArgument(1); + } + /** * {@inheritdoc} */ diff --git a/app/code/Magento/ConfigurableProduct/composer.json b/app/code/Magento/ConfigurableProduct/composer.json index 4730c03af0e6b..959c036981878 100644 --- a/app/code/Magento/ConfigurableProduct/composer.json +++ b/app/code/Magento/ConfigurableProduct/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -23,8 +23,8 @@ "magento/module-webapi": "*", "magento/module-sales": "*", "magento/module-product-video": "*", - "magento/module-configurable-sample-data": "Sample Data version:100.3.*", - "magento/module-product-links-sample-data": "Sample Data version:100.3.*" + "magento/module-configurable-sample-data": "*", + "magento/module-product-links-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/ConfigurableProduct/etc/di.xml b/app/code/Magento/ConfigurableProduct/etc/di.xml index 3f04081eaf645..15dbc53a5447a 100644 --- a/app/code/Magento/ConfigurableProduct/etc/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/di.xml @@ -168,13 +168,6 @@ - - - - \Magento\ConfigurableProduct\Model\Product\Cache\Tag\Configurable - - - @@ -209,4 +202,7 @@ + + + diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml index d70576d975ac3..0e0eb3464eaa6 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml @@ -89,7 +89,7 @@ showLoader: true, context: $form }) - .success(function (data) { + .done(function (data) { if (!data.error) { setAttributeSetId(data.id); closeDialogAndProcessForm($form); diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js index d8f902f0dbfc6..63361baab809f 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js @@ -95,7 +95,7 @@ define([ type: 'GET', url: this._buildGridUrl(filterData), context: $('body') - }).success(function (data) { + }).done(function (data) { bootstrap(JSON.parse(data)); }); }, diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js index 537482994fd38..9aa67beb3a51d 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js @@ -492,7 +492,7 @@ define([ dataType: 'json', showLoader: true, context: this - }).success(function (data) { + }).done(function (data) { if (!data.error) { this.set( 'skeletonAttributeSet', @@ -507,7 +507,7 @@ define([ } return false; - }).error(function (xhr) { + }).fail(function (xhr) { if (xhr.statusText === 'abort') { return; } diff --git a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml index 98abb906f69d6..f020e4c2c9495 100644 --- a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml +++ b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml @@ -19,16 +19,20 @@ $finalPriceModel = $block->getPriceType('final_price'); $idSuffix = $block->getIdSuffix() ? $block->getIdSuffix() : ''; $schema = ($block->getZone() == 'item_view') ? true : false; ?> + + __('As low as'), + 'price_id' => $block->getPriceId('product-price-' . $idSuffix), + 'price_type' => 'finalPrice', + 'include_container' => true, + 'schema' => $schema, + ]; + /* @noEscape */ echo $block->renderAmount($finalPriceModel->getAmount(), $arguments); + ?> + + isProductList() && $block->hasSpecialPrice()): ?> - - renderAmount($finalPriceModel->getAmount(), [ - 'display_label' => __('Special Price'), - 'price_id' => $block->getPriceId('product-price-' . $idSuffix), - 'price_type' => 'finalPrice', - 'include_container' => true, - 'schema' => $schema - ]); ?> - renderAmount($priceModel->getAmount(), [ 'display_label' => __('Regular Price'), @@ -38,13 +42,6 @@ $schema = ($block->getZone() == 'item_view') ? true : false; 'skip_adjustments' => true ]); ?> - - renderAmount($finalPriceModel->getAmount(), [ - 'price_id' => $block->getPriceId('product-price-' . $idSuffix), - 'price_type' => 'finalPrice', - 'include_container' => true, - 'schema' => $schema - ]); ?> showMinimalPrice()): ?> diff --git a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js index 545887d04c965..8cabe71c17504 100644 --- a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js +++ b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js @@ -32,6 +32,7 @@ define([ mediaGallerySelector: '[data-gallery-role=gallery-placeholder]', mediaGalleryInitial: null, slyOldPriceSelector: '.sly-old-price', + normalPriceLabelSelector: '.normal-price .price-label', /** * Defines the mechanism of how images of a gallery should be @@ -269,6 +270,7 @@ define([ this._reloadPrice(); this._displayRegularPriceBlock(this.simpleProduct); this._displayTierPriceBlock(this.simpleProduct); + this._displayNormalPriceLabel(); this._changeProductImage(); }, @@ -527,8 +529,16 @@ define([ * @private */ _displayRegularPriceBlock: function (optionId) { - if (typeof optionId != 'undefined' && - this.options.spConfig.optionPrices[optionId].oldPrice.amount != //eslint-disable-line eqeqeq + var shouldBeShown = true; + + _.each(this.options.settings, function (element) { + if (element.value === '') { + shouldBeShown = false; + } + }); + + if (shouldBeShown && + this.options.spConfig.optionPrices[optionId].oldPrice.amount !== this.options.spConfig.optionPrices[optionId].finalPrice.amount ) { $(this.options.slyOldPriceSelector).show(); @@ -537,6 +547,27 @@ define([ } }, + /** + * Show or hide normal price label + * + * @private + */ + _displayNormalPriceLabel: function () { + var shouldBeShown = false; + + _.each(this.options.settings, function (element) { + if (element.value === '') { + shouldBeShown = true; + } + }); + + if (shouldBeShown) { + $(this.options.normalPriceLabelSelector).show(); + } else { + $(this.options.normalPriceLabelSelector).hide(); + } + }, + /** * Callback which fired after gallery gets initialized. * diff --git a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js index a355a3f3ac7c7..558a1fdf31085 100644 --- a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js +++ b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js @@ -7,10 +7,12 @@ define([ var selectors = { formSelector: '#product_addtocart_form', - productIdSelector: '#product_addtocart_form [name="product"]' + productIdSelector: '#product_addtocart_form [name="product"]', + itemIdSelector: '#product_addtocart_form [name="item"]' }, cartData = customerData.get('cart'), productId = $(selectors.productIdSelector).val(), + itemId = $(selectors.itemIdSelector).val(), /** * set productOptions according to cart data from customer-data @@ -25,7 +27,9 @@ define([ return false; } changedProductOptions = _.find(data.items, function (item) { - return item['product_id'] === productId; + if (item['item_id'] === itemId) { + return item['product_id'] === productId; + } }); changedProductOptions = changedProductOptions && changedProductOptions.options && changedProductOptions.options.reduce(function (obj, val) { diff --git a/app/code/Magento/ConfigurableProductGraphQl/composer.json b/app/code/Magento/ConfigurableProductGraphQl/composer.json index 8ce42fc2f08cc..a22df27734b76 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/composer.json +++ b/app/code/Magento/ConfigurableProductGraphQl/composer.json @@ -3,7 +3,7 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-catalog": "*", "magento/module-configurable-product": "*", "magento/module-catalog-graph-ql": "*", diff --git a/app/code/Magento/ConfigurableProductSales/composer.json b/app/code/Magento/ConfigurableProductSales/composer.json index 1f2b4e86eee65..2a106b3abc75a 100644 --- a/app/code/Magento/ConfigurableProductSales/composer.json +++ b/app/code/Magento/ConfigurableProductSales/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-sales": "*", diff --git a/app/code/Magento/Contact/composer.json b/app/code/Magento/Contact/composer.json index d0c59f1eda7f1..b45132d0a360b 100644 --- a/app/code/Magento/Contact/composer.json +++ b/app/code/Magento/Contact/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-cms": "*", "magento/module-config": "*", diff --git a/app/code/Magento/Cookie/composer.json b/app/code/Magento/Cookie/composer.json index 468f717e0c81b..58f28ad472120 100644 --- a/app/code/Magento/Cookie/composer.json +++ b/app/code/Magento/Cookie/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-store": "*" }, diff --git a/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php b/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php index b98a456a511f1..ed5e46d7a60f7 100644 --- a/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php +++ b/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php @@ -290,8 +290,15 @@ protected function _runJob($scheduledTime, $currentTime, $jobConfig, $schedule, try { call_user_func_array($callback, [$schedule]); - } catch (\Exception $e) { + } catch (\Throwable $e) { $schedule->setStatus(Schedule::STATUS_ERROR); + if (!$e instanceof \Exception) { + $e = new \RuntimeException( + 'Error when running a cron job', + 0, + $e + ); + } throw $e; } diff --git a/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php b/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php index c50afa0e6f0d1..6954fe49fdc43 100644 --- a/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php +++ b/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php @@ -12,8 +12,27 @@ class CronJobException { + /** + * @var \Throwable|null + */ + private $exception; + + /** + * @param \Throwable|null $exception + */ + public function __construct(\Throwable $exception = null) + { + $this->exception = $exception; + } + + /** + * @throws \Throwable + */ public function execute() { - throw new \Exception('Test exception'); + if (!$this->exception) { + $this->exception = new \Exception('Test exception'); + } + throw $this->exception; } } diff --git a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php index 0db6a598fb56f..d8cb79af52138 100644 --- a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php +++ b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php @@ -468,6 +468,8 @@ public function testDispatchExceptionInCallback( */ public function dispatchExceptionInCallbackDataProvider() { + $throwable = new \TypeError(); + return [ 'non-callable callback' => [ 'Not_Existed_Class', @@ -483,6 +485,19 @@ public function dispatchExceptionInCallbackDataProvider() 2, new \Exception(__('Test exception')) ], + 'throwable in execution' => [ + 'CronJobException', + new \Magento\Cron\Test\Unit\Model\CronJobException( + $throwable + ), + 'Error when running a cron job', + 2, + new \RuntimeException( + 'Error when running a cron job', + 0, + $throwable + ), + ], ]; } diff --git a/app/code/Magento/Cron/composer.json b/app/code/Magento/Cron/composer.json index ee2c7a858cc6b..5595bf1cb55f5 100644 --- a/app/code/Magento/Cron/composer.json +++ b/app/code/Magento/Cron/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-store": "*" }, diff --git a/app/code/Magento/CurrencySymbol/composer.json b/app/code/Magento/CurrencySymbol/composer.json index 7ea5d049cb226..009cb62488916 100644 --- a/app/code/Magento/CurrencySymbol/composer.json +++ b/app/code/Magento/CurrencySymbol/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-config": "*", diff --git a/app/code/Magento/CurrencySymbol/view/adminhtml/templates/grid.phtml b/app/code/Magento/CurrencySymbol/view/adminhtml/templates/grid.phtml index 7dca29263bae3..0ba3c7ed2d7d6 100644 --- a/app/code/Magento/CurrencySymbol/view/adminhtml/templates/grid.phtml +++ b/app/code/Magento/CurrencySymbol/view/adminhtml/templates/grid.phtml @@ -13,8 +13,6 @@ */ ?> -getCurrencySymbolsData();?> -
    diff --git a/app/code/Magento/Customer/Api/AccountDelegationInterface.php b/app/code/Magento/Customer/Api/AccountDelegationInterface.php new file mode 100644 index 0000000000000..e3a738530c49d --- /dev/null +++ b/app/code/Magento/Customer/Api/AccountDelegationInterface.php @@ -0,0 +1,31 @@ +getSortOrder() < $secondLink->getSortOrder()); + if ($firstLink->getSortOrder() == $secondLink->getSortOrder()) { + return 0; + } + + return ($firstLink->getSortOrder() < $secondLink->getSortOrder()) ? 1 : -1; } } diff --git a/app/code/Magento/Customer/Block/Widget/Dob.php b/app/code/Magento/Customer/Block/Widget/Dob.php index e456efbc605fa..1a1d5d81bf13d 100644 --- a/app/code/Magento/Customer/Block/Widget/Dob.php +++ b/app/code/Magento/Customer/Block/Widget/Dob.php @@ -127,7 +127,8 @@ protected function getFormFilter() protected function applyOutputFilter($value) { $filter = $this->getFormFilter(); - if ($filter) { + if ($filter && $value) { + $value = date('Y-m-d', $this->getTime()); $value = $filter->outputFilter($value); } return $value; diff --git a/app/code/Magento/Customer/Controller/Section/Load.php b/app/code/Magento/Customer/Controller/Section/Load.php index 6e73e070c790d..7a2345c91750c 100644 --- a/app/code/Magento/Customer/Controller/Section/Load.php +++ b/app/code/Magento/Customer/Controller/Section/Load.php @@ -64,8 +64,8 @@ public function execute() { /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); - $resultJson->setHeader('Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store'); - $resultJson->setHeader('Pragma', 'no-cache'); + $resultJson->setHeader('Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store', true); + $resultJson->setHeader('Pragma', 'no-cache', true); try { $sectionNames = $this->getRequest()->getParam('sections'); $sectionNames = $sectionNames ? array_unique(\explode(',', $sectionNames)) : null; diff --git a/app/code/Magento/Customer/Model/AccountManagement.php b/app/code/Magento/Customer/Model/AccountManagement.php index 7d0b271b9b137..63eb1efa64be6 100644 --- a/app/code/Magento/Customer/Model/AccountManagement.php +++ b/app/code/Magento/Customer/Model/AccountManagement.php @@ -624,6 +624,7 @@ public function resetPassword($email, $resetToken, $newPassword) $customerSecure->setRpToken(null); $customerSecure->setRpTokenCreatedAt(null); $customerSecure->setPasswordHash($this->createPasswordHash($newPassword)); + $this->getAuthentication()->unlock($customer->getId()); $this->sessionManager->destroy(); $this->destroyCustomerSessions($customer->getId()); $this->customerRepository->save($customer); @@ -873,6 +874,8 @@ protected function sendEmailConfirmation(CustomerInterface $customer, $redirectU } catch (MailException $e) { // If we are not able to send a new account email, this should be ignored $this->logger->critical($e); + } catch (\UnexpectedValueException $e) { + $this->logger->error($e); } } diff --git a/app/code/Magento/Customer/Model/Address/Validator/Country.php b/app/code/Magento/Customer/Model/Address/Validator/Country.php index 0ba8a21ff8cd9..ff1020eba70ef 100644 --- a/app/code/Magento/Customer/Model/Address/Validator/Country.php +++ b/app/code/Magento/Customer/Model/Address/Validator/Country.php @@ -7,6 +7,8 @@ use Magento\Customer\Model\Address\AbstractAddress; use Magento\Customer\Model\Address\ValidatorInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Store\Model\ScopeInterface; /** * Address country and region validator. @@ -18,13 +20,31 @@ class Country implements ValidatorInterface */ private $directoryData; + /** + * @var \Magento\Directory\Model\AllowedCountries + */ + private $allowedCountriesReader; + + /** + * @var \Magento\Customer\Model\Config\Share + */ + private $shareConfig; + /** * @param \Magento\Directory\Helper\Data $directoryData + * @param \Magento\Directory\Model\AllowedCountries|null $allowedCountriesReader + * @param \Magento\Customer\Model\Config\Share|null $shareConfig */ public function __construct( - \Magento\Directory\Helper\Data $directoryData + \Magento\Directory\Helper\Data $directoryData, + \Magento\Directory\Model\AllowedCountries $allowedCountriesReader = null, + \Magento\Customer\Model\Config\Share $shareConfig = null ) { $this->directoryData = $directoryData; + $this->allowedCountriesReader = $allowedCountriesReader + ?: ObjectManager::getInstance()->get(\Magento\Directory\Model\AllowedCountries::class); + $this->shareConfig = $shareConfig + ?: ObjectManager::getInstance()->get(\Magento\Customer\Model\Config\Share::class); } /** @@ -52,7 +72,7 @@ private function validateCountry(AbstractAddress $address) $errors = []; if (!\Zend_Validate::is($countryId, 'NotEmpty')) { $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'countryId']); - } elseif (!in_array($countryId, $this->directoryData->getCountryCollection()->getAllIds(), true)) { + } elseif (!in_array($countryId, $this->getWebsiteAllowedCountries($address), true)) { //Checking if such country exists. $errors[] = __( 'Invalid value of "%value" provided for the %fieldName field.', @@ -97,4 +117,21 @@ private function validateRegion(AbstractAddress $address) return $errors; } + + /** + * Return allowed counties per website. + * + * @param AbstractAddress $address + * @return array + */ + private function getWebsiteAllowedCountries(AbstractAddress $address): array + { + $websiteId = null; + + if (!$this->shareConfig->isGlobalScope()) { + $websiteId = $address->getCustomer() ? $address->getCustomer()->getWebsiteId() : null; + } + + return $this->allowedCountriesReader->getAllowedCountries(ScopeInterface::SCOPE_WEBSITE, $websiteId); + } } diff --git a/app/code/Magento/Customer/Model/Customer/NotificationStorage.php b/app/code/Magento/Customer/Model/Customer/NotificationStorage.php index 7054324851f34..11e0b9b916559 100644 --- a/app/code/Magento/Customer/Model/Customer/NotificationStorage.php +++ b/app/code/Magento/Customer/Model/Customer/NotificationStorage.php @@ -5,6 +5,7 @@ */ namespace Magento\Customer\Model\Customer; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Cache\FrontendInterface; use Magento\Framework\Serialize\SerializerInterface; @@ -18,21 +19,21 @@ class NotificationStorage private $cache; /** - * @param FrontendInterface $cache - */ - - /** - * @param FrontendInterface $cache + * @var SerializerInterface */ private $serializer; /** * NotificationStorage constructor. * @param FrontendInterface $cache + * @param SerializerInterface $serializer */ - public function __construct(FrontendInterface $cache) - { + public function __construct( + FrontendInterface $cache, + SerializerInterface $serializer = null + ) { $this->cache = $cache; + $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); } /** @@ -45,7 +46,7 @@ public function __construct(FrontendInterface $cache) public function add($notificationType, $customerId) { $this->cache->save( - $this->getSerializer()->serialize([ + $this->serializer->serialize([ 'customer_id' => $customerId, 'notification_type' => $notificationType ]), @@ -88,19 +89,4 @@ private function getCacheKey($notificationType, $customerId) { return 'notification_' . $notificationType . '_' . $customerId; } - - /** - * Get serializer - * - * @return SerializerInterface - * @deprecated 100.2.0 - */ - private function getSerializer() - { - if ($this->serializer === null) { - $this->serializer = \Magento\Framework\App\ObjectManager::getInstance() - ->get(SerializerInterface::class); - } - return $this->serializer; - } } diff --git a/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php b/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php new file mode 100644 index 0000000000000..85c67213c4613 --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php @@ -0,0 +1,53 @@ +redirectFactory = $redirectFactory; + $this->storage = $storage; + } + + /** + * {@inheritdoc} + */ + public function createRedirectForNew( + CustomerInterface $customer, + array $mixedData = null + ): Redirect { + $this->storage->storeNewOperation($customer, $mixedData); + + return $this->redirectFactory->create()->setPath('customer/account/create'); + } +} diff --git a/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php b/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php new file mode 100644 index 0000000000000..5dcefc2326794 --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php @@ -0,0 +1,54 @@ +customer = $customer; + $this->additionalData = $additionalData; + } + + /** + * @return CustomerInterface + */ + public function getCustomer(): CustomerInterface + { + return $this->customer; + } + + /** + * @return array + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } +} diff --git a/app/code/Magento/Customer/Model/Delegation/Storage.php b/app/code/Magento/Customer/Model/Delegation/Storage.php new file mode 100644 index 0000000000000..71a61d59057cb --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/Storage.php @@ -0,0 +1,151 @@ +newFactory = $newFactory; + $this->customerFactory = $customerFactory; + $this->addressFactory = $addressFactory; + $this->regionFactory = $regionFactory; + $this->logger = $logger; + $this->session = $session; + } + + /** + * Store data for new account operation. + * + * @param CustomerInterface $customer + * @param array $delegatedData + * + * @return void + */ + public function storeNewOperation(CustomerInterface $customer, array $delegatedData): void + { + /** @var Customer $customer */ + $customerData = $customer->__toArray(); + $addressesData = []; + if ($customer->getAddresses()) { + /** @var Address $address */ + foreach ($customer->getAddresses() as $address) { + $addressesData[] = $address->__toArray(); + } + } + $this->session->setCustomerFormData($customerData); + $this->session->setDelegatedNewCustomerData([ + 'customer' => $customerData, + 'addresses' => $addressesData, + 'delegated_data' => $delegatedData, + ]); + } + + /** + * Retrieve delegated new operation data and mark it as used. + * + * @return NewOperation|null + */ + public function consumeNewOperation() + { + try { + $serialized = $this->session->getDelegatedNewCustomerData(true); + } catch (\Throwable $exception) { + $this->logger->error($exception); + $serialized = null; + } + if ($serialized === null) { + return null; + } + + /** @var AddressInterface[] $addresses */ + $addresses = []; + foreach ($serialized['addresses'] as $addressData) { + if (isset($addressData['region'])) { + /** @var RegionInterface $region */ + $region = $this->regionFactory->create( + ['data' => $addressData['region']] + ); + $addressData['region'] = $region; + } + $addresses[] = $this->addressFactory->create( + ['data' => $addressData] + ); + } + $customerData = $serialized['customer']; + $customerData['addresses'] = $addresses; + + return $this->newFactory->create([ + 'customer' => $this->customerFactory->create( + ['data' => $customerData] + ), + 'additionalData' => $serialized['delegated_data'], + ]); + } +} diff --git a/app/code/Magento/Customer/Model/EmailNotification.php b/app/code/Magento/Customer/Model/EmailNotification.php index 53c16a4b37093..4b65dcca0973f 100644 --- a/app/code/Magento/Customer/Model/EmailNotification.php +++ b/app/code/Magento/Customer/Model/EmailNotification.php @@ -7,6 +7,8 @@ namespace Magento\Customer\Model; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Mail\Template\SenderResolverInterface; use Magento\Store\Model\StoreManagerInterface; use Magento\Framework\Mail\Template\TransportBuilder; use Magento\Customer\Helper\View as CustomerViewHelper; @@ -91,6 +93,11 @@ class EmailNotification implements EmailNotificationInterface */ private $scopeConfig; + /** + * @var SenderResolverInterface + */ + private $senderResolver; + /** * @param CustomerRegistry $customerRegistry * @param StoreManagerInterface $storeManager @@ -98,6 +105,7 @@ class EmailNotification implements EmailNotificationInterface * @param CustomerViewHelper $customerViewHelper * @param DataObjectProcessor $dataProcessor * @param ScopeConfigInterface $scopeConfig + * @param SenderResolverInterface|null $senderResolver */ public function __construct( CustomerRegistry $customerRegistry, @@ -105,7 +113,8 @@ public function __construct( TransportBuilder $transportBuilder, CustomerViewHelper $customerViewHelper, DataObjectProcessor $dataProcessor, - ScopeConfigInterface $scopeConfig + ScopeConfigInterface $scopeConfig, + SenderResolverInterface $senderResolver = null ) { $this->customerRegistry = $customerRegistry; $this->storeManager = $storeManager; @@ -113,6 +122,7 @@ public function __construct( $this->customerViewHelper = $customerViewHelper; $this->dataProcessor = $dataProcessor; $this->scopeConfig = $scopeConfig; + $this->senderResolver = $senderResolver ?: ObjectManager::getInstance()->get(SenderResolverInterface::class); } /** @@ -231,6 +241,7 @@ private function passwordReset(CustomerInterface $customer) * @param int|null $storeId * @param string $email * @return void + * @throws \Magento\Framework\Exception\MailException */ private function sendEmailTemplate( $customer, @@ -244,10 +255,17 @@ private function sendEmailTemplate( if ($email === null) { $email = $customer->getEmail(); } + + /** @var array $from */ + $from = $this->senderResolver->resolve( + $this->scopeConfig->getValue($sender, 'store', $storeId), + $storeId + ); + $transport = $this->transportBuilder->setTemplateIdentifier($templateId) ->setTemplateOptions(['area' => 'frontend', 'store' => $storeId]) ->setTemplateVars($templateParams) - ->setFrom($this->scopeConfig->getValue($sender, 'store', $storeId)) + ->setFrom($from) ->addTo($email, $this->customerViewHelper->getCustomerName($customer)) ->getTransport(); @@ -296,9 +314,9 @@ private function getWebsiteStoreId($customer, $defaultStoreId = null) */ public function passwordReminder(CustomerInterface $customer) { - $storeId = $this->getWebsiteStoreId($customer); + $storeId = $customer->getStoreId(); if (!$storeId) { - $storeId = $this->storeManager->getStore()->getId(); + $storeId = $this->getWebsiteStoreId($customer); } $customerEmailData = $this->getFullCustomerObject($customer); diff --git a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php index 91a593c347806..29e35c721a3be 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php @@ -7,16 +7,20 @@ namespace Magento\Customer\Model\ResourceModel; use Magento\Customer\Api\CustomerMetadataInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\Delegation\Data\NewOperation; use Magento\Customer\Model\Customer\NotificationStorage; use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\Api\ImageProcessorInterface; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Customer\Model\Delegation\Storage as DelegatedStorage; use Magento\Framework\App\ObjectManager; /** * Customer repository. * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) */ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInterface { @@ -95,6 +99,11 @@ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInte */ private $notificationStorage; + /** + * @var DelegatedStorage + */ + private $delegatedStorage; + /** * @param \Magento\Customer\Model\CustomerFactory $customerFactory * @param \Magento\Customer\Model\Data\CustomerSecureFactory $customerSecureFactory @@ -111,6 +120,7 @@ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInte * @param \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor * @param CollectionProcessorInterface $collectionProcessor * @param NotificationStorage $notificationStorage + * @param DelegatedStorage|null $delegatedStorage * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -128,7 +138,8 @@ public function __construct( ImageProcessorInterface $imageProcessor, \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor, CollectionProcessorInterface $collectionProcessor, - NotificationStorage $notificationStorage + NotificationStorage $notificationStorage, + DelegatedStorage $delegatedStorage = null ) { $this->customerFactory = $customerFactory; $this->customerSecureFactory = $customerSecureFactory; @@ -145,6 +156,8 @@ public function __construct( $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor; $this->collectionProcessor = $collectionProcessor; $this->notificationStorage = $notificationStorage; + $this->delegatedStorage = $delegatedStorage + ?? ObjectManager::getInstance()->get(DelegatedStorage::class); } /** @@ -152,8 +165,10 @@ public function __construct( * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ - public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $passwordHash = null) + public function save(CustomerInterface $customer, $passwordHash = null) { + /** @var NewOperation|null $delegatedNewOperation */ + $delegatedNewOperation = !$customer->getId() ? $this->delegatedStorage->consumeNewOperation() : null; $prevCustomerData = null; $prevCustomerDataArr = null; if ($customer->getId()) { @@ -167,56 +182,49 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER, $prevCustomerData ); - $origAddresses = $customer->getAddresses(); $customer->setAddresses([]); - $customerData = $this->extensibleDataObjectConverter->toNestedArray( - $customer, - [], - \Magento\Customer\Api\Data\CustomerInterface::class - ); - + $customerData = $this->extensibleDataObjectConverter->toNestedArray($customer, [], CustomerInterface::class); $customer->setAddresses($origAddresses); + /** @var Customer $customerModel */ $customerModel = $this->customerFactory->create(['data' => $customerData]); + //Model's actual ID field maybe different than "id" so "id" field from $customerData may be ignored. + $customerModel->setId($customer->getId()); $storeId = $customerModel->getStoreId(); if ($storeId === null) { $customerModel->setStoreId($this->storeManager->getStore()->getId()); } - $customerModel->setId($customer->getId()); - // Need to use attribute set or future updates can cause data loss if (!$customerModel->getAttributeSetId()) { - $customerModel->setAttributeSetId( - \Magento\Customer\Api\CustomerMetadataInterface::ATTRIBUTE_SET_ID_CUSTOMER - ); + $customerModel->setAttributeSetId(CustomerMetadataInterface::ATTRIBUTE_SET_ID_CUSTOMER); } $this->populateCustomerWithSecureData($customerModel, $passwordHash); - // If customer email was changed, reset RpToken info - if ($prevCustomerData - && $prevCustomerData->getEmail() !== $customerModel->getEmail() - ) { + if ($prevCustomerData && $prevCustomerData->getEmail() !== $customerModel->getEmail()) { $customerModel->setRpToken(null); $customerModel->setRpTokenCreatedAt(null); } - if (!array_key_exists('default_billing', $customerArr) && - null !== $prevCustomerDataArr && - array_key_exists('default_billing', $prevCustomerDataArr) + if (!array_key_exists('default_billing', $customerArr) + && null !== $prevCustomerDataArr + && array_key_exists('default_billing', $prevCustomerDataArr) ) { $customerModel->setDefaultBilling($prevCustomerDataArr['default_billing']); } - - if (!array_key_exists('default_shipping', $customerArr) && - null !== $prevCustomerDataArr && - array_key_exists('default_shipping', $prevCustomerDataArr) + if (!array_key_exists('default_shipping', $customerArr) + && null !== $prevCustomerDataArr + && array_key_exists('default_shipping', $prevCustomerDataArr) ) { $customerModel->setDefaultShipping($prevCustomerDataArr['default_shipping']); } - $customerModel->save(); $this->customerRegistry->push($customerModel); $customerId = $customerModel->getId(); - + if (!$customer->getAddresses() + && $delegatedNewOperation + && $delegatedNewOperation->getCustomer()->getAddresses() + ) { + $customer->setAddresses($delegatedNewOperation->getCustomer()->getAddresses()); + } if ($customer->getAddresses() !== null) { if ($customer->getId()) { $existingAddresses = $this->getById($customer->getId())->getAddresses(); @@ -227,7 +235,6 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa } else { $existingAddressIds = []; } - $savedAddressIds = []; foreach ($customer->getAddresses() as $address) { $address->setCustomerId($customerId) @@ -237,7 +244,6 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $savedAddressIds[] = $address->getId(); } } - $addressIdsToDelete = array_diff($existingAddressIds, $savedAddressIds); foreach ($addressIdsToDelete as $addressId) { $this->addressRepository->deleteById($addressId); @@ -247,8 +253,13 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $savedCustomer = $this->get($customer->getEmail(), $customer->getWebsiteId()); $this->eventManager->dispatch( 'customer_save_after_data_object', - ['customer_data_object' => $savedCustomer, 'orig_customer_data_object' => $prevCustomerData] + [ + 'customer_data_object' => $savedCustomer, + 'orig_customer_data_object' => $prevCustomerData, + 'delegate_data' => $delegatedNewOperation ? $delegatedNewOperation->getAdditionalData() : [], + ] ); + return $savedCustomer; } @@ -310,7 +321,7 @@ public function getList(SearchCriteriaInterface $searchCriteria) $collection = $this->customerFactory->create()->getCollection(); $this->extensionAttributesJoinProcessor->process( $collection, - \Magento\Customer\Api\Data\CustomerInterface::class + CustomerInterface::class ); // This is needed to make sure all the attributes are properly loaded foreach ($this->customerMetadata->getAllAttributesMetadata() as $metadata) { @@ -342,7 +353,7 @@ public function getList(SearchCriteriaInterface $searchCriteria) /** * {@inheritdoc} */ - public function delete(\Magento\Customer\Api\Data\CustomerInterface $customer) + public function delete(CustomerInterface $customer) { return $this->deleteById($customer->getId()); } diff --git a/app/code/Magento/Customer/Model/Session.php b/app/code/Magento/Customer/Model/Session.php index 71b0297fdd114..680e68b5c4c0f 100644 --- a/app/code/Magento/Customer/Model/Session.php +++ b/app/code/Magento/Customer/Model/Session.php @@ -555,7 +555,7 @@ public function setAfterAuthUrl($url) } /** - * Reset core session hosts after reseting session ID + * Reset core session hosts after resetting session ID * * @return $this */ diff --git a/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php index cf55928a6d30f..9e3a16a307923 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php @@ -757,19 +757,15 @@ public function testCreateAccountWithPasswordInputException( ->willReturn(iconv_strlen($password, 'UTF-8')); if ($testNumber == 1) { - $this->expectException( - \Magento\Framework\Exception\InputException::class, - 'The password needs at least ' . $minPasswordLength . ' characters. ' - . 'Create a new password and try again.' - ); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage('The password needs at least ' . $minPasswordLength . ' characters. ' + . 'Create a new password and try again.'); } if ($testNumber == 2) { - $this->expectException( - \Magento\Framework\Exception\InputException::class, - 'Minimum of different classes of characters in password is ' . $minCharacterSetsNum . - '. Classes of characters: Lower Case, Upper Case, Digits, Special Characters.' - ); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage('Minimum of different classes of characters in password is ' . + $minCharacterSetsNum . '. Classes of characters: Lower Case, Upper Case, Digits, Special Characters.'); } $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class)->getMock(); @@ -787,10 +783,8 @@ public function testCreateAccountInputExceptionExtraLongPassword() ->with($password) ->willReturn(iconv_strlen($password, 'UTF-8')); - $this->expectException( - \Magento\Framework\Exception\InputException::class, - 'Please enter a password with at most 256 characters.' - ); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage('Please enter a password with at most 256 characters.'); $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class)->getMock(); $this->accountManagement->createAccount($customer, $password); @@ -1568,10 +1562,8 @@ public function testChangePasswordException() ->with($email) ->willThrowException($exception); - $this->expectException( - \Magento\Framework\Exception\InvalidEmailOrPasswordException::class, - 'Invalid login or password.' - ); + $this->expectException(\Magento\Framework\Exception\InvalidEmailOrPasswordException::class); + $this->expectExceptionMessage('Invalid login or password.'); $this->accountManagement->changePassword($email, $currentPassword, $newPassword); } @@ -1883,4 +1875,105 @@ private function prepareDateTimeFactory() return $dateTime; } + + /** + * @return void + */ + public function testCreateAccountUnexpectedValueException(): void + { + $websiteId = 1; + $storeId = null; + $defaultStoreId = 1; + $customerId = 1; + $customerEmail = 'email@email.com'; + $newLinkToken = '2jh43j5h2345jh23lh452h345hfuzasd96ofu'; + $exception = new \UnexpectedValueException('Template file was not found'); + + $datetime = $this->prepareDateTimeFactory(); + + $address = $this->createMock(\Magento\Customer\Api\Data\AddressInterface::class); + $address->expects($this->once()) + ->method('setCustomerId') + ->with($customerId); + $store = $this->createMock(\Magento\Store\Model\Store::class); + $store->expects($this->once()) + ->method('getId') + ->willReturn($defaultStoreId); + $website = $this->createMock(\Magento\Store\Model\Website::class); + $website->expects($this->atLeastOnce()) + ->method('getStoreIds') + ->willReturn([1, 2, 3]); + $website->expects($this->once()) + ->method('getDefaultStore') + ->willReturn($store); + $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $customer->expects($this->atLeastOnce()) + ->method('getId') + ->willReturn($customerId); + $customer->expects($this->atLeastOnce()) + ->method('getEmail') + ->willReturn($customerEmail); + $customer->expects($this->atLeastOnce()) + ->method('getWebsiteId') + ->willReturn($websiteId); + $customer->expects($this->atLeastOnce()) + ->method('getStoreId') + ->willReturn($storeId); + $customer->expects($this->once()) + ->method('setStoreId') + ->with($defaultStoreId); + $customer->expects($this->once()) + ->method('getAddresses') + ->willReturn([$address]); + $customer->expects($this->once()) + ->method('setAddresses') + ->with(null); + $this->customerRepository->expects($this->once()) + ->method('get') + ->with($customerEmail) + ->willReturn($customer); + $this->share->expects($this->once()) + ->method('isWebsiteScope') + ->willReturn(true); + $this->storeManager->expects($this->atLeastOnce()) + ->method('getWebsite') + ->with($websiteId) + ->willReturn($website); + $this->customerRepository->expects($this->atLeastOnce()) + ->method('save') + ->willReturn($customer); + $this->addressRepository->expects($this->atLeastOnce()) + ->method('save') + ->with($address); + $this->customerRepository->expects($this->once()) + ->method('getById') + ->with($customerId) + ->willReturn($customer); + $this->random->expects($this->once()) + ->method('getUniqueHash') + ->willReturn($newLinkToken); + $customerSecure = $this->createPartialMock( + \Magento\Customer\Model\Data\CustomerSecure::class, + ['setRpToken', 'setRpTokenCreatedAt', 'getPasswordHash'] + ); + $customerSecure->expects($this->any()) + ->method('setRpToken') + ->with($newLinkToken); + $customerSecure->expects($this->any()) + ->method('setRpTokenCreatedAt') + ->with($datetime) + ->willReturnSelf(); + $customerSecure->expects($this->any()) + ->method('getPasswordHash') + ->willReturn(null); + $this->customerRegistry->expects($this->atLeastOnce()) + ->method('retrieveSecureData') + ->willReturn($customerSecure); + $this->emailNotificationMock->expects($this->once()) + ->method('newAccount') + ->willThrowException($exception); + $this->logger->expects($this->once())->method('error')->with($exception); + + $this->accountManagement->createAccount($customer); + } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/CountryTest.php b/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/CountryTest.php index e70f93edab12c..9ab35f2d301d4 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/CountryTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/CountryTest.php @@ -6,6 +6,8 @@ namespace Magento\Customer\Test\Unit\Model\Address\Validator; +use Magento\Store\Model\ScopeInterface; + /** * Magento\Customer\Model\Address\Validator\Country tests. */ @@ -20,14 +22,34 @@ class CountryTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager */ private $objectManager; + /** + * @var \Magento\Directory\Model\AllowedCountries|\PHPUnit_Framework_MockObject_MockObject + */ + private $allowedCountriesReaderMock; + + /** + * @var \Magento\Customer\Model\Config\Share|\PHPUnit_Framework_MockObject_MockObject + */ + private $shareConfigMock; + protected function setUp() { $this->directoryDataMock = $this->createMock(\Magento\Directory\Helper\Data::class); $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->allowedCountriesReaderMock = $this->createPartialMock( + \Magento\Directory\Model\AllowedCountries::class, + ['getAllowedCountries'] + ); + $this->shareConfigMock = $this->createPartialMock( + \Magento\Customer\Model\Config\Share::class, + ['isGlobalScope'] + ); $this->model = $this->objectManager->getObject( \Magento\Customer\Model\Address\Validator\Country::class, [ 'directoryData' => $this->directoryDataMock, + 'allowedCountriesReader' => $this->allowedCountriesReaderMock, + 'shareConfig' => $this->shareConfigMock, ] ); } @@ -59,16 +81,11 @@ public function testValidate(array $data, array $countryIds, array $allowedRegio ->method('isRegionRequired') ->willReturn($data['regionRequired']); - $countryCollectionMock = $this->getMockBuilder(\Magento\Directory\Model\ResourceModel\Country\Collection::class) - ->disableOriginalConstructor() - ->setMethods(['getAllIds']) - ->getMock(); - - $this->directoryDataMock->expects($this->any()) - ->method('getCountryCollection') - ->willReturn($countryCollectionMock); - - $countryCollectionMock->expects($this->any())->method('getAllIds')->willReturn($countryIds); + $this->shareConfigMock->method('isGlobalScope')->willReturn(false); + $this->allowedCountriesReaderMock + ->method('getAllowedCountries') + ->with(ScopeInterface::SCOPE_WEBSITE, null) + ->willReturn($countryIds); $addressMock->method('getCountryId')->willReturn($data['country_id']); diff --git a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php index 0240b7ab29ab7..318023d8068c5 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php @@ -3,10 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Customer\Test\Unit\Model; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\EmailNotification; use Magento\Framework\App\Area; +use Magento\Framework\Mail\Template\SenderResolverInterface; use Magento\Store\Model\ScopeInterface; /** @@ -47,7 +50,7 @@ class EmailNotificationTest extends \PHPUnit\Framework\TestCase private $customerSecureMock; /** - * @var \Magento\Framework\App\Config\ScopeConfigInterface + * @var \Magento\Framework\App\Config\ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject */ private $scopeConfigMock; @@ -61,6 +64,11 @@ class EmailNotificationTest extends \PHPUnit\Framework\TestCase */ private $model; + /** + * @var SenderResolverInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $senderResolverMock; + public function setUp() { $this->customerRegistryMock = $this->createMock(\Magento\Customer\Model\CustomerRegistry::class); @@ -88,17 +96,23 @@ public function setUp() $this->storeMock = $this->createMock(\Magento\Store\Model\Store::class); + $this->senderResolverMock = $this->getMockBuilder(SenderResolverInterface::class) + ->setMethods(['resolve']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->model = $objectManager->getObject( EmailNotification::class, [ - 'customerRegistry' => $this->customerRegistryMock, + 'customerRegistry' => $this->customerRegistryMock, 'storeManager' => $this->storeManagerMock, 'transportBuilder' => $this->transportBuilderMock, 'customerViewHelper' => $this->customerViewHelperMock, 'dataProcessor' => $this->dataProcessorMock, - 'scopeConfig' => $this->scopeConfigMock + 'scopeConfig' => $this->scopeConfigMock, + 'senderResolver' => $this->senderResolverMock, ] ); } @@ -121,7 +135,10 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $expects = $this->once(); + $xmlPathTemplate = EmailNotification::XML_PATH_RESET_PASSWORD_TEMPLATE; switch ($testNumber) { case 1: $xmlPathTemplate = EmailNotification::XML_PATH_RESET_PASSWORD_TEMPLATE; @@ -137,7 +154,14 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas break; } - $origCustomer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $this->senderResolverMock + ->expects($expects) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var \PHPUnit_Framework_MockObject_MockObject $origCustomer */ + $origCustomer = $this->createMock(CustomerInterface::class); $origCustomer->expects($this->any()) ->method('getStoreId') ->willReturn(0); @@ -175,7 +199,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas $this->dataProcessorMock->expects(clone $expects) ->method('buildOutputDataArray') - ->with($origCustomer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($origCustomer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -192,6 +216,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas ->with('name', $customerName) ->willReturnSelf(); + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $savedCustomer */ $savedCustomer = clone $origCustomer; $origCustomer->expects($this->any()) @@ -234,7 +259,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas ->willReturnSelf(); $this->transportBuilderMock->expects(clone $expects) ->method('setFrom') - ->with($sender) + ->with($senderValues) ->willReturnSelf(); $this->transportBuilderMock->expects(clone $expects) @@ -287,14 +312,27 @@ public function sendNotificationEmailsDataProvider() public function testPasswordReminder() { $customerId = 1; + $customerWebsiteId = 1; $customerStoreId = 2; $customerEmail = 'email@email.com'; $customerData = ['key' => 'value']; $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $storeIds = [1, 2]; + + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); + $customer->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($customerWebsiteId); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -313,10 +351,15 @@ public function testPasswordReminder() ->method('getStore') ->willReturn($this->storeMock); - $this->storeManagerMock->expects($this->at(1)) - ->method('getStore') - ->with($customerStoreId) - ->willReturn($this->storeMock); + $websiteMock = $this->createPartialMock(\Magento\Store\Model\Website::class, ['getStoreIds']); + $websiteMock->expects($this->any()) + ->method('getStoreIds') + ->willReturn($storeIds); + + $this->storeManagerMock->expects($this->any()) + ->method('getWebsite') + ->with($customerWebsiteId) + ->willReturn($websiteMock); $this->customerRegistryMock->expects($this->once()) ->method('retrieveSecureData') @@ -325,7 +368,7 @@ public function testPasswordReminder() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -351,35 +394,108 @@ public function testPasswordReminder() ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); - $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateIdentifier') - ->with($templateIdentifier) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateOptions') - ->with(['area' => Area::AREA_FRONTEND, 'store' => $customerStoreId]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'store' => $this->storeMock]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setFrom') - ->with($sender) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('addTo') - ->with($customerEmail, $customerName) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('getTransport') - ->willReturn($transport); + $this->model->passwordReminder($customer); + } - $transport->expects($this->once()) - ->method('sendMessage'); + /** + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testPasswordReminderCustomerWithoutStoreId() + { + $customerId = 1; + $customerWebsiteId = 1; + $customerStoreId = null; + $customerEmail = 'email@email.com'; + $customerData = ['key' => 'value']; + $customerName = 'Customer Name'; + $templateIdentifier = 'Template Identifier'; + $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $storeIds = [1, 2]; + $defaultStoreId = reset($storeIds); + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $defaultStoreId) + ->willReturn($senderValues); + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); + $customer->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($customerWebsiteId); + $customer->expects($this->any()) + ->method('getStoreId') + ->willReturn($customerStoreId); + $customer->expects($this->any()) + ->method('getId') + ->willReturn($customerId); + $customer->expects($this->any()) + ->method('getEmail') + ->willReturn($customerEmail); + $this->storeMock->expects($this->any()) + ->method('getId') + ->willReturn($defaultStoreId); + $this->storeManagerMock->expects($this->at(0)) + ->method('getStore') + ->willReturn($this->storeMock); + $this->storeManagerMock->expects($this->at(1)) + ->method('getStore') + ->with($defaultStoreId) + ->willReturn($this->storeMock); + $websiteMock = $this->createPartialMock(\Magento\Store\Model\Website::class, ['getStoreIds']); + $websiteMock->expects($this->any()) + ->method('getStoreIds') + ->willReturn($storeIds); + $this->storeManagerMock->expects($this->any()) + ->method('getWebsite') + ->with($customerWebsiteId) + ->willReturn($websiteMock); + $this->customerRegistryMock->expects($this->once()) + ->method('retrieveSecureData') + ->with($customerId) + ->willReturn($this->customerSecureMock); + $this->dataProcessorMock->expects($this->once()) + ->method('buildOutputDataArray') + ->with($customer, CustomerInterface::class) + ->willReturn($customerData); + $this->customerViewHelperMock->expects($this->any()) + ->method('getCustomerName') + ->with($customer) + ->willReturn($customerName); + $this->customerSecureMock->expects($this->once()) + ->method('addData') + ->with($customerData) + ->willReturnSelf(); + $this->customerSecureMock->expects($this->once()) + ->method('setData') + ->with('name', $customerName) + ->willReturnSelf(); + $this->scopeConfigMock->expects($this->at(0)) + ->method('getValue') + ->with(EmailNotification::XML_PATH_REMIND_EMAIL_TEMPLATE, ScopeInterface::SCOPE_STORE, $defaultStoreId) + ->willReturn($templateIdentifier); + $this->scopeConfigMock->expects($this->at(1)) + ->method('getValue') + ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $defaultStoreId) + ->willReturn($sender); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $defaultStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); $this->model->passwordReminder($customer); } @@ -395,8 +511,16 @@ public function testPasswordResetConfirmation() $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -427,7 +551,7 @@ public function testPasswordResetConfirmation() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -453,34 +577,14 @@ public function testPasswordResetConfirmation() ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); - $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); - - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateIdentifier') - ->with($templateIdentifier) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateOptions') - ->with(['area' => Area::AREA_FRONTEND, 'store' => $customerStoreId]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'store' => $this->storeMock]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setFrom') - ->with($sender) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('addTo') - ->with($customerEmail, $customerName) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('getTransport') - ->willReturn($transport); - - $transport->expects($this->once()) - ->method('sendMessage'); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); $this->model->passwordResetConfirmation($customer); } @@ -497,8 +601,16 @@ public function testNewAccount() $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -525,7 +637,7 @@ public function testNewAccount() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -551,6 +663,38 @@ public function testNewAccount() ->with(EmailNotification::XML_PATH_REGISTER_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'back_url' => '', 'store' => $this->storeMock] + ); + + $this->model->newAccount($customer, EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, '', $customerStoreId); + } + + /** + * Create default mock for $this->transportBuilderMock. + * + * @param string $templateIdentifier + * @param int $customerStoreId + * @param array $senderValues + * @param string $customerEmail + * @param string $customerName + * @param array $templateVars + * + * @return void + */ + private function mockDefaultTransportBuilder( + string $templateIdentifier, + int $customerStoreId, + array $senderValues, + string $customerEmail, + string $customerName, + array $templateVars = [] + ): void { $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); $this->transportBuilderMock->expects($this->once()) @@ -563,11 +707,11 @@ public function testNewAccount() ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'back_url' => '', 'store' => $this->storeMock]) + ->with($templateVars) ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('setFrom') - ->with($sender) + ->with($senderValues) ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('addTo') @@ -579,7 +723,5 @@ public function testNewAccount() $transport->expects($this->once()) ->method('sendMessage'); - - $this->model->newAccount($customer, EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, '', $customerStoreId); } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php b/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php index 4cea7ee22837d..408389182ae49 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php @@ -71,7 +71,8 @@ public function testLog($customerId, $data) $data = array_filter($data); if (!$data) { - $this->expectException('\InvalidArgumentException', 'Log data is empty'); + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage('Log data is empty'); $this->logger->log($customerId, $data); return; } diff --git a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php index 06133dd89d754..bd1dc774b5319 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php @@ -419,7 +419,11 @@ public function testSave() ->method('dispatch') ->with( 'customer_save_after_data_object', - ['customer_data_object' => $this->customer, 'orig_customer_data_object' => $origCustomer] + [ + 'customer_data_object' => $this->customer, + 'orig_customer_data_object' => $origCustomer, + 'delegate_data' => [], + ] ); $this->model->save($this->customer); @@ -646,7 +650,11 @@ public function testSaveWithPasswordHash() ->method('dispatch') ->with( 'customer_save_after_data_object', - ['customer_data_object' => $this->customer, 'orig_customer_data_object' => $origCustomer] + [ + 'customer_data_object' => $this->customer, + 'orig_customer_data_object' => $origCustomer, + 'delegate_data' => [], + ] ); $this->model->save($this->customer, $passwordHash); diff --git a/app/code/Magento/Customer/composer.json b/app/code/Magento/Customer/composer.json index 992362ce6495e..b9a7aca73fe34 100644 --- a/app/code/Magento/Customer/composer.json +++ b/app/code/Magento/Customer/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-authorization": "*", "magento/module-backend": "*", @@ -29,7 +29,7 @@ }, "suggest": { "magento/module-cookie": "*", - "magento/module-customer-sample-data": "Sample Data version:100.3.*" + "magento/module-customer-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Customer/etc/di.xml b/app/code/Magento/Customer/etc/di.xml index 43c2b9cf7bb80..86ed633790491 100644 --- a/app/code/Magento/Customer/etc/di.xml +++ b/app/code/Magento/Customer/etc/di.xml @@ -353,9 +353,9 @@ - customer_group_code - customer_group_id - class_name + main_table.customer_group_code + main_table.customer_group_id + tax_class_table.class_name @@ -363,9 +363,9 @@ - customer_group_code - customer_group_id - class_name + main_table.customer_group_code + main_table.customer_group_id + tax_class_table.class_name ASC @@ -433,4 +433,7 @@ + diff --git a/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml b/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml index 7881b7e857fd9..ac8e1298b29b9 100644 --- a/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml @@ -20,6 +20,7 @@ escapeHtml($block->getName()) ?>
    escapeHtml($block->getCustomer()->getEmail()) ?>

    + getChildHtml('customer.account.dashboard.info.extra'); ?>
    escapeHtml(__('Edit')) ?> diff --git a/app/code/Magento/Customer/view/frontend/templates/form/login.phtml b/app/code/Magento/Customer/view/frontend/templates/form/login.phtml index 16206525aa53b..2d44dde215139 100644 --- a/app/code/Magento/Customer/view/frontend/templates/form/login.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/form/login.phtml @@ -24,7 +24,7 @@
    @@ -42,3 +42,4 @@
    + diff --git a/app/code/Magento/Customer/view/frontend/templates/form/register.phtml b/app/code/Magento/Customer/view/frontend/templates/form/register.phtml index 43e4e92fd0904..6cdb8fc44f665 100644 --- a/app/code/Magento/Customer/view/frontend/templates/form/register.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/form/register.phtml @@ -131,7 +131,7 @@
    - +
    diff --git a/app/code/Magento/Customer/view/frontend/templates/logout.phtml b/app/code/Magento/Customer/view/frontend/templates/logout.phtml index 43665045ce3e2..5a99b7d931b9b 100644 --- a/app/code/Magento/Customer/view/frontend/templates/logout.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/logout.phtml @@ -7,13 +7,12 @@ /** @var \Magento\Framework\View\Element\Template $block */ ?>

    escapeHtml(__('You have signed out and will go to our homepage in 5 seconds.')) ?>

    - diff --git a/app/code/Magento/Customer/view/frontend/templates/widget/name.phtml b/app/code/Magento/Customer/view/frontend/templates/widget/name.phtml index 73e9c4fa34bb3..223e43c9bb897 100644 --- a/app/code/Magento/Customer/view/frontend/templates/widget/name.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/widget/name.phtml @@ -28,9 +28,7 @@ $suffix = $block->showSuffix(); ?> getNoWrap()): ?>
    - +
    @@ -38,10 +36,7 @@ $suffix = $block->showSuffix();
    - - +
    getPrefixOptions() === false): ?> showSuffix();
    - - +
    showSuffix(); isMiddlenameRequired(); ?>
    - - +
    showSuffix();
    - - +
    showSuffix();
    - - +
    getSuffixOptions() === false): ?>
    -
    diff --git a/app/code/Magento/GoogleAdwords/composer.json b/app/code/Magento/GoogleAdwords/composer.json index 5aa2823582841..8aa1428652144 100644 --- a/app/code/Magento/GoogleAdwords/composer.json +++ b/app/code/Magento/GoogleAdwords/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-sales": "*", "magento/module-store": "*" diff --git a/app/code/Magento/GoogleAnalytics/composer.json b/app/code/Magento/GoogleAnalytics/composer.json index bf3d84a3bda90..9c86005706e83 100644 --- a/app/code/Magento/GoogleAnalytics/composer.json +++ b/app/code/Magento/GoogleAnalytics/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-cookie": "*", "magento/module-sales": "*", diff --git a/app/code/Magento/GoogleAnalytics/view/frontend/web/js/google-analytics.js b/app/code/Magento/GoogleAnalytics/view/frontend/web/js/google-analytics.js index eb708ab8b6320..a8b8303d47cdd 100644 --- a/app/code/Magento/GoogleAnalytics/view/frontend/web/js/google-analytics.js +++ b/app/code/Magento/GoogleAnalytics/view/frontend/web/js/google-analytics.js @@ -53,7 +53,7 @@ define([ } // Process orders data - if (config.ordersTrackingData.length) { + if (config.ordersTrackingData.hasOwnProperty('currency')) { ga('require', 'ec', 'ec.js'); ga('set', 'currencyCode', config.ordersTrackingData.currency); diff --git a/app/code/Magento/GoogleOptimizer/composer.json b/app/code/Magento/GoogleOptimizer/composer.json index 0330103c99575..51036562857f2 100644 --- a/app/code/Magento/GoogleOptimizer/composer.json +++ b/app/code/Magento/GoogleOptimizer/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/GraphQl/composer.json b/app/code/Magento/GraphQl/composer.json index cc342b3f513a8..3e821b0909444 100644 --- a/app/code/Magento/GraphQl/composer.json +++ b/app/code/Magento/GraphQl/composer.json @@ -3,7 +3,7 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-authorization": "*", "magento/module-store": "*", "magento/module-eav": "*", diff --git a/app/code/Magento/GraphQl/etc/schema.graphqls b/app/code/Magento/GraphQl/etc/schema.graphqls index ffdf5511b7492..37ca2d8d7b378 100644 --- a/app/code/Magento/GraphQl/etc/schema.graphqls +++ b/app/code/Magento/GraphQl/etc/schema.graphqls @@ -30,4 +30,4 @@ type SearchResultPageInfo @doc(description: "SearchResultPageInfo provides navig enum SortEnum @doc(description: "This enumeration indicates whether to return results in ascending or descending order") { ASC DESC -} +} \ No newline at end of file diff --git a/app/code/Magento/GroupedImportExport/composer.json b/app/code/Magento/GroupedImportExport/composer.json index 5376a97ff455c..b7674e2157021 100644 --- a/app/code/Magento/GroupedImportExport/composer.json +++ b/app/code/Magento/GroupedImportExport/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-catalog-import-export": "*", diff --git a/app/code/Magento/GroupedProduct/Pricing/Price/ConfiguredRegularPrice.php b/app/code/Magento/GroupedProduct/Pricing/Price/ConfiguredRegularPrice.php new file mode 100644 index 0000000000000..8b29e82d93a4e --- /dev/null +++ b/app/code/Magento/GroupedProduct/Pricing/Price/ConfiguredRegularPrice.php @@ -0,0 +1,91 @@ +item = $item; + + return $this; + } + + /** + * Calculate configured price. + * + * @return float + */ + protected function calculatePrice(): float + { + $value = 0.; + /** @var \Magento\GroupedProduct\Model\Product\Type\Grouped $typeInstance */ + $typeInstance = $this->getProduct()->getTypeInstance(); + $associatedProducts = $typeInstance + ->setStoreFilter($this->getProduct()->getStore(), $this->getProduct()) + ->getAssociatedProducts($this->getProduct()); + + foreach ($associatedProducts as $product) { + /** @var Product $product */ + /** @var \Magento\Wishlist\Model\Item\Option $customOption */ + $customOption = $this->getProduct() + ->getCustomOption('associated_product_' . $product->getId()); + if (!$customOption) { + continue; + } + $finalPrice = $product->getPriceInfo() + ->getPrice(\Magento\Catalog\Pricing\Price\RegularPrice::PRICE_CODE) + ->getValue(); + $value += $finalPrice * ($customOption->getValue() ?: 1); + } + + return $value; + } + + /** + * Price value of product with configured options. + * + * @return bool|float + */ + public function getValue() + { + if ($this->item) { + return $this->calculatePrice(); + } else { + if ($this->value === null) { + $price = $this->product->getPrice(); + $priceInCurrentCurrency = $this->priceCurrency->convertAndRound($price); + $this->value = $priceInCurrentCurrency ? floatval($priceInCurrentCurrency) : false; + } + + return $this->value; + } + } +} diff --git a/app/code/Magento/GroupedProduct/composer.json b/app/code/Magento/GroupedProduct/composer.json index 2f74c97295452..19509ae3ce084 100644 --- a/app/code/Magento/GroupedProduct/composer.json +++ b/app/code/Magento/GroupedProduct/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -21,7 +21,7 @@ "magento/module-ui": "*" }, "suggest": { - "magento/module-grouped-product-sample-data": "Sample Data version:100.3.*" + "magento/module-grouped-product-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/GroupedProduct/etc/di.xml b/app/code/Magento/GroupedProduct/etc/di.xml index f39bcfa01453c..8f688e3d06b00 100644 --- a/app/code/Magento/GroupedProduct/etc/di.xml +++ b/app/code/Magento/GroupedProduct/etc/di.xml @@ -48,6 +48,7 @@ Magento\GroupedProduct\Pricing\Price\FinalPrice Magento\GroupedProduct\Pricing\Price\ConfiguredPrice + Magento\GroupedProduct\Pricing\Price\ConfiguredRegularPrice Magento\Catalog\Pricing\Price\Pool diff --git a/app/code/Magento/GroupedProductGraphQl/composer.json b/app/code/Magento/GroupedProductGraphQl/composer.json index 17838bb17adc6..bf6745370b4be 100644 --- a/app/code/Magento/GroupedProductGraphQl/composer.json +++ b/app/code/Magento/GroupedProductGraphQl/composer.json @@ -3,7 +3,7 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-grouped-product": "*", "magento/module-catalog-graph-ql": "*", "magento/framework": "*" diff --git a/app/code/Magento/ImportExport/Model/Report/Csv.php b/app/code/Magento/ImportExport/Model/Report/Csv.php index 86c68d7c4df77..7279092265cbb 100644 --- a/app/code/Magento/ImportExport/Model/Report/Csv.php +++ b/app/code/Magento/ImportExport/Model/Report/Csv.php @@ -77,7 +77,7 @@ public function createReport( $outputCsv = $this->createOutputCsvModel($outputFileName); $columnsName = $sourceCsv->getColNames(); - array_push($columnsName, self::REPORT_ERROR_COLUMN_NAME); + $columnsName[] = self::REPORT_ERROR_COLUMN_NAME; $outputCsv->setHeaderCols($columnsName); foreach ($sourceCsv as $rowNum => $rowData) { diff --git a/app/code/Magento/ImportExport/Test/Unit/Model/Import/SourceAbstractTest.php b/app/code/Magento/ImportExport/Test/Unit/Model/Import/SourceAbstractTest.php index 06d2292dfb1a0..ba65677d53b95 100644 --- a/app/code/Magento/ImportExport/Test/Unit/Model/Import/SourceAbstractTest.php +++ b/app/code/Magento/ImportExport/Test/Unit/Model/Import/SourceAbstractTest.php @@ -57,7 +57,7 @@ public function testIteratorInterface() )->method( '_getNextRow' )->will( - $this->onConsecutiveCalls([1, 2, 3], [4, 5, 5], [6, 7, 8]) + $this->onConsecutiveCalls([1, 2, 3], [4, 5, 5], [6, 7, 8], []) ); $data = []; foreach ($this->_model as $key => $value) { diff --git a/app/code/Magento/ImportExport/composer.json b/app/code/Magento/ImportExport/composer.json index d80458cf99586..b0ba04f5aa0eb 100644 --- a/app/code/Magento/ImportExport/composer.json +++ b/app/code/Magento/ImportExport/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "ext-ctype": "*", "magento/framework": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/Indexer/Console/Command/IndexerStatusCommand.php b/app/code/Magento/Indexer/Console/Command/IndexerStatusCommand.php index 22acdc6f82bbc..cefb070f60b74 100644 --- a/app/code/Magento/Indexer/Console/Command/IndexerStatusCommand.php +++ b/app/code/Magento/Indexer/Console/Command/IndexerStatusCommand.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Magento\Framework\Indexer; use Magento\Framework\Mview; +use Symfony\Component\Console\Helper\Table; /** * Command for displaying status of indexers. @@ -32,7 +33,7 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - $table = $this->getHelperSet()->get('table'); + $table = new Table($output); $table->setHeaders(['Title', 'Status', 'Update On', 'Schedule Status', 'Schedule Updated']); $rows = []; @@ -63,7 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output) }); $table->addRows($rows); - $table->render($output); + $table->render(); } /** diff --git a/app/code/Magento/Indexer/Model/Indexer.php b/app/code/Magento/Indexer/Model/Indexer.php index 58b2e9ed76a78..87a7cce58e1a5 100644 --- a/app/code/Magento/Indexer/Model/Indexer.php +++ b/app/code/Magento/Indexer/Model/Indexer.php @@ -398,7 +398,7 @@ protected function getStructureInstance() * Regenerate full index * * @return void - * @throws \Exception + * @throws \Throwable */ public function reindexAll() { @@ -414,16 +414,11 @@ public function reindexAll() $state->setStatus(StateInterface::STATUS_VALID); $state->save(); $this->getView()->resume(); - } catch (\Exception $exception) { + } catch (\Throwable $exception) { $state->setStatus(StateInterface::STATUS_INVALID); $state->save(); $this->getView()->resume(); throw $exception; - } catch (\Error $error) { - $state->setStatus(StateInterface::STATUS_INVALID); - $state->save(); - $this->getView()->resume(); - throw $error; } } } diff --git a/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerStatusCommandTest.php b/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerStatusCommandTest.php index a79c9cc47a1bc..8498bd183af21 100644 --- a/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerStatusCommandTest.php +++ b/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerStatusCommandTest.php @@ -8,8 +8,6 @@ use Magento\Framework\Indexer\StateInterface; use Magento\Indexer\Console\Command\IndexerStatusCommand; use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\Console\Helper\HelperSet; -use Symfony\Component\Console\Helper\TableHelper; class IndexerStatusCommandTest extends AbstractIndexerCommandCommonSetup { @@ -93,15 +91,6 @@ public function testExecuteAll(array $indexers) $this->initIndexerCollectionByItems($indexerMocks); $this->command = new IndexerStatusCommand($this->objectManagerFactory); - $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - - $this->command->setHelperSet( - $objectManager->getObject( - HelperSet::class, - ['helpers' => [$objectManager->getObject(TableHelper::class)]] - ) - ); - $commandTester = new CommandTester($this->command); $commandTester->execute([]); diff --git a/app/code/Magento/Indexer/composer.json b/app/code/Magento/Indexer/composer.json index 01828643e1c20..6aefa433495af 100644 --- a/app/code/Magento/Indexer/composer.json +++ b/app/code/Magento/Indexer/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*" }, diff --git a/app/code/Magento/InstantPurchase/Model/InstantPurchaseOption.php b/app/code/Magento/InstantPurchase/Model/InstantPurchaseOption.php index 214b93560669f..0748c5818c857 100644 --- a/app/code/Magento/InstantPurchase/Model/InstantPurchaseOption.php +++ b/app/code/Magento/InstantPurchase/Model/InstantPurchaseOption.php @@ -20,22 +20,22 @@ class InstantPurchaseOption { /** - * @var PaymentTokenInterface + * @var PaymentTokenInterface|null */ private $paymentToken; /** - * @var AddressIn + * @var Address|null */ private $shippingAddress; /** - * @var Address + * @var Address|null */ private $billingAddress; /** - * @var ShippingMethodInterface + * @var ShippingMethodInterface|null */ private $shippingMethod; diff --git a/app/code/Magento/InstantPurchase/Model/InstantPurchaseOptionLoadingFactory.php b/app/code/Magento/InstantPurchase/Model/InstantPurchaseOptionLoadingFactory.php index d1dc71b80d5d1..b203cfdad2221 100644 --- a/app/code/Magento/InstantPurchase/Model/InstantPurchaseOptionLoadingFactory.php +++ b/app/code/Magento/InstantPurchase/Model/InstantPurchaseOptionLoadingFactory.php @@ -100,7 +100,7 @@ public function create( /** * Loads customer address model by identifier. * - * @param $addressId + * @param int $addressId * @return Address */ private function getAddress($addressId): Address diff --git a/app/code/Magento/InstantPurchase/Model/ShippingMethodChoose/DeferredShippingMethodChooserPool.php b/app/code/Magento/InstantPurchase/Model/ShippingMethodChoose/DeferredShippingMethodChooserPool.php index 96c01cdbb6663..ca0e9351967ad 100644 --- a/app/code/Magento/InstantPurchase/Model/ShippingMethodChoose/DeferredShippingMethodChooserPool.php +++ b/app/code/Magento/InstantPurchase/Model/ShippingMethodChoose/DeferredShippingMethodChooserPool.php @@ -39,7 +39,7 @@ public function get($type) : DeferredShippingMethodChooserInterface { if (!isset($this->choosers[$type])) { throw new \InvalidArgumentException(sprintf( - 'Deferred shipping method chooser is not registered.', + 'Deferred shipping method %s is not registered.', $type )); } diff --git a/app/code/Magento/InstantPurchase/PaymentMethodIntegration/IntegrationsManager.php b/app/code/Magento/InstantPurchase/PaymentMethodIntegration/IntegrationsManager.php index 9c93febe0db36..3ad2e000e97d3 100644 --- a/app/code/Magento/InstantPurchase/PaymentMethodIntegration/IntegrationsManager.php +++ b/app/code/Magento/InstantPurchase/PaymentMethodIntegration/IntegrationsManager.php @@ -146,7 +146,7 @@ private function findIntegrations(int $storeId): array * * * @param VaultPaymentInterface $paymentMethod - * @param $storeId + * @param int|string|null|\Magento\Store\Model\Store $storeId * @return bool */ private function isIntegrationAvailable(VaultPaymentInterface $paymentMethod, $storeId): bool diff --git a/app/code/Magento/InstantPurchase/composer.json b/app/code/Magento/InstantPurchase/composer.json index 82feaed33979c..2b39920fefc6e 100644 --- a/app/code/Magento/InstantPurchase/composer.json +++ b/app/code/Magento/InstantPurchase/composer.json @@ -7,7 +7,7 @@ "AFL-3.0" ], "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-store": "*", "magento/module-catalog": "*", "magento/module-customer": "*", diff --git a/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php b/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php index f896f92d5768b..e697d2594bded 100644 --- a/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php +++ b/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php @@ -39,7 +39,7 @@ class KeyLength extends \Zend_Validate_StringLength * Default encoding is set to utf-8 if none provided * New option name added to allow adding key name in validation error messages * - * @param integer|array|\Zend_Config $options + * @inheritdoc */ public function __construct($options = []) { diff --git a/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php b/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php index 73fab59176c40..c6b7ce22fc39c 100644 --- a/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php +++ b/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php @@ -6,6 +6,7 @@ namespace Magento\Integration\Test\Unit\Model\Oauth; use Magento\Framework\Url\Validator as UrlValidator; +use Zend\Validator\Uri as ZendUriValidator; use Magento\Integration\Model\Oauth\Consumer\Validator\KeyLength; /** @@ -84,7 +85,7 @@ protected function setUp() $this->keyLengthValidator = new KeyLength(); - $this->urlValidator = new UrlValidator(); + $this->urlValidator = new UrlValidator(new ZendUriValidator()); $this->oauthDataMock = $this->createPartialMock( \Magento\Integration\Helper\Oauth\Data::class, diff --git a/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php b/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php index 36b0d77d1e1ae..badb69aa19fe4 100644 --- a/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php +++ b/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php @@ -384,7 +384,8 @@ public function testValidateIfNotCallbackEstablishedAndNotValid() $this->validatorMock->expects($this->once())->method('isValid')->willReturn(false); $this->validatorMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } @@ -402,7 +403,8 @@ public function testValidateIfSecretNotValid() $this->validatorKeyLengthMock->expects($this->once())->method('isValid')->willReturn(false); $this->validatorKeyLengthMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } @@ -429,7 +431,8 @@ public function testValidateIfTokenNotValid() ] ); $this->validatorKeyLengthMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } @@ -459,7 +462,8 @@ public function testValidateIfVerifierNotValid() ] ); $this->validatorKeyLengthMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } diff --git a/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php b/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php index 875377776771d..966a6b293196f 100644 --- a/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php +++ b/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php @@ -777,11 +777,9 @@ public function testBuildAuthorizationHeader() */ public function testMissingParamForBuildAuthorizationHeader($expectedMessage, $request) { - $this->expectException( - \Magento\Framework\Oauth\OauthInputException::class, - $expectedMessage, - 0 - ); + $this->expectException(\Magento\Framework\Oauth\OauthInputException::class); + $this->expectExceptionMessage($expectedMessage); + $this->expectExceptionCode(0); $requestUrl = 'http://www.example.com/endpoint'; $this->_oauth->buildAuthorizationHeader($request, $requestUrl); diff --git a/app/code/Magento/Integration/composer.json b/app/code/Magento/Integration/composer.json index a797de8d4fb2d..6a63854775ac3 100644 --- a/app/code/Magento/Integration/composer.json +++ b/app/code/Magento/Integration/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-authorization": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/LayeredNavigation/composer.json b/app/code/Magento/LayeredNavigation/composer.json index b66929297eb98..6d322fc3ab50c 100644 --- a/app/code/Magento/LayeredNavigation/composer.json +++ b/app/code/Magento/LayeredNavigation/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-config": "*" diff --git a/app/code/Magento/Marketplace/composer.json b/app/code/Magento/Marketplace/composer.json index faaf659c11509..b52d507f825fc 100644 --- a/app/code/Magento/Marketplace/composer.json +++ b/app/code/Magento/Marketplace/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*" }, diff --git a/app/code/Magento/Marketplace/view/adminhtml/templates/partners.phtml b/app/code/Magento/Marketplace/view/adminhtml/templates/partners.phtml index 309df6f883a49..b63bf9ebd50eb 100644 --- a/app/code/Magento/Marketplace/view/adminhtml/templates/partners.phtml +++ b/app/code/Magento/Marketplace/view/adminhtml/templates/partners.phtml @@ -11,7 +11,7 @@ $partners = $block->getPartners(); ?> - getPartners() as $partner) : ?> +
    response = $response; $this->isAllowed = $isAllowed; - $this->directory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $this->directory = $filesystem->getDirectoryWrite(DirectoryList::PUB); $mediaDirectory = trim($mediaDirectory); if (!empty($mediaDirectory)) { $this->mediaDirectoryPath = str_replace('\\', '/', realpath($mediaDirectory)); @@ -99,6 +126,9 @@ public function __construct( $this->relativeFileName = $relativeFileName; $this->configFactory = $configFactory; $this->syncFactory = $syncFactory; + $this->placeholderFactory = $placeholderFactory; + $this->appState = $state; + $this->imageResize = $imageResize; } /** @@ -109,9 +139,11 @@ public function __construct( */ public function launch() { + $this->appState->setAreaCode(Area::AREA_GLOBAL); + if ($this->mediaDirectoryPath !== $this->directory->getAbsolutePath()) { // Path to media directory changed or absent - update the config - /** @var \Magento\MediaStorage\Model\File\Storage\Config $config */ + /** @var Config $config */ $config = $this->configFactory->create(['cacheFile' => $this->configCacheFile]); $config->save(); $this->mediaDirectoryPath = $config->getMediaDirectory(); @@ -122,18 +154,40 @@ public function launch() } } - /** @var \Magento\MediaStorage\Model\File\Storage\Synchronization $sync */ - $sync = $this->syncFactory->create(['directory' => $this->directory]); - $sync->synchronize($this->relativeFileName); - - if ($this->directory->isReadable($this->relativeFileName)) { - $this->response->setFilePath($this->directory->getAbsolutePath($this->relativeFileName)); - } else { - $this->response->setHttpResponseCode(404); + try { + /** @var \Magento\MediaStorage\Model\File\Storage\Synchronization $sync */ + $sync = $this->syncFactory->create(['directory' => $this->directory]); + $sync->synchronize($this->relativeFileName); + $this->imageResize->resizeFromImageName($this->getOriginalImage($this->relativeFileName)); + if ($this->directory->isReadable($this->relativeFileName)) { + $this->response->setFilePath($this->directory->getAbsolutePath($this->relativeFileName)); + } else { + $this->setPlaceholderImage(); + } + } catch (\Exception $e) { + $this->setPlaceholderImage(); } + return $this->response; } + private function setPlaceholderImage() + { + $placeholder = $this->placeholderFactory->create(['type' => 'image']); + $this->response->setFilePath($placeholder->getPath()); + } + + /** + * Find the path to the original image of the cache path + * + * @param string $resizedImagePath + * @return string + */ + private function getOriginalImage(string $resizedImagePath): string + { + return preg_replace('|^.*((?:/[^/]+){3})$|', '$1', $resizedImagePath); + } + /** * {@inheritdoc} */ diff --git a/app/code/Magento/MediaStorage/Console/Command/ImagesResizeCommand.php b/app/code/Magento/MediaStorage/Console/Command/ImagesResizeCommand.php new file mode 100644 index 0000000000000..a4b78287df012 --- /dev/null +++ b/app/code/Magento/MediaStorage/Console/Command/ImagesResizeCommand.php @@ -0,0 +1,95 @@ +resize = $resize; + $this->appState = $appState; + $this->objectManager = $objectManager; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('catalog:images:resize') + ->setDescription('Creates resized product images'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + try { + $this->appState->setAreaCode(Area::AREA_GLOBAL); + $generator = $this->resize->resizeFromThemes(); + + /** @var ProgressBar $progress */ + $progress = $this->objectManager->create(ProgressBar::class, [ + 'output' => $output, + 'max' => $generator->current() + ]); + $progress->setFormat( + "%current%/%max% [%bar%] %percent:3s%% %elapsed% %memory:6s% \t| %message%" + ); + + if ($output->getVerbosity() !== OutputInterface::VERBOSITY_NORMAL) { + $progress->setOverwrite(false); + } + + for (; $generator->valid(); $generator->next()) { + $progress->setMessage($generator->key()); + $progress->advance(); + } + } catch (\Exception $e) { + $output->writeln("{$e->getMessage()}"); + // we must have an exit code higher than zero to indicate something was wrong + return \Magento\Framework\Console\Cli::RETURN_FAILURE; + } + + $output->write(PHP_EOL); + $output->writeln("Product images resized successfully"); + } +} diff --git a/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php b/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php index d744afb0fad9f..09f269e0ebddf 100644 --- a/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php +++ b/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php @@ -7,7 +7,9 @@ use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Filesystem\Directory\WriteInterface as DirectoryWrite; -use Magento\Framework\Filesystem\File\Write; +use Magento\Framework\Filesystem\File\WriteInterface; +use Magento\MediaStorage\Service\ImageResize; +use Magento\MediaStorage\Model\File\Storage\Database; /** * Class Synchronization @@ -17,7 +19,7 @@ class Synchronization /** * Database storage factory * - * @var \Magento\MediaStorage\Model\File\Storage\DatabaseFactory + * @var DatabaseFactory */ protected $storageFactory; @@ -29,11 +31,11 @@ class Synchronization protected $mediaDirectory; /** - * @param \Magento\MediaStorage\Model\File\Storage\DatabaseFactory $storageFactory + * @param DatabaseFactory $storageFactory * @param DirectoryWrite $directory */ public function __construct( - \Magento\MediaStorage\Model\File\Storage\DatabaseFactory $storageFactory, + DatabaseFactory $storageFactory, DirectoryWrite $directory ) { $this->storageFactory = $storageFactory; @@ -49,14 +51,14 @@ public function __construct( */ public function synchronize($relativeFileName) { - /** @var $storage \Magento\MediaStorage\Model\File\Storage\Database */ + /** @var $storage Database */ $storage = $this->storageFactory->create(); try { $storage->loadByFilename($relativeFileName); } catch (\Exception $e) { } if ($storage->getId()) { - /** @var \Magento\Framework\Filesystem\File\WriteInterface $file */ + /** @var WriteInterface $file */ $file = $this->mediaDirectory->openFile($relativeFileName, 'w'); try { $file->lock(); diff --git a/app/code/Magento/MediaStorage/Service/ImageResize.php b/app/code/Magento/MediaStorage/Service/ImageResize.php new file mode 100644 index 0000000000000..37dd6f485f1a8 --- /dev/null +++ b/app/code/Magento/MediaStorage/Service/ImageResize.php @@ -0,0 +1,265 @@ +appState = $appState; + $this->imageConfig = $imageConfig; + $this->productImage = $productImage; + $this->imageFactory = $imageFactory; + $this->paramsBuilder = $paramsBuilder; + $this->viewConfig = $viewConfig; + $this->assertImageFactory = $assertImageFactory; + $this->themeCustomizationConfig = $themeCustomizationConfig; + $this->themeCollection = $themeCollection; + $this->mediaDirectory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $this->filesystem = $filesystem; + } + + /** + * Create resized images of different sizes from an original image + * @param string $originalImageName + * @throws NotFoundException + */ + public function resizeFromImageName(string $originalImageName) + { + $originalImagePath = $this->mediaDirectory->getAbsolutePath( + $this->imageConfig->getMediaPath($originalImageName) + ); + if (!$this->mediaDirectory->isFile($originalImagePath)) { + throw new NotFoundException(__('Cannot resize image "%1" - original image not found', $originalImagePath)); + } + foreach ($this->getViewImages($this->getThemesInUse()) as $viewImage) { + $this->resize($viewImage, $originalImagePath, $originalImageName); + } + } + + /** + * Create resized images of different sizes from themes + * @param array|null $themes + * @return \Generator + * @throws NotFoundException + */ + public function resizeFromThemes(array $themes = null): \Generator + { + $count = $this->productImage->getCountAllProductImages(); + if (!$count) { + throw new NotFoundException(__('Cannot resize images - product images not found')); + } + + $productImages = $this->productImage->getAllProductImages(); + $viewImages = $this->getViewImages($themes ?? $this->getThemesInUse()); + + foreach ($productImages as $image) { + $originalImageName = $image['filepath']; + $originalImagePath = $this->mediaDirectory->getAbsolutePath( + $this->imageConfig->getMediaPath($originalImageName) + ); + foreach ($viewImages as $viewImage) { + $this->resize($viewImage, $originalImagePath, $originalImageName); + } + yield $originalImageName => $count; + } + } + + /** + * Search the current theme + * @return array + */ + private function getThemesInUse(): array + { + $themesInUse = []; + $registeredThemes = $this->themeCollection->loadRegisteredThemes(); + $storesByThemes = $this->themeCustomizationConfig->getStoresByThemes(); + $keyType = is_integer(key($storesByThemes)) ? 'getId' : 'getCode'; + foreach ($registeredThemes as $registeredTheme) { + if (array_key_exists($registeredTheme->$keyType(), $storesByThemes)) { + $themesInUse[] = $registeredTheme; + } + } + return $themesInUse; + } + + /** + * Get view images data from themes + * @param array $themes + * @return array + */ + private function getViewImages(array $themes): array + { + $viewImages = []; + /** @var \Magento\Theme\Model\Theme $theme */ + foreach ($themes as $theme) { + $config = $this->viewConfig->getViewConfig([ + 'area' => Area::AREA_FRONTEND, + 'themeModel' => $theme, + ]); + $images = $config->getMediaEntities('Magento_Catalog', ImageHelper::MEDIA_TYPE_CONFIG_NODE); + foreach ($images as $imageId => $imageData) { + $uniqIndex = $this->getUniqImageIndex($imageData); + $imageData['id'] = $imageId; + $viewImages[$uniqIndex] = $imageData; + } + } + return $viewImages; + } + + /** + * Get uniq image index + * @param array $imageData + * @return string + */ + private function getUniqImageIndex(array $imageData): string + { + ksort($imageData); + unset($imageData['type']); + return md5(json_encode($imageData)); + } + + /** + * Make image + * @param string $originalImagePath + * @param array $imageParams + * @return Image + */ + private function makeImage(string $originalImagePath, array $imageParams): Image + { + $image = $this->imageFactory->create($originalImagePath); + $image->keepAspectRatio($imageParams['keep_aspect_ratio']); + $image->keepFrame($imageParams['keep_frame']); + $image->keepTransparency($imageParams['keep_transparency']); + $image->constrainOnly($imageParams['constrain_only']); + $image->backgroundColor($imageParams['background']); + $image->quality($imageParams['quality']); + return $image; + } + + /** + * Resize image + * @param array $viewImage + * @param string $originalImagePath + * @param string $originalImageName + */ + private function resize(array $viewImage, string $originalImagePath, string $originalImageName) + { + $imageParams = $this->paramsBuilder->build($viewImage); + $image = $this->makeImage($originalImagePath, $imageParams); + $imageAsset = $this->assertImageFactory->create( + [ + 'miscParams' => $imageParams, + 'filePath' => $originalImageName, + ] + ); + + if ($imageParams['image_width'] !== null && $imageParams['image_height'] !== null) { + $image->resize($imageParams['image_width'], $imageParams['image_height']); + } + $image->save($imageAsset->getPath()); + } +} diff --git a/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php b/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php index 46becd82343e9..a94bb8ec5e489 100644 --- a/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php +++ b/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php @@ -5,7 +5,10 @@ */ namespace Magento\MediaStorage\Test\Unit\App; +use Magento\Catalog\Model\View\Asset\Placeholder; +use Magento\Catalog\Model\View\Asset\PlaceholderFactory; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; /** * Class MediaTest @@ -91,20 +94,30 @@ protected function setUp() $this->filesystemMock->expects($this->any()) ->method('getDirectoryWrite') - ->with(DirectoryList::MEDIA) + ->with(DirectoryList::PUB) ->will($this->returnValue($this->directoryMock)); $this->responseMock = $this->createMock(\Magento\MediaStorage\Model\File\Storage\Response::class); - $this->model = new \Magento\MediaStorage\App\Media( - $this->configFactoryMock, - $this->syncFactoryMock, - $this->responseMock, - $this->closure, - self::MEDIA_DIRECTORY, - self::CACHE_FILE_PATH, - self::RELATIVE_FILE_PATH, - $this->filesystemMock + $objectManager = new ObjectManager($this); + $this->model = $objectManager->getObject( + \Magento\MediaStorage\App\Media::class, + [ + 'configFactory' => $this->configFactoryMock, + 'syncFactory' => $this->syncFactoryMock, + 'response' => $this->responseMock, + 'isAllowed' => $this->closure, + 'mediaDirectory' => false, + 'configCacheFile' => self::CACHE_FILE_PATH, + 'relativeFileName' => self::RELATIVE_FILE_PATH, + 'filesystem' => $this->filesystemMock, + 'placeholderFactory' => $this->createConfiguredMock( + PlaceholderFactory::class, + [ + 'create' => $this->createMock(Placeholder::class) + ] + ), + ] ); } @@ -115,15 +128,19 @@ protected function tearDown() public function testProcessRequestCreatesConfigFileMediaDirectoryIsNotProvided() { - $this->model = new \Magento\MediaStorage\App\Media( - $this->configFactoryMock, - $this->syncFactoryMock, - $this->responseMock, - $this->closure, - false, - self::CACHE_FILE_PATH, - self::RELATIVE_FILE_PATH, - $this->filesystemMock + $objectManager = new ObjectManager($this); + $this->model = $objectManager->getObject( + \Magento\MediaStorage\App\Media::class, + [ + 'configFactory' => $this->configFactoryMock, + 'syncFactory' => $this->syncFactoryMock, + 'response' => $this->responseMock, + 'isAllowed' => $this->closure, + 'mediaDirectory' => false, + 'configCacheFile' => self::CACHE_FILE_PATH, + 'relativeFileName' => self::RELATIVE_FILE_PATH, + 'filesystem' => $this->filesystemMock + ] ); $filePath = '/absolute/path/to/test/file.png'; $this->directoryMock->expects($this->any()) @@ -144,33 +161,6 @@ public function testProcessRequestCreatesConfigFileMediaDirectoryIsNotProvided() $this->model->launch(); } - /** - * @expectedException \LogicException - * @expectedExceptionMessage The specified path is not allowed. - */ - public function testProcessRequestReturnsNotFoundResponseIfResourceIsNotAllowed() - { - $this->closure = function () { - return false; - }; - $this->model = new \Magento\MediaStorage\App\Media( - $this->configFactoryMock, - $this->syncFactoryMock, - $this->responseMock, - $this->closure, - false, - self::CACHE_FILE_PATH, - self::RELATIVE_FILE_PATH, - $this->filesystemMock - ); - $this->directoryMock->expects($this->once()) - ->method('getAbsolutePath') - ->with() - ->will($this->returnValue(self::MEDIA_DIRECTORY)); - $this->configMock->expects($this->once())->method('getAllowedResources')->will($this->returnValue(false)); - $this->model->launch(); - } - public function testProcessRequestReturnsFileIfItsProperlySynchronized() { $filePath = '/absolute/path/to/test/file.png'; @@ -202,7 +192,6 @@ public function testProcessRequestReturnsNotFoundIfFileIsNotSynchronized() ->method('isReadable') ->with(self::RELATIVE_FILE_PATH) ->will($this->returnValue(false)); - $this->responseMock->expects($this->once())->method('setHttpResponseCode')->with(404); $this->assertSame($this->responseMock, $this->model->launch()); } diff --git a/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php b/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php index f58a11540dc8b..f3ebf57546950 100644 --- a/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php +++ b/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php @@ -5,6 +5,8 @@ */ namespace Magento\MediaStorage\Test\Unit\Model\File\Storage; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; + class SynchronizationTest extends \PHPUnit\Framework\TestCase { public function testSynchronize() @@ -40,7 +42,11 @@ public function testSynchronize() ->with($relativeFileName) ->will($this->returnValue($file)); - $model = new \Magento\MediaStorage\Model\File\Storage\Synchronization($storageFactoryMock, $directory); + $objectManager = new ObjectManager($this); + $model = $objectManager->getObject(\Magento\MediaStorage\Model\File\Storage\Synchronization::class, [ + 'storageFactory' => $storageFactoryMock, + 'directory' => $directory, + ]); $model->synchronize($relativeFileName); } } diff --git a/app/code/Magento/MediaStorage/composer.json b/app/code/Magento/MediaStorage/composer.json index c796ceb7f9a46..7d27c88b3dcb2 100644 --- a/app/code/Magento/MediaStorage/composer.json +++ b/app/code/Magento/MediaStorage/composer.json @@ -5,11 +5,13 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-config": "*", - "magento/module-store": "*" + "magento/module-store": "*", + "magento/module-catalog": "*", + "magento/module-theme": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/MediaStorage/etc/di.xml b/app/code/Magento/MediaStorage/etc/di.xml index 8f491f5b84644..2b9317787463d 100644 --- a/app/code/Magento/MediaStorage/etc/di.xml +++ b/app/code/Magento/MediaStorage/etc/di.xml @@ -19,4 +19,11 @@ + + + + Magento\MediaStorage\Console\Command\ImagesResizeCommand + + + diff --git a/app/code/Magento/MediaStorage/etc/module.xml b/app/code/Magento/MediaStorage/etc/module.xml index 6a04d4641e66d..df4acf8904fbc 100644 --- a/app/code/Magento/MediaStorage/etc/module.xml +++ b/app/code/Magento/MediaStorage/etc/module.xml @@ -6,5 +6,10 @@ */ --> - + + + + + + diff --git a/app/code/Magento/MessageQueue/composer.json b/app/code/Magento/MessageQueue/composer.json index 72eee88105e5b..5cdf7351bbb61 100644 --- a/app/code/Magento/MessageQueue/composer.json +++ b/app/code/Magento/MessageQueue/composer.json @@ -7,7 +7,7 @@ "require": { "magento/framework": "*", "magento/magento-composer-installer": "*", - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0" + "php": "~7.1.3||~7.2.0" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Msrp/composer.json b/app/code/Magento/Msrp/composer.json index e098252a21cb5..6e7bf61063a2a 100644 --- a/app/code/Magento/Msrp/composer.json +++ b/app/code/Magento/Msrp/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-downloadable": "*", @@ -16,7 +16,7 @@ }, "suggest": { "magento/module-bundle": "*", - "magento/module-msrp-sample-data": "Sample Data version:100.3.*" + "magento/module-msrp-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Multishipping/Block/Checkout/AbstractMultishipping.php b/app/code/Magento/Multishipping/Block/Checkout/AbstractMultishipping.php index 0de66ccd505a4..d3c17a8d7c8de 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/AbstractMultishipping.php +++ b/app/code/Magento/Multishipping/Block/Checkout/AbstractMultishipping.php @@ -5,7 +5,7 @@ */ /** - * Mustishipping checkout base abstract block + * Multishipping checkout base abstract block * * @author Magento Core Team */ diff --git a/app/code/Magento/Multishipping/Block/Checkout/Overview.php b/app/code/Magento/Multishipping/Block/Checkout/Overview.php index c62226dc8d063..5963e62e948f9 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/Overview.php +++ b/app/code/Magento/Multishipping/Block/Checkout/Overview.php @@ -120,11 +120,7 @@ public function getPaymentHtml() */ public function getPayment() { - if (!$this->hasData('payment')) { - $payment = new \Magento\Framework\DataObject($this->getRequest()->getPost('payment')); - $this->setData('payment', $payment); - } - return $this->_getData('payment'); + return $this->getCheckout()->getQuote()->getPayment(); } /** @@ -200,9 +196,9 @@ public function formatPrice($price) /** * @param Address $address - * @return mixed + * @return array */ - public function getShippingAddressItems($address) + public function getShippingAddressItems($address): array { return $address->getAllVisibleItems(); } @@ -309,16 +305,7 @@ public function getVirtualProductEditUrl() */ public function getVirtualItems() { - $items = []; - foreach ($this->getBillingAddress()->getItemsCollection() as $_item) { - if ($_item->isDeleted()) { - continue; - } - if ($_item->getProduct()->getIsVirtual() && !$_item->getParentItemId()) { - $items[] = $_item; - } - } - return $items; + return $this->getBillingAddress()->getAllVisibleItems(); } /** @@ -332,9 +319,19 @@ public function getQuote() } /** + * @deprecated + * typo in method name, see getBillingAddressTotals() * @return mixed */ public function getBillinAddressTotals() + { + return $this->getBillingAddressTotals(); + } + + /** + * @return mixed + */ + public function getBillingAddressTotals() { $address = $this->getQuote()->getBillingAddress(); return $this->getShippingAddressTotals($address); diff --git a/app/code/Magento/Multishipping/Block/Checkout/Results.php b/app/code/Magento/Multishipping/Block/Checkout/Results.php new file mode 100644 index 0000000000000..35c050d5ff8c1 --- /dev/null +++ b/app/code/Magento/Multishipping/Block/Checkout/Results.php @@ -0,0 +1,186 @@ +addressConfig = $addressConfig; + $this->orderRepository = $orderRepository; + $this->session = $session; + } + + /** + * Returns shipping addresses from quote. + * + * @return array + */ + public function getQuoteShippingAddresses(): array + { + return $this->_multishipping->getQuote()->getAllShippingAddresses(); + } + + /** + * Returns all failed addresses from quote. + * + * @return array + */ + public function getFailedAddresses(): array + { + $addresses = $this->getQuoteShippingAddresses(); + if ($this->getAddressError($this->getQuoteBillingAddress())) { + $addresses[] = $this->getQuoteBillingAddress(); + } + return $addresses; + } + + /** + * Retrieve order shipping address. + * + * @param int $orderId + * @return OrderAddress|null + */ + public function getOrderShippingAddress(int $orderId) + { + return $this->orderRepository->get($orderId)->getShippingAddress(); + } + + /** + * Retrieve quote billing address. + * + * @return QuoteAddress + */ + public function getQuoteBillingAddress(): QuoteAddress + { + return $this->getCheckout()->getQuote()->getBillingAddress(); + } + + /** + * Returns formatted shipping address from placed order. + * + * @param OrderAddress $address + * @return string + */ + public function formatOrderShippingAddress(OrderAddress $address): string + { + return $this->getAddressOneline($address->getData()); + } + + /** + * Returns formatted shipping address from quote. + * + * @param QuoteAddress $address + * @return string + */ + public function formatQuoteShippingAddress(QuoteAddress $address): string + { + return $this->getAddressOneline($address->getData()); + } + + /** + * Checks if address type is shipping. + * + * @param QuoteAddress $address + * @return bool + */ + public function isShippingAddress(QuoteAddress $address): bool + { + return $address->getAddressType() === QuoteAddress::ADDRESS_TYPE_SHIPPING; + } + + /** + * Get unescaped address formatted as one line string. + * + * @param array $address + * @return string + */ + private function getAddressOneline(array $address): string + { + $renderer = $this->addressConfig->getFormatByCode('oneline')->getRenderer(); + + return $renderer->renderArray($address); + } + + /** + * Returns address error. + * + * @param QuoteAddress $address + * @return string + */ + public function getAddressError(QuoteAddress $address): string + { + $errors = $this->session->getAddressErrors(); + + return $errors[$address->getId()] ?? ''; + } + + /** + * Add title to block head. + * + * @throws LocalizedException + * @return Success + */ + protected function _prepareLayout(): Success + { + /** @var Title $pageTitle */ + $pageTitle = $this->getLayout()->getBlock('page.main.title'); + if ($pageTitle) { + $title = $this->getOrderIds() ? $pageTitle->getPartlySuccessTitle() : $pageTitle->getFailedTitle(); + $pageTitle->setPageTitle($title); + } + + return parent::_prepareLayout(); + } +} diff --git a/app/code/Magento/Multishipping/Block/Checkout/Success.php b/app/code/Magento/Multishipping/Block/Checkout/Success.php index 3f18132d04a45..0f39e03c56c63 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/Success.php +++ b/app/code/Magento/Multishipping/Block/Checkout/Success.php @@ -36,7 +36,7 @@ public function __construct( */ public function getOrderIds() { - $ids = $this->_session->getOrderIds(true); + $ids = $this->_session->getOrderIds(); if ($ids && is_array($ids)) { return $ids; } diff --git a/app/code/Magento/Multishipping/Block/DataProviders/Billing.php b/app/code/Magento/Multishipping/Block/DataProviders/Billing.php new file mode 100644 index 0000000000000..684ed32d9f112 --- /dev/null +++ b/app/code/Magento/Multishipping/Block/DataProviders/Billing.php @@ -0,0 +1,76 @@ +addressConfig = $addressConfig; + $this->configProvider = $configProvider; + $this->serializer = $serializer; + } + + /** + * Get address formatted as html string. + * + * @param Address $address + * @return string + */ + public function getAddressHtml(Address $address): string + { + $renderer = $this->addressConfig->getFormatByCode('html')->getRenderer(); + + return $renderer->renderArray($address->getData()); + } + + /** + * Returns serialized checkout config. + * + * @return string + * @throws \InvalidArgumentException + */ + public function getSerializedCheckoutConfigs(): string + { + return $this->serializer->serialize($this->configProvider->getConfig()); + } +} diff --git a/app/code/Magento/Multishipping/Block/DataProviders/Overview.php b/app/code/Magento/Multishipping/Block/DataProviders/Overview.php new file mode 100644 index 0000000000000..e630f14f61c79 --- /dev/null +++ b/app/code/Magento/Multishipping/Block/DataProviders/Overview.php @@ -0,0 +1,75 @@ +session = $session; + } + + /** + * Returns address error. + * + * @param Address $address + * @return string + */ + public function getAddressError(Address $address): string + { + $addressErrors = $this->getAddressErrors(); + + return $addressErrors[$address->getId()] ?? ''; + } + + /** + * Returns all stored errors. + * + * @return array + */ + public function getAddressErrors(): array + { + if (empty($this->addressErrors)) { + $this->addressErrors = $this->session->getAddressErrors(true); + } + + return $this->addressErrors ?? []; + } + + /** + * Creates anchor name for address Id. + * + * @param int $addressId + * @return string + */ + public function getAddressAnchorName(int $addressId): string + { + return 'a' . $addressId; + } +} diff --git a/app/code/Magento/Multishipping/Block/DataProviders/Success.php b/app/code/Magento/Multishipping/Block/DataProviders/Success.php new file mode 100644 index 0000000000000..4b7543c9f72fd --- /dev/null +++ b/app/code/Magento/Multishipping/Block/DataProviders/Success.php @@ -0,0 +1,19 @@ +getRequest()->getPost('payment', []); - $payment['checks'] = [ - \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_COUNTRY, - \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_CURRENCY, - \Magento\Payment\Model\Method\AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, - \Magento\Payment\Model\Method\AbstractMethod::CHECK_ZERO_TOTAL, - ]; - $this->_getCheckout()->setPaymentMethod($payment); - + if (!empty($payment)) { + $payment['checks'] = [ + AbstractMethod::CHECK_USE_FOR_COUNTRY, + AbstractMethod::CHECK_USE_FOR_CURRENCY, + AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, + AbstractMethod::CHECK_ZERO_TOTAL, + ]; + $this->_getCheckout()->setPaymentMethod($payment); + } $this->_getState()->setCompleteStep(State::STEP_BILLING); $this->_view->loadLayout(); $this->_view->renderLayout(); - } catch (\Magento\Framework\Exception\LocalizedException $e) { + } catch (LocalizedException $e) { $this->messageManager->addError($e->getMessage()); $this->_redirect('*/*/billing'); } catch (\Exception $e) { - $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); + $this->_objectManager->get(LoggerInterface::class)->critical($e); $this->messageManager->addException($e, __('We cannot open the overview page.')); $this->_redirect('*/*/billing'); } diff --git a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php index d23f8863d0728..f05a7f43b8118 100644 --- a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php +++ b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php @@ -9,6 +9,7 @@ use Magento\Customer\Api\AccountManagementInterface; use Magento\Customer\Api\CustomerRepositoryInterface; use Magento\Framework\Exception\PaymentException; +use Magento\Framework\Session\SessionManagerInterface; /** * Class OverviewPost @@ -32,6 +33,11 @@ class OverviewPost extends \Magento\Multishipping\Controller\Checkout */ protected $agreementsValidator; + /** + * @var SessionManagerInterface + */ + private $session; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Customer\Model\Session $customerSession @@ -40,6 +46,7 @@ class OverviewPost extends \Magento\Multishipping\Controller\Checkout * @param \Magento\Framework\Data\Form\FormKey\Validator $formKeyValidator * @param \Psr\Log\LoggerInterface $logger * @param \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + * @param SessionManagerInterface $session */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -48,11 +55,14 @@ public function __construct( AccountManagementInterface $accountManagement, \Magento\Framework\Data\Form\FormKey\Validator $formKeyValidator, \Psr\Log\LoggerInterface $logger, - \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator, + SessionManagerInterface $session ) { $this->formKeyValidator = $formKeyValidator; $this->logger = $logger; $this->agreementsValidator = $agreementValidator; + $this->session = $session; + parent::__construct( $context, $customerSession, @@ -95,11 +105,17 @@ public function execute() $paymentInstance->setCcCid($payment['cc_cid']); } $this->_getCheckout()->createOrders(); - $this->_getState()->setActiveStep(State::STEP_SUCCESS); $this->_getState()->setCompleteStep(State::STEP_OVERVIEW); - $this->_getCheckout()->getCheckoutSession()->clearQuote(); - $this->_getCheckout()->getCheckoutSession()->setDisplaySuccess(true); - $this->_redirect('*/*/success'); + + if ($this->session->getAddressErrors()) { + $this->_getState()->setActiveStep(State::STEP_RESULTS); + $this->_redirect('*/*/results'); + } else { + $this->_getState()->setActiveStep(State::STEP_SUCCESS); + $this->_getCheckout()->getCheckoutSession()->clearQuote(); + $this->_getCheckout()->getCheckoutSession()->setDisplaySuccess(true); + $this->_redirect('*/*/success'); + } } catch (PaymentException $e) { $message = $e->getMessage(); if (!empty($message)) { diff --git a/app/code/Magento/Multishipping/Controller/Checkout/Results.php b/app/code/Magento/Multishipping/Controller/Checkout/Results.php new file mode 100644 index 0000000000000..1a18daf02ebd8 --- /dev/null +++ b/app/code/Magento/Multishipping/Controller/Checkout/Results.php @@ -0,0 +1,16 @@ +state = $state; + $this->multishipping = $multishipping; + + parent::__construct($context); + } + /** * Multishipping checkout success page * @@ -17,13 +49,13 @@ class Success extends \Magento\Multishipping\Controller\Checkout */ public function execute() { - if (!$this->_getState()->getCompleteStep(State::STEP_OVERVIEW)) { + if (!$this->state->getCompleteStep(State::STEP_OVERVIEW)) { $this->_redirect('*/*/addresses'); return; } $this->_view->loadLayout(); - $ids = $this->_getCheckout()->getOrderIds(); + $ids = $this->multishipping->getOrderIds(); $this->_eventManager->dispatch('multishipping_checkout_controller_success_action', ['order_ids' => $ids]); $this->_view->renderLayout(); } diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php index fdbe1d24ba1f5..9412b13895599 100644 --- a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php @@ -7,11 +7,14 @@ namespace Magento\Multishipping\Model\Checkout\Type; use Magento\Customer\Api\AddressRepositoryInterface; +use Magento\Framework\Exception\NotFoundException; use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Model\Order\Email\Sender\OrderSender; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\App\ObjectManager; use Magento\Directory\Model\AllowedCountries; +use Psr\Log\LoggerInterface; /** * Multishipping checkout model @@ -157,6 +160,21 @@ class Multishipping extends \Magento\Framework\DataObject */ private $shippingAssignmentProcessor; + /** + * @var Multishipping\PlaceOrderFactory + */ + private $placeOrderFactory; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var \Magento\Framework\Api\DataObjectHelper + */ + private $dataObjectHelper; + /** * Constructor * @@ -184,6 +202,8 @@ class Multishipping extends \Magento\Framework\DataObject * @param array $data * @param \Magento\Quote\Api\Data\CartExtensionFactory|null $cartExtensionFactory * @param AllowedCountries|null $allowedCountryReader + * @param Multishipping\PlaceOrderFactory $placeOrderFactory + * @param LoggerInterface $logger * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -210,7 +230,10 @@ public function __construct( \Magento\Quote\Model\Quote\TotalsCollector $totalsCollector, array $data = [], \Magento\Quote\Api\Data\CartExtensionFactory $cartExtensionFactory = null, - AllowedCountries $allowedCountryReader = null + AllowedCountries $allowedCountryReader = null, + Multishipping\PlaceOrderFactory $placeOrderFactory = null, + LoggerInterface $logger = null, + \Magento\Framework\Api\DataObjectHelper $dataObjectHelper = null ) { $this->_eventManager = $eventManager; $this->_scopeConfig = $scopeConfig; @@ -237,6 +260,12 @@ public function __construct( ->get(\Magento\Quote\Api\Data\CartExtensionFactory::class); $this->allowedCountryReader = $allowedCountryReader ?: ObjectManager::getInstance() ->get(AllowedCountries::class); + $this->placeOrderFactory = $placeOrderFactory ?: ObjectManager::getInstance() + ->get(Multishipping\PlaceOrderFactory::class); + $this->logger = $logger ?: ObjectManager::getInstance() + ->get(LoggerInterface::class); + $this->dataObjectHelper = $dataObjectHelper ?: ObjectManager::getInstance() + ->get(\Magento\Framework\Api\DataObjectHelper::class); parent::__construct($data); $this->_init(); } @@ -649,7 +678,14 @@ protected function _prepareOrder(\Magento\Quote\Model\Quote\Address $address) $quote->reserveOrderId(); $quote->collectTotals(); - $order = $this->quoteAddressToOrder->convert($address); + $order = $this->_orderFactory->create(); + + $this->dataObjectHelper->mergeDataObjects( + \Magento\Sales\Api\Data\OrderInterface::class, + $order, + $this->quoteAddressToOrder->convert($address) + ); + $order->setQuote($quote); $order->setBillingAddress($this->quoteAddressToOrderAddress->convert($quote->getBillingAddress())); @@ -764,21 +800,48 @@ public function createOrders() ); } + $paymentProviderCode = $this->getQuote()->getPayment()->getMethod(); + $placeOrderService = $this->placeOrderFactory->create($paymentProviderCode); + $exceptionList = $placeOrderService->place($orders); + $this->logExceptions($exceptionList); + + /** @var OrderInterface[] $failedOrders */ + $failedOrders = []; + /** @var OrderInterface[] $successfulOrders */ + $successfulOrders = []; foreach ($orders as $order) { - $order->place(); - $order->save(); + if (isset($exceptionList[$order->getIncrementId()])) { + $failedOrders[] = $order; + } else { + $successfulOrders[] = $order; + } + } + + $placedAddressItems = []; + foreach ($successfulOrders as $order) { + $orderIds[$order->getId()] = $order->getIncrementId(); if ($order->getCanSendNewEmailFlag()) { $this->orderSender->send($order); } - $orderIds[$order->getId()] = $order->getIncrementId(); + $placedAddressItems = array_merge($placedAddressItems, $this->getQuoteAddressItems($order)); } - $this->_session->setOrderIds($orderIds); - $this->_checkoutSession->setLastQuoteId($this->getQuote()->getId()); - - $this->getQuote()->setIsActive(false); - $this->quoteRepository->save($this->getQuote()); + $addressErrors = []; + if (!empty($failedOrders)) { + $this->removePlacedItemsFromQuote($shippingAddresses, $placedAddressItems); + $addressErrors = $this->getQuoteAddressErrors( + $failedOrders, + $shippingAddresses, + $exceptionList + ); + } else { + $this->_checkoutSession->setLastQuoteId($this->getQuote()->getId()); + $this->getQuote()->setIsActive(false); + $this->quoteRepository->save($this->getQuote()); + } + $this->_session->setOrderIds($orderIds); + $this->_session->setAddressErrors($addressErrors); $this->_eventManager->dispatch( 'checkout_submit_all_after', ['orders' => $orders, 'quote' => $this->getQuote()] @@ -791,6 +854,19 @@ public function createOrders() } } + /** + * Logs exceptions. + * + * @param \Exception[] $exceptionList + * @return void + */ + private function logExceptions(array $exceptionList) + { + foreach ($exceptionList as $exception) { + $this->logger->critical($exception); + } + } + /** * Collect quote totals and save quote object * @@ -877,7 +953,10 @@ public function getMinimumAmountError() public function getOrderIds($asAssoc = false) { $idsAssoc = $this->_session->getOrderIds(); - return $asAssoc ? $idsAssoc : array_keys($idsAssoc); + if ($idsAssoc !== null) { + return $asAssoc ? $idsAssoc : array_keys($idsAssoc); + } + return []; } /** @@ -1035,4 +1114,112 @@ private function getShippingAssignmentProcessor() } return $this->shippingAssignmentProcessor; } + + /** + * Remove successfully placed items from quote. + * + * @param \Magento\Quote\Model\Quote\Address[] $shippingAddresses + * @param int[] $placedAddressItems + * @return void + */ + private function removePlacedItemsFromQuote(array $shippingAddresses, array $placedAddressItems) + { + foreach ($shippingAddresses as $address) { + foreach ($address->getAllItems() as $addressItem) { + if (in_array($addressItem->getId(), $placedAddressItems)) { + if ($addressItem->getProduct()->getIsVirtual()) { + $addressItem->isDeleted(true); + } else { + $address->isDeleted(true); + } + + $this->decreaseQuoteItemQty($addressItem->getQuoteItemId(), $addressItem->getQty()); + } + } + } + $this->save(); + } + + /** + * Decrease quote item quantity. + * + * @param int $quoteItemId + * @param int $qty + * @return void + */ + private function decreaseQuoteItemQty(int $quoteItemId, int $qty) + { + $quoteItem = $this->getQuote()->getItemById($quoteItemId); + if ($quoteItem) { + $newItemQty = $quoteItem->getQty() - $qty; + if ($newItemQty > 0) { + $quoteItem->setQty($newItemQty); + } else { + $this->getQuote()->removeItem($quoteItem->getId()); + $this->getQuote()->setIsMultiShipping(1); + } + } + } + + /** + * Returns quote address id that was assigned to order. + * + * @param OrderInterface $order + * @param \Magento\Quote\Model\Quote\Address[] $addresses + * + * @return int + * @throws NotFoundException + */ + private function searchQuoteAddressId(OrderInterface $order, array $addresses): int + { + $items = $order->getItems(); + $item = array_pop($items); + foreach ($addresses as $address) { + foreach ($address->getAllItems() as $addressItem) { + if ($addressItem->getId() == $item->getQuoteItemId()) { + return (int)$address->getId(); + } + } + } + + throw new NotFoundException(__('Quote address for failed order not found.')); + } + + /** + * @param OrderInterface[] $orders + * @param \Magento\Quote\Model\Quote\Address[] $addresses + * @param \Exception[] $exceptionList + * + * @return string[] + * @throws NotFoundException + */ + private function getQuoteAddressErrors(array $orders, array $addresses, array $exceptionList): array + { + $addressErrors = []; + foreach ($orders as $failedOrder) { + if (!isset($exceptionList[$failedOrder->getIncrementId()])) { + throw new NotFoundException(__('Exception for failed order not found.')); + } + $addressId = $this->searchQuoteAddressId($failedOrder, $addresses); + $addressErrors[$addressId] = $exceptionList[$failedOrder->getIncrementId()]->getMessage(); + } + + return $addressErrors; + } + + /** + * Returns quote address item id. + * + * @param OrderInterface $order + * @return array + */ + private function getQuoteAddressItems(OrderInterface $order): array + { + $placedAddressItems = []; + foreach ($order->getItems() as $orderItem) { + $placedAddressItems[] = $orderItem->getQuoteItemId(); + } + + return $placedAddressItems; + } } diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderDefault.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderDefault.php new file mode 100644 index 0000000000000..584003fcf9abb --- /dev/null +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderDefault.php @@ -0,0 +1,48 @@ +orderManagement = $orderManagement; + } + + /** + * {@inheritdoc} + */ + public function place(array $orderList): array + { + $errorList = []; + foreach ($orderList as $order) { + try { + $this->orderManagement->place($order); + } catch (\Exception $e) { + $incrementId = $order->getIncrementId(); + $errorList[$incrementId] = $e; + } + } + + return $errorList; + } +} diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderFactory.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderFactory.php new file mode 100644 index 0000000000000..cf672d2898820 --- /dev/null +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderFactory.php @@ -0,0 +1,52 @@ +objectManager = $objectManager; + $this->placeOrderPool = $placeOrderPool; + } + + /** + * @param string $paymentProviderCode + * @return PlaceOrderInterface + */ + public function create(string $paymentProviderCode): PlaceOrderInterface + { + $service = $this->placeOrderPool->get($paymentProviderCode); + if ($service === null) { + $service = $this->objectManager->get(PlaceOrderDefault::class); + } + + return $service; + } +} diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderInterface.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderInterface.php new file mode 100644 index 0000000000000..5d384a5373d5e --- /dev/null +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderInterface.php @@ -0,0 +1,26 @@ +services = $tmapFactory->createSharedObjectsMap( + [ + 'array' => $services, + 'type' => PlaceOrderInterface::class + ] + ); + } + + /** + * Returns place order service for defined payment provider. + * + * @param string $paymentProviderCode + * @return PlaceOrderInterface|null + */ + public function get(string $paymentProviderCode) + { + if (!isset($this->services[$paymentProviderCode])) { + return null; + } + + return $this->services[$paymentProviderCode]; + } +} diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php index 2615ad1b540c8..2e53817351d3c 100644 --- a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php @@ -25,6 +25,8 @@ class State extends \Magento\Framework\DataObject const STEP_SUCCESS = 'multishipping_success'; + const STEP_RESULTS = 'multishipping_results'; + /** * Allow steps array * @@ -61,6 +63,7 @@ public function __construct(Session $checkoutSession, Multishipping $multishippi self::STEP_BILLING => new \Magento\Framework\DataObject(['label' => __('Billing Information')]), self::STEP_OVERVIEW => new \Magento\Framework\DataObject(['label' => __('Place Order')]), self::STEP_SUCCESS => new \Magento\Framework\DataObject(['label' => __('Order Success')]), + self::STEP_RESULTS => new \Magento\Framework\DataObject(['label' => __('Order Results')]), ]; foreach ($this->_steps as $step) { diff --git a/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php b/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php index a5074dbebf7fa..273ee41854cc5 100644 --- a/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php +++ b/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php @@ -73,14 +73,14 @@ protected function setUp() public function testGetOrderIdsWithoutId() { - $this->sessionMock->expects($this->once())->method('getOrderIds')->with(true)->will($this->returnValue(null)); + $this->sessionMock->method('getOrderIds')->willReturn(null); $this->assertFalse($this->model->getOrderIds()); } public function testGetOrderIdsWithEmptyIdsArray() { - $this->sessionMock->expects($this->once())->method('getOrderIds')->with(true)->will($this->returnValue([])); + $this->sessionMock->method('getOrderIds')->willReturn([]); $this->assertFalse($this->model->getOrderIds()); } @@ -88,7 +88,7 @@ public function testGetOrderIdsWithEmptyIdsArray() public function testGetOrderIds() { $ids = [100, 102, 103]; - $this->sessionMock->expects($this->once())->method('getOrderIds')->with(true)->will($this->returnValue($ids)); + $this->sessionMock->method('getOrderIds')->willReturn($ids); $this->assertEquals($ids, $this->model->getOrderIds()); } diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderDefaultTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderDefaultTest.php new file mode 100644 index 0000000000000..1878b5edb17ac --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderDefaultTest.php @@ -0,0 +1,73 @@ +orderManagement = $this->getMockForAbstractClass(OrderManagementInterface::class); + + $this->placeOrderDefault = new PlaceOrderDefault($this->orderManagement); + } + + public function testPlace() + { + $incrementId = '000000001'; + + $order = $this->getMockForAbstractClass(OrderInterface::class); + $order->method('getIncrementId')->willReturn($incrementId); + $orderList = [$order]; + + $this->orderManagement->expects($this->once()) + ->method('place') + ->with($order) + ->willReturn($order); + $errors = $this->placeOrderDefault->place($orderList); + + $this->assertEmpty($errors); + } + + public function testPlaceWithErrors() + { + $incrementId = '000000001'; + + $order = $this->getMockForAbstractClass(OrderInterface::class); + $order->method('getIncrementId')->willReturn($incrementId); + $orderList = [$order]; + + $exception = new \Exception('error'); + $this->orderManagement->method('place')->willThrowException($exception); + $errors = $this->placeOrderDefault->place($orderList); + + $this->assertEquals( + [$incrementId => $exception], + $errors + ); + } +} diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderFactoryTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderFactoryTest.php new file mode 100644 index 0000000000000..d6363e0acbd35 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderFactoryTest.php @@ -0,0 +1,92 @@ +objectManager = $this->getMockForAbstractClass(ObjectManagerInterface::class); + + $this->placeOrderPool = $this->getMockBuilder(PlaceOrderPool::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->placeOrderFactory = new PlaceOrderFactory($this->objectManager, $this->placeOrderPool); + } + + /** + * Checks instantiation of place order service. + * + * @return void + */ + public function testCreate() + { + $paymentProviderCode = 'code'; + + $placeOrder = $this->getMockForAbstractClass(PlaceOrderInterface::class); + $this->placeOrderPool->method('get') + ->with($paymentProviderCode) + ->willReturn($placeOrder); + + $instance = $this->placeOrderFactory->create($paymentProviderCode); + + $this->assertInstanceOf(PlaceOrderInterface::class, $instance); + } + + /** + * Checks that default place order service is created when place order pull returns null. + * + * @return void + */ + public function testCreateWithDefault() + { + $paymentProviderCode = 'code'; + + $this->placeOrderPool->method('get') + ->with($paymentProviderCode) + ->willReturn(null); + $placeOrder = $this->getMockBuilder(PlaceOrderDefault::class) + ->disableOriginalConstructor() + ->getMock(); + $this->objectManager->method('get') + ->with(PlaceOrderDefault::class) + ->willReturn($placeOrder); + + $instance = $this->placeOrderFactory->create($paymentProviderCode); + + $this->assertInstanceOf(PlaceOrderDefault::class, $instance); + } +} diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderPoolTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderPoolTest.php new file mode 100644 index 0000000000000..083160b10fda9 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderPoolTest.php @@ -0,0 +1,54 @@ +getMockBuilder(TMapFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $tMapFactory->method('createSharedObjectsMap')->willReturn($placeOrderList); + + $placeOrderPool = new PlaceOrderPool($tMapFactory); + $result = $placeOrderPool->get($paymentProviderCode); + + $this->assertEquals($expectedResult, $result); + } + + /** + * @return array + */ + public function getDataProvider(): array + { + $placeOrder = $this->getMockForAbstractClass(PlaceOrderInterface::class); + $placeOrderList = ['payment_code' => $placeOrder]; + + return [ + 'code exists in pool' => ['payment_code', $placeOrderList, $placeOrder], + 'no code in pool' => ['some_code', $placeOrderList, null], + ]; + } +} diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/MultishippingTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/MultishippingTest.php index b2e484e148f43..94c11bef1d311 100644 --- a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/MultishippingTest.php +++ b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/MultishippingTest.php @@ -11,7 +11,9 @@ use Magento\Customer\Api\Data\AddressInterface; use Magento\Customer\Api\Data\AddressSearchResultsInterface; use Magento\Customer\Api\Data\CustomerInterface; -use Magento\Customer\Model\Data\Address; +use Magento\Multishipping\Model\Checkout\Type\Multishipping\PlaceOrderDefault; +use Magento\Multishipping\Model\Checkout\Type\Multishipping\PlaceOrderFactory; +use Magento\Quote\Model\Quote\Address; use Magento\Customer\Model\Session as CustomerSession; use Magento\Framework\Api\FilterBuilder; use Magento\Framework\Api\SearchCriteria; @@ -51,6 +53,7 @@ /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) */ class MultishippingTest extends \PHPUnit\Framework\TestCase { @@ -119,24 +122,69 @@ class MultishippingTest extends \PHPUnit\Framework\TestCase */ private $quoteRepositoryMock; + /** + * @var OrderFactory|PHPUnit_Framework_MockObject_MockObject + */ + private $orderFactoryMock; + + /** + * @var \Magento\Framework\Api\DataObjectHelper|PHPUnit_Framework_MockObject_MockObject + */ + private $dataObjectHelperMock; + + /** + * @var ToOrder|PHPUnit_Framework_MockObject_MockObject + */ + private $toOrderMock; + + /** + * @var ToOrderAddress|PHPUnit_Framework_MockObject_MockObject + */ + private $toOrderAddressMock; + + /** + * @var ToOrderPayment|PHPUnit_Framework_MockObject_MockObject + */ + private $toOrderPaymentMock; + + /** + * @var PriceCurrencyInterface|PHPUnit_Framework_MockObject_MockObject + */ + private $priceMock; + + /** + * @var ToOrderItem|PHPUnit_Framework_MockObject_MockObject + */ + private $toOrderItemMock; + + /** + * @var PlaceOrderFactory|PHPUnit_Framework_MockObject_MockObject + */ + private $placeOrderFactoryMock; + + /** + * @var Generic|PHPUnit_Framework_MockObject_MockObject + */ + private $sessionMock; + protected function setUp() { $this->checkoutSessionMock = $this->createSimpleMock(Session::class); $this->customerSessionMock = $this->createSimpleMock(CustomerSession::class); - $orderFactoryMock = $this->createSimpleMock(OrderFactory::class); + $this->orderFactoryMock = $this->createSimpleMock(OrderFactory::class); $eventManagerMock = $this->createSimpleMock(ManagerInterface::class); $scopeConfigMock = $this->createSimpleMock(ScopeConfigInterface::class); - $sessionMock = $this->createSimpleMock(Generic::class); + $this->sessionMock = $this->createSimpleMock(Generic::class); $addressFactoryMock = $this->createSimpleMock(AddressFactory::class); - $toOrderMock = $this->createSimpleMock(ToOrder::class); - $toOrderAddressMock = $this->createSimpleMock(ToOrderAddress::class); - $toOrderPaymentMock = $this->createSimpleMock(ToOrderPayment::class); - $toOrderItemMock = $this->createSimpleMock(ToOrderItem::class); + $this->toOrderMock = $this->createSimpleMock(ToOrder::class); + $this->toOrderAddressMock = $this->createSimpleMock(ToOrderAddress::class); + $this->toOrderPaymentMock = $this->createSimpleMock(ToOrderPayment::class); + $this->toOrderItemMock = $this->createSimpleMock(ToOrderItem::class); $storeManagerMock = $this->createSimpleMock(StoreManagerInterface::class); $paymentSpecMock = $this->createSimpleMock(SpecificationInterface::class); $this->helperMock = $this->createSimpleMock(Data::class); $orderSenderMock = $this->createSimpleMock(OrderSender::class); - $priceMock = $this->createSimpleMock(PriceCurrencyInterface::class); + $this->priceMock = $this->createSimpleMock(PriceCurrencyInterface::class); $this->quoteRepositoryMock = $this->createSimpleMock(CartRepositoryInterface::class); $this->filterBuilderMock = $this->createSimpleMock(FilterBuilder::class); $this->searchCriteriaBuilderMock = $this->createSimpleMock(SearchCriteriaBuilder::class); @@ -160,32 +208,44 @@ protected function setUp() ->getMock(); $allowedCountryReaderMock->method('getAllowedCountries') ->willReturn(['EN'=>'EN']); + $this->dataObjectHelperMock = $this->getMockBuilder(\Magento\Framework\Api\DataObjectHelper::class) + ->disableOriginalConstructor() + ->setMethods(['mergeDataObjects']) + ->getMock(); + $this->placeOrderFactoryMock = $this->getMockBuilder(PlaceOrderFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $logger = $this->createSimpleMock(\Psr\Log\LoggerInterface::class); $this->model = new Multishipping( $this->checkoutSessionMock, $this->customerSessionMock, - $orderFactoryMock, + $this->orderFactoryMock, $this->addressRepositoryMock, $eventManagerMock, $scopeConfigMock, - $sessionMock, + $this->sessionMock, $addressFactoryMock, - $toOrderMock, - $toOrderAddressMock, - $toOrderPaymentMock, - $toOrderItemMock, + $this->toOrderMock, + $this->toOrderAddressMock, + $this->toOrderPaymentMock, + $this->toOrderItemMock, $storeManagerMock, $paymentSpecMock, $this->helperMock, $orderSenderMock, - $priceMock, + $this->priceMock, $this->quoteRepositoryMock, $this->searchCriteriaBuilderMock, $this->filterBuilderMock, $this->totalsCollectorMock, $data, $this->cartExtensionFactoryMock, - $allowedCountryReaderMock + $allowedCountryReaderMock, + $this->placeOrderFactoryMock, + $logger, + $this->dataObjectHelperMock ); $this->shippingAssignmentProcessorMock = $this->createSimpleMock(ShippingAssignmentProcessor::class); @@ -365,6 +425,255 @@ public function testSetShippingMethods() $this->model->setShippingMethods($methodsArray); } + /** + * @return void + */ + public function testCreateOrders(): void + { + $addressTotal = 5; + $productType = \Magento\Catalog\Model\Product\Type::TYPE_SIMPLE; + $infoBuyRequest = [ + 'info_buyRequest' => [ + 'product' => '1', + 'qty' => 1, + ], + ]; + $quoteItemId = 1; + $paymentProviderCode = 'checkmo'; + + $simpleProductTypeMock = $this->getMockBuilder(\Magento\Catalog\Model\Product\Type\Simple::class) + ->disableOriginalConstructor() + ->setMethods(['getOrderOptions']) + ->getMock(); + $productMock = $this->getProductMock($simpleProductTypeMock); + $simpleProductTypeMock->method('getOrderOptions')->with($productMock)->willReturn($infoBuyRequest); + + $quoteItemMock = $this->getQuoteItemMock($productType, $productMock); + $quoteAddressItemMock = $this->getQuoteAddressItemMock($quoteItemMock, $productType, $infoBuyRequest); + list($shippingAddressMock, $billingAddressMock) = + $this->getQuoteAddressesMock($quoteAddressItemMock, $addressTotal); + $this->setQuoteMockData($paymentProviderCode, $shippingAddressMock, $billingAddressMock); + + $orderAddressMock = $this->createSimpleMock(\Magento\Sales\Api\Data\OrderAddressInterface::class); + $orderPaymentMock = $this->createSimpleMock(\Magento\Sales\Api\Data\OrderPaymentInterface::class); + $orderItemMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + ->disableOriginalConstructor() + ->setMethods(['getQuoteItemId']) + ->getMock(); + $orderItemMock->method('getQuoteItemId')->willReturn($quoteItemId); + $orderMock = $this->getOrderMock($orderAddressMock, $orderPaymentMock, $orderItemMock); + + $this->orderFactoryMock->expects($this->once())->method('create')->willReturn($orderMock); + $this->dataObjectHelperMock->expects($this->once())->method('mergeDataObjects') + ->with( + \Magento\Sales\Api\Data\OrderInterface::class, + $orderMock, + $orderMock + )->willReturnSelf(); + $this->priceMock->expects($this->once())->method('round')->with($addressTotal)->willReturn($addressTotal); + + $this->toOrderMock + ->expects($this->once()) + ->method('convert') + ->with($shippingAddressMock) + ->willReturn($orderMock); + $this->toOrderAddressMock->expects($this->exactly(2))->method('convert') + ->withConsecutive( + [$billingAddressMock, []], + [$shippingAddressMock, []] + )->willReturn($orderAddressMock); + $this->toOrderPaymentMock->method('convert')->willReturn($orderPaymentMock); + $this->toOrderItemMock->method('convert')->with($quoteAddressItemMock)->willReturn($orderItemMock); + + $placeOrderServiceMock = $this->getMockBuilder(PlaceOrderDefault::class) + ->disableOriginalConstructor() + ->setMethods(['place']) + ->getMock(); + $placeOrderServiceMock->method('place')->with([$orderMock])->willReturn([]); + $this->placeOrderFactoryMock->method('create')->with($paymentProviderCode)->willReturn($placeOrderServiceMock); + $this->quoteRepositoryMock->method('save')->with($this->quoteMock); + + $this->model->createOrders(); + } + + /** + * @param string $paymentProviderCode + * @return PHPUnit_Framework_MockObject_MockObject + */ + private function getPaymentMock(string $paymentProviderCode): PHPUnit_Framework_MockObject_MockObject + { + $abstractMethod = $this->getMockBuilder(AbstractMethod::class) + ->disableOriginalConstructor() + ->setMethods(['isAvailable']) + ->getMockForAbstractClass(); + $abstractMethod->method('isAvailable')->willReturn(true); + + $paymentMock = $this->getMockBuilder(Payment::class) + ->disableOriginalConstructor() + ->setMethods(['getMethodInstance', 'getMethod']) + ->getMock(); + $paymentMock->method('getMethodInstance')->willReturn($abstractMethod); + $paymentMock->method('getMethod')->willReturn($paymentProviderCode); + + return $paymentMock; + } + + /** + * @param \Magento\Catalog\Model\Product\Type\Simple|PHPUnit_Framework_MockObject_MockObject $simpleProductTypeMock + * @return PHPUnit_Framework_MockObject_MockObject + */ + private function getProductMock($simpleProductTypeMock): PHPUnit_Framework_MockObject_MockObject + { + $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) + ->disableOriginalConstructor() + ->setMethods(['getTypeInstance']) + ->getMock(); + $productMock->method('getTypeInstance')->willReturn($simpleProductTypeMock); + + return $productMock; + } + + /** + * @param string $productType + * @param \Magento\Catalog\Model\Product|PHPUnit_Framework_MockObject_MockObject $productMock + * @return PHPUnit_Framework_MockObject_MockObject + */ + private function getQuoteItemMock($productType, $productMock): PHPUnit_Framework_MockObject_MockObject + { + $quoteItemMock = $this->getMockBuilder(\Magento\Quote\Model\Quote\Item::class) + ->disableOriginalConstructor() + ->setMethods(['getProductType', 'getProduct']) + ->getMock(); + $quoteItemMock->method('getProductType')->willReturn($productType); + $quoteItemMock->method('getProduct')->willReturn($productMock); + + return $quoteItemMock; + } + + /** + * @param \Magento\Quote\Model\Quote\Item|PHPUnit_Framework_MockObject_MockObject $quoteItemMock + * @param string $productType + * @param array $infoBuyRequest + * @return PHPUnit_Framework_MockObject_MockObject + */ + private function getQuoteAddressItemMock( + $quoteItemMock, + string $productType, + array $infoBuyRequest + ): PHPUnit_Framework_MockObject_MockObject { + $quoteAddressItemMock = $this->getMockBuilder(\Magento\Quote\Model\Quote\Address\Item::class) + ->disableOriginalConstructor() + ->setMethods(['getQuoteItem', 'setProductType', 'setProductOptions', 'getParentItem']) + ->getMock(); + $quoteAddressItemMock->method('getQuoteItem')->willReturn($quoteItemMock); + $quoteAddressItemMock->method('setProductType')->with($productType)->willReturnSelf(); + $quoteAddressItemMock->method('setProductOptions')->willReturn($infoBuyRequest); + $quoteAddressItemMock->method('getParentItem')->willReturn(false); + + return $quoteAddressItemMock; + } + + /** + * @param \Magento\Quote\Model\Quote\Address\Item|PHPUnit_Framework_MockObject_MockObject $quoteAddressItemMock + * @param int $addressTotal + * @return array + */ + private function getQuoteAddressesMock($quoteAddressItemMock, int $addressTotal): array + { + $shippingAddressMock = $this->getMockBuilder(Address::class) + ->disableOriginalConstructor() + ->setMethods( + [ + 'validate', + 'getShippingMethod', + 'getShippingRateByCode', + 'getCountryId', + 'getAddressType', + 'getGrandTotal', + 'getAllItems', + ] + )->getMock(); + $shippingAddressMock->method('validate')->willReturn(true); + $shippingAddressMock->method('getShippingMethod')->willReturn('carrier'); + $shippingAddressMock->method('getShippingRateByCode')->willReturn('code'); + $shippingAddressMock->method('getCountryId')->willReturn('EN'); + $shippingAddressMock->method('getAllItems')->willReturn([$quoteAddressItemMock]); + $shippingAddressMock->method('getAddressType')->willReturn('shipping'); + $shippingAddressMock->method('getGrandTotal')->willReturn($addressTotal); + + $billingAddressMock = $this->getMockBuilder(Address::class) + ->disableOriginalConstructor() + ->setMethods(['validate']) + ->getMock(); + $billingAddressMock->method('validate')->willReturn(true); + + return [$shippingAddressMock, $billingAddressMock]; + } + + /** + * @param string $paymentProviderCode + * @param Address|PHPUnit_Framework_MockObject_MockObject $shippingAddressMock + * @param Address|PHPUnit_Framework_MockObject_MockObject $billingAddressMock + * + * @return void + */ + private function setQuoteMockData(string $paymentProviderCode, $shippingAddressMock, $billingAddressMock): void + { + $quoteId = 1; + $paymentMock = $this->getPaymentMock($paymentProviderCode); + $this->quoteMock->method('getPayment') + ->willReturn($paymentMock); + $this->quoteMock->method('getAllShippingAddresses') + ->willReturn([$shippingAddressMock]); + $this->quoteMock->method('getBillingAddress') + ->willReturn($billingAddressMock); + $this->quoteMock->method('hasVirtualItems') + ->willReturn(false); + $this->quoteMock->expects($this->once())->method('reserveOrderId')->willReturnSelf(); + $this->quoteMock->expects($this->once())->method('collectTotals')->willReturnSelf(); + $this->quoteMock->method('getId')->willReturn($quoteId); + $this->quoteMock->method('setIsActive')->with(false)->willReturnSelf(); + } + + /** + * @param \Magento\Sales\Api\Data\OrderAddressInterface|PHPUnit_Framework_MockObject_MockObject $orderAddressMock + * @param \Magento\Sales\Api\Data\OrderPaymentInterface|PHPUnit_Framework_MockObject_MockObject $orderPaymentMock + * @param \Magento\Sales\Model\Order\Item|PHPUnit_Framework_MockObject_MockObject $orderItemMock + * @return PHPUnit_Framework_MockObject_MockObject + */ + private function getOrderMock( + $orderAddressMock, + $orderPaymentMock, + $orderItemMock + ): PHPUnit_Framework_MockObject_MockObject { + $orderMock = $this->getMockBuilder(\Magento\Sales\Model\Order::class) + ->disableOriginalConstructor() + ->setMethods( + [ + 'setQuote', + 'setBillingAddress', + 'setShippingAddress', + 'setPayment', + 'addItem', + 'getIncrementId', + 'getId', + 'getCanSendNewEmailFlag', + 'getItems', + ] + )->getMock(); + $orderMock->method('setQuote')->with($this->quoteMock); + $orderMock->method('setBillingAddress')->with($orderAddressMock)->willReturnSelf(); + $orderMock->method('setShippingAddress')->with($orderAddressMock)->willReturnSelf(); + $orderMock->method('setPayment')->with($orderPaymentMock)->willReturnSelf(); + $orderMock->method('addItem')->with($orderItemMock)->willReturnSelf(); + $orderMock->method('getIncrementId')->willReturn('1'); + $orderMock->method('getId')->willReturn('1'); + $orderMock->method('getCanSendNewEmailFlag')->willReturn(false); + $orderMock->method('getItems')->willReturn([$orderItemMock]); + + return $orderMock; + } + /** * Tests exception for addresses with country id not in the allowed countries list. * diff --git a/app/code/Magento/Multishipping/composer.json b/app/code/Magento/Multishipping/composer.json index 1ba4b7b9b2ed2..0d7982518d2c8 100644 --- a/app/code/Magento/Multishipping/composer.json +++ b/app/code/Magento/Multishipping/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-checkout": "*", "magento/module-customer": "*", @@ -14,9 +14,7 @@ "magento/module-quote": "*", "magento/module-sales": "*", "magento/module-store": "*", - "magento/module-tax": "*" - }, - "suggest": { + "magento/module-tax": "*", "magento/module-theme": "*" }, "type": "magento2-module", diff --git a/app/code/Magento/Multishipping/i18n/en_US.csv b/app/code/Magento/Multishipping/i18n/en_US.csv index 1e3e1880758ee..43cc785c56eab 100644 --- a/app/code/Magento/Multishipping/i18n/en_US.csv +++ b/app/code/Magento/Multishipping/i18n/en_US.csv @@ -88,3 +88,5 @@ Options,Options "Review Order","Review Order" "Select Shipping Method","Select Shipping Method" "We received your order!","We received your order!" +"Ship to:","Ship to:" +"Error:","Error:" \ No newline at end of file diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml index 8d78bad7a9ecc..5fcca5d9214a6 100644 --- a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml @@ -15,6 +15,9 @@ + + Magento\Multishipping\Block\DataProviders\Billing + diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml index f6584d2dcb2fd..376bc7b7d8ca8 100644 --- a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml @@ -17,6 +17,7 @@ + Magento\Multishipping\Block\DataProviders\Overview Magento_Multishipping::checkout/item/default.phtml Magento_Multishipping::checkout/overview/item.phtml diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_results.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_results.xml new file mode 100644 index 0000000000000..90f13ea28e02f --- /dev/null +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_results.xml @@ -0,0 +1,23 @@ + + + + + Order results + + + + + We could only complete part of your order. + We were unable to complete your order. + + + + + + + diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml index 98dd6e21910f8..d03282e551b9e 100644 --- a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml @@ -12,11 +12,15 @@ - We received your order! + Thank you for your purchase! - + + + Magento\Multishipping\Block\DataProviders\Success + + diff --git a/app/code/Magento/Multishipping/view/frontend/requirejs-config.js b/app/code/Magento/Multishipping/view/frontend/requirejs-config.js index c00005138eee4..f14159ba0a85a 100644 --- a/app/code/Magento/Multishipping/view/frontend/requirejs-config.js +++ b/app/code/Magento/Multishipping/view/frontend/requirejs-config.js @@ -8,7 +8,8 @@ var config = { '*': { multiShipping: 'Magento_Multishipping/js/multi-shipping', orderOverview: 'Magento_Multishipping/js/overview', - payment: 'Magento_Multishipping/js/payment' + payment: 'Magento_Multishipping/js/payment', + billingLoader: 'Magento_Checkout/js/checkout-loader' } } }; diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml index 277328c1b5b2a..d8514ca77f9c2 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml @@ -4,52 +4,109 @@ * See COPYING.txt for license details. */ -// @codingStandardsIgnoreFile - -?> - -
    +
    +
    + <?= $block->escapeHtml(__('Loading...')); ?> +
    +
    + +
    + + +
    +
    - - + escapeHtml(__('Billing Address')); ?> + + escapeHtml(__('Change')); ?> +
    - getAddress() ?> -
    format('html') ?>
    +
    + getCheckoutData()->getAddressHtml($block->getAddress()); ?> +
    -
    + + escapeHtml(__('Payment Method')); ?> +
    getChildHtml('payment_methods_before') ?> -
    +
    getMethods(); - $_methodsCount = count($_methods); + $methods = $block->getMethods(); + $methodsCount = count($methods); + $methodsForms = $block->hasFormTemplates() ? $block->getFormTemplates(): []; + + foreach ($methods as $_method) : + $code = $_method->getCode(); + $checked = $block->getSelectedMethodCode() === $code; + + if (isset($methodsForms[$code])) { + $block->setMethodFormTemplate($code, $methodsForms[$code]); + } ?> - getCode() ?> -
    - 1): ?> - getSelectedMethodCode() == $_code): ?> checked="checked" class="radio"/> - - - - -
    - getChildHtml('payment.method.' . $_code)) : ?> -
    - +
    + 1) : ?> + + checked="checked" + + class="radio"/> + + + + +
    + getChildHtml('payment.method.' . $code)) : ?> +
    +
    @@ -63,29 +120,35 @@
    - +
    diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml index 2549eff3aca7d..4590b7c584085 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml @@ -4,105 +4,146 @@ * See COPYING.txt for license details. */ -// @codingStandardsIgnoreFile - /** @var \Magento\Multishipping\Block\Checkout\Overview $block */ ?> -
    - getBlockHtml('formkey') ?> +getCheckoutData()->getAddressErrors(); ?> + $error) : ?> +
    + escapeHtml($error); ?> + escapeHtml(__('Please see')); ?> + + escapeHtml(__('details below')); ?>. +
    + + + getBlockHtml('formkey'); ?>
    -
    +
    escapeHtml(__('Billing Information')); ?>
    - getBillingAddress() ?> + getBillingAddress() ?> - - + escapeHtml(__('Billing Address')); ?> + escapeHtml(__('Change')); ?>
    - format('html') ?> + format('html') ?>
    - - + escapeHtml(__('Payment Method')); ?> + escapeHtml(__('Change')); ?>
    - - - getPaymentHtml() ?> + + + getPaymentHtml() ?>
    -
    - helper('Magento\Tax\Helper\Data')->displayCartBothPrices() ? 2 : 1); ?> - getShippingAddresses() as $_index => $_address): ?> +
    escapeHtml(__('Shipping Information')); ?>
    + helper(Magento\Tax\Helper\Data::class)->displayCartBothPrices() ? 2 : 1); ?> + getShippingAddresses() as $index => $address) : ?>
    +
    - of %2', ($_index+1), $block->getShippingAddressCount()) ?> + escapeHtml(__('Address')); ?> escapeHtml($index + 1); ?> + + escapeHtml(__('of')); ?> + escapeHtml($block->getShippingAddressCount())?> + +
    + getCheckoutData()->getAddressError($address)) : ?> +
    escapeHtml($error); ?>
    +
    - - + escapeHtml(__('Shipping To')); ?> + escapeHtml(__('Change')); ?>
    - format('html') ?> + format('html') ?>
    - - + escapeHtml(__('Shipping Method')); ?> + escapeHtml(__('Change')); ?> - getShippingAddressRate($_address)): ?> + getShippingAddressRate($address)) : ?>
    - escapeHtml($_rate->getCarrierTitle()) ?> (escapeHtml($_rate->getMethodTitle()) ?>) - getShippingPriceExclTax($_address); ?> - getShippingPriceInclTax($_address); ?> - - helper('Magento\Tax\Helper\Data')->displayShippingBothPrices() && $_incl != $_excl): ?> - - - - helper('Magento\Tax\Helper\Data')->displayShippingBothPrices() && $_incl != $_excl): ?> - - - helper('Magento\Tax\Helper\Data')->displayShippingBothPrices() && $_incl != $_excl): ?> - - + escapeHtml($_rate->getCarrierTitle()) ?> + (escapeHtml($_rate->getMethodTitle()) ?>) + getShippingPriceExclTax($address); + $inclTax = $block->getShippingPriceInclTax($address); + $displayBothPrices = $this->helper(Magento\Tax\Helper\Data::class) + ->displayShippingBothPrices() && $inclTax !== $exclTax; + ?> + + + + + + + + + +
    - - +
    + - - - - + + + - getShippingAddressItems($_address) as $_item): ?> - getRowItemHtml($_item) ?> + getShippingAddressItems($address) as $item) : ?> + getRowItemHtml($item) ?> - renderTotals($block->getShippingAddressTotals($_address)) ?> + renderTotals( + $block->getShippingAddressTotals($address) + ); ?>
    escapeHtml(__('Order Review')); ?>
    - + escapeHtml(__('Item')); ?> + + escapeHtml(__('Edit')); ?> + escapeHtml(__('Price')); ?>escapeHtml(__('Qty')); ?>escapeHtml(__('Subtotal')); ?>
    @@ -112,33 +153,40 @@
    - getQuote()->hasVirtualItems()): ?> + getQuote()->hasVirtualItems()) : ?>
    -
    + getQuote()->getBillingAddress(); ?> + +
    escapeHtml(__('Other items in your order')); ?>
    + getCheckoutData()->getAddressError($billingAddress)) :?> +
    escapeHtml($error); ?>
    +
    - - + escapeHtml(__('Items')); ?> + escapeHtml(__('Edit Items')); ?> - helper('Magento\Tax\Helper\Data')->displayCartBothPrices() ? 2 : 1); ?> + helper(Magento\Tax\Helper\Data::class)->displayCartBothPrices() ? 2 : 1); ?>
    - + - - - - + + + + - getVirtualItems() as $_item): ?> - getRowItemHtml($_item) ?> + getVirtualItems() as $_item) : ?> + getRowItemHtml($_item) ?> - renderTotals($block->getBillinAddressTotals()) ?> + renderTotals($block->getBillingAddressTotals()); ?>
    escapeHtml(__('Items')); ?>
    escapeHtml(__('Product Name')); ?>escapeHtml(__('Price')); ?>escapeHtml(__('Qty')); ?>escapeHtml(__('Subtotal')); ?>
    @@ -146,23 +194,34 @@
    - getChildHtml('items_after') ?> + getChildHtml('items_after') ?>
    - getChildHtml('agreements') ?> + getChildHtml('agreements') ?>
    - - helper('Magento\Checkout\Helper\Data')->formatPrice($block->getTotal()) ?> + escapeHtml(__('Grand Total:')); ?> + + helper(Magento\Checkout\Helper\Data::class) + ->formatPrice($block->getTotal()); ?> +
    - +
    -
    diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml new file mode 100644 index 0000000000000..dacf96f9c0baf --- /dev/null +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml @@ -0,0 +1,90 @@ +getOrderIds(); +?> +
    +

    + + escapeHtml(__('Not all items were included.')); ?> + + escapeHtml(__('For details, see')); ?> + escapeHtml(__('Failed to Order')); ?> + escapeHtml(__('section below')); ?> +

    + +

    + escapeHtml(__('For successfully ordered items, you\'ll receive a confirmation email '. + 'including order numbers, tracking information, and more details.')); ?> +

    +
    +

    escapeHtml(__('Successfully Ordered')); ?>

    +
      + $incrementId) : ?> +
    • + + getOrderShippingAddress($orderId); ?> +
      + + escapeHtml(__('Ship to:')); ?> + + escapeHtml($block->formatOrderShippingAddress($shippingAddress)); ?> + + + + escapeHtml(__('No shipping required.')); ?> + + +
      +
    • + +
    +
    + +
    +

    escapeHtml(__('Failed to Order')); ?>

    +
    +
    + escapeHtml(__('To purchase these items: Return to the')); ?> + + escapeHtml(__('Review page in Checkout')); ?>, + escapeHtml(__('resolve any errors, and place a new order.'))?> +
    +
    + getFailedAddresses() ?> + +
      + +
    1. +
      +
      + isShippingAddress($address)) : ?> + escapeHtml(__('Ship to:')); ?> + + escapeHtml($block->formatQuoteShippingAddress($address)); ?> + + + + escapeHtml(__('No shipping required.')); ?> + + +
      +
      + escapeHtml(__('Error:')); ?> + + getAddressError($address); ?> + +
      +
      +
    2. + +
    + +
    +
    diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml index 3403c745e6495..57c4afaee6541 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml @@ -4,27 +4,48 @@ * See COPYING.txt for license details. */ -// @codingStandardsIgnoreFile - +/** @var \Magento\Multishipping\Block\Checkout\Success $block */ ?> -
    -

    escapeHtml(__('Thank you for your purchase!')) ?>

    -

    escapeHtml(__('Thanks for your order. We\'ll email you order details and tracking information.')) ?>

    - getOrderIds()): ?> -

    - - - 1): ?> - escapeHtml(__('Your order numbers are: ')) ?> - - escapeHtml(__('Your order number is: ')) ?> - - - $incrementId): ?> -

    - - getChildHtml() ?> -
    - escapeHtml(__('Continue Shopping')) ?> + +
    +

    escapeHtml(__('For successfully order items, you\'ll receive a confirmation email including '. + 'order numbers, tracking information and more details.')) ?>

    + getOrderIds()) : ?> +

    escapeHtml(__('Successfully ordered'))?>

    +
    +
      + $incrementId) : ?> +
    • + + getCheckoutData()->getOrderShippingAddress($orderId); ?> +
      + + escapeHtml(__('Ship to:')); ?> + + escapeHtml( + $block->getCheckoutData()->formatOrderShippingAddress($shippingAddress) + ); ?> + + + + escapeHtml(__('No shipping required.')); ?> + + +
      +
    • + +
    +
    + + getChildHtml() ?> +
    +
    + +
    +
    -
    + diff --git a/app/code/Magento/Multishipping/view/frontend/web/js/payment.js b/app/code/Magento/Multishipping/view/frontend/web/js/payment.js index 94987328bb278..da24b99597d42 100644 --- a/app/code/Magento/Multishipping/view/frontend/web/js/payment.js +++ b/app/code/Magento/Multishipping/view/frontend/web/js/payment.js @@ -63,9 +63,12 @@ define([ parentsDl = element.closest('dl'); parentsDl.find('dt input:radio').prop('checked', false); - parentsDl.find('.items').hide().find('[name^="payment["]').prop('disabled', true); + parentsDl.find('dd').addClass('no-display').end() + .find('.items').hide() + .find('[name^="payment["]').prop('disabled', true); element.prop('checked', true).parent() - .nextUntil('dt').find('.items').show().find('[name^="payment["]').prop('disabled', false); + .next('dd').removeClass('no-display') + .find('.items').show().find('[name^="payment["]').prop('disabled', false); }, /** @@ -122,16 +125,35 @@ define([ this.element.find(this.options.methodsContainer).show(); }, + /** + * Returns checked payment method. + * + * @private + */ + _getSelectedPaymentMethod: function () { + return this.element.find('input[name=\'payment[method]\']:checked'); + }, + /** * Validate before form submit * @private * @param {EventObject} e */ _submitHandler: function (e) { + var currentMethod, + submitButton; + e.preventDefault(); if (this._validatePaymentMethod()) { - this.element.submit(); + currentMethod = this._getSelectedPaymentMethod(); + submitButton = currentMethod.parent().next('dd').find('button[type=submit]'); + + if (submitButton.length) { + submitButton.first().trigger('click'); + } else { + this.element.submit(); + } } } }); diff --git a/app/code/Magento/MysqlMq/composer.json b/app/code/Magento/MysqlMq/composer.json index 50250387cb5b8..3d7ee4f8b037b 100644 --- a/app/code/Magento/MysqlMq/composer.json +++ b/app/code/Magento/MysqlMq/composer.json @@ -8,7 +8,7 @@ "magento/framework": "*", "magento/magento-composer-installer": "*", "magento/module-store": "*", - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0" + "php": "~7.1.3||~7.2.0" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/MysqlMq/etc/db_schema.xml b/app/code/Magento/MysqlMq/etc/db_schema.xml index 9ef1fc15f1fb5..02757afa53fcd 100644 --- a/app/code/Magento/MysqlMq/etc/db_schema.xml +++ b/app/code/Magento/MysqlMq/etc/db_schema.xml @@ -44,10 +44,10 @@ - - diff --git a/app/code/Magento/MysqlMq/etc/db_schema_whitelist.json b/app/code/Magento/MysqlMq/etc/db_schema_whitelist.json index 9d224cd8cb724..4d5a919320fe9 100644 --- a/app/code/Magento/MysqlMq/etc/db_schema_whitelist.json +++ b/app/code/Magento/MysqlMq/etc/db_schema_whitelist.json @@ -33,9 +33,11 @@ }, "constraint": { "PRIMARY": true, + "QUEUE_MESSAGE_STATUS_MESSAGE_ID_QUEUE_MESSAGE_ID": true, "QUEUE_MESSAGE_ID_QUEUE_MESSAGE_STATUS_MESSAGE_ID": true, + "QUEUE_MESSAGE_STATUS_QUEUE_ID_QUEUE_ID": true, "QUEUE_ID_QUEUE_MESSAGE_STATUS_QUEUE_ID": true, "QUEUE_MESSAGE_STATUS_QUEUE_ID_MESSAGE_ID": true } } -} \ No newline at end of file +} diff --git a/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php b/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php index e6ef9db3eb6a1..7a8419d57c865 100644 --- a/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php +++ b/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php @@ -48,21 +48,21 @@ protected function configure() { $this->setName("newrelic:create:deploy-marker"); $this->setDescription("Check the deploy queue for entries and create an appropriate deploy marker.") - ->addArgument( - 'message', - InputArgument::REQUIRED, - 'Deploy Message?' - ) - ->addArgument( - 'change_log', - InputArgument::REQUIRED, - 'Change Log?' - ) - ->addArgument( - 'user', - InputArgument::OPTIONAL, - 'Deployment User' - ); + ->addArgument( + 'message', + InputArgument::REQUIRED, + 'Deploy Message?' + ) + ->addArgument( + 'change_log', + InputArgument::REQUIRED, + 'Change Log?' + ) + ->addArgument( + 'user', + InputArgument::OPTIONAL, + 'Deployment User' + ); parent::configure(); } diff --git a/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php b/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php index 724a488570207..ce7e95950c937 100644 --- a/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php +++ b/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php @@ -37,6 +37,9 @@ public function __construct( $this->newRelicWrapper = $newRelicWrapper; } + /** + * @param Observer $observer + */ public function execute(Observer $observer) { if ($this->config->isNewRelicEnabled()) { diff --git a/app/code/Magento/NewRelicReporting/composer.json b/app/code/Magento/NewRelicReporting/composer.json index ef8a2d2715dd6..25e7193ce0e2f 100644 --- a/app/code/Magento/NewRelicReporting/composer.json +++ b/app/code/Magento/NewRelicReporting/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/magento-composer-installer": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/Newsletter/Controller/Subscriber/Confirm.php b/app/code/Magento/Newsletter/Controller/Subscriber/Confirm.php index 004677b899cd0..4e338c2d1df34 100644 --- a/app/code/Magento/Newsletter/Controller/Subscriber/Confirm.php +++ b/app/code/Magento/Newsletter/Controller/Subscriber/Confirm.php @@ -32,6 +32,8 @@ public function execute() } } - $this->getResponse()->setRedirect($this->_storeManager->getStore()->getBaseUrl()); + $resultRedirect = $this->resultRedirectFactory->create(); + $resultRedirect->setUrl($this->_storeManager->getStore()->getBaseUrl()); + return $resultRedirect; } } diff --git a/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php b/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php index b82d6fe06918f..22b31575debbc 100644 --- a/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php +++ b/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php @@ -8,6 +8,9 @@ use Magento\Customer\Api\CustomerRepositoryInterface as CustomerRepository; use Magento\Customer\Api\Data\CustomerInterface; use Magento\Newsletter\Model\SubscriberFactory; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Newsletter\Model\ResourceModel\Subscriber; +use Magento\Customer\Api\Data\CustomerExtensionInterface; class CustomerPlugin { @@ -18,14 +21,36 @@ class CustomerPlugin */ private $subscriberFactory; + /** + * @var ExtensionAttributesFactory + */ + private $extensionFactory; + + /** + * @var Subscriber + */ + private $subscriberResource; + + /** + * @var array + */ + private $customerSubscriptionStatus = []; + /** * Initialize dependencies. * * @param SubscriberFactory $subscriberFactory + * @param ExtensionAttributesFactory $extensionFactory + * @param Subscriber $subscriberResource */ - public function __construct(SubscriberFactory $subscriberFactory) - { + public function __construct( + SubscriberFactory $subscriberFactory, + ExtensionAttributesFactory $extensionFactory, + Subscriber $subscriberResource + ) { $this->subscriberFactory = $subscriberFactory; + $this->extensionFactory = $extensionFactory; + $this->subscriberResource = $subscriberResource; } /** @@ -41,14 +66,34 @@ public function __construct(SubscriberFactory $subscriberFactory) */ public function afterSave(CustomerRepository $subject, CustomerInterface $result, CustomerInterface $customer) { - $this->subscriberFactory->create()->updateSubscription($result->getId()); - if ($result->getId() && $customer->getExtensionAttributes()) { - if ($customer->getExtensionAttributes()->getIsSubscribed() === true) { - $this->subscriberFactory->create()->subscribeCustomerById($result->getId()); - } elseif ($customer->getExtensionAttributes()->getIsSubscribed() === false) { - $this->subscriberFactory->create()->unsubscribeCustomerById($result->getId()); + $resultId = $result->getId(); + /** @var \Magento\Newsletter\Model\Subscriber $subscriber */ + $subscriber = $this->subscriberFactory->create(); + + $subscriber->updateSubscription($resultId); + // update the result only if the original customer instance had different value. + $initialExtensionAttributes = $result->getExtensionAttributes(); + if ($initialExtensionAttributes === null) { + /** @var CustomerExtensionInterface $initialExtensionAttributes */ + $initialExtensionAttributes = $this->extensionFactory->create(CustomerInterface::class); + $result->setExtensionAttributes($initialExtensionAttributes); + } + + $newExtensionAttributes = $customer->getExtensionAttributes(); + if ($newExtensionAttributes + && $initialExtensionAttributes->getIsSubscribed() !== $newExtensionAttributes->getIsSubscribed() + ) { + if ($newExtensionAttributes->getIsSubscribed()) { + $subscriber->subscribeCustomerById($resultId); + } else { + $subscriber->unsubscribeCustomerById($resultId); } } + + $isSubscribed = $subscriber->isSubscribed(); + $this->customerSubscriptionStatus[$resultId] = $isSubscribed; + $initialExtensionAttributes->setIsSubscribed($isSubscribed); + return $result; } @@ -94,4 +139,47 @@ public function afterDelete(CustomerRepository $subject, $result, CustomerInterf } return $result; } + + /** + * Plugin after getById customer that obtains newsletter subscription status for given customer. + * + * @param CustomerRepository $subject + * @param CustomerInterface $customer + * @return CustomerInterface + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetById(CustomerRepository $subject, CustomerInterface $customer) + { + $extensionAttributes = $customer->getExtensionAttributes(); + + if ($extensionAttributes === null) { + /** @var CustomerExtensionInterface $extensionAttributes */ + $extensionAttributes = $this->extensionFactory->create(CustomerInterface::class); + $customer->setExtensionAttributes($extensionAttributes); + } + if ($extensionAttributes->getIsSubscribed() === null) { + $isSubscribed = $this->isSubscribed($customer); + $extensionAttributes->setIsSubscribed($isSubscribed); + } + + return $customer; + } + + /** + * This method returns newsletters subscription status for given customer. + * + * @param CustomerInterface $customer + * @return bool + */ + private function isSubscribed(CustomerInterface $customer) + { + $customerId = $customer->getId(); + if (!isset($this->customerSubscriptionStatus[$customerId])) { + $subscriber = $this->subscriberResource->loadByCustomerData($customer); + $this->customerSubscriptionStatus[$customerId] = isset($subscriber['subscriber_status']) + && $subscriber['subscriber_status'] == 1; + } + + return $this->customerSubscriptionStatus[$customerId]; + } } diff --git a/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php b/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php index 47d4584857bde..e809b7e37a432 100644 --- a/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php +++ b/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php @@ -7,13 +7,16 @@ use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\ResourceModel\CustomerRepository; +use Magento\Customer\Api\Data\CustomerExtensionInterface; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Newsletter\Model\ResourceModel\Subscriber; class CustomerPluginTest extends \PHPUnit\Framework\TestCase { /** * @var \Magento\Newsletter\Model\Plugin\CustomerPlugin */ - protected $plugin; + private $plugin; /** * @var \Magento\Newsletter\Model\SubscriberFactory|\PHPUnit_Framework_MockObject_MockObject @@ -28,7 +31,27 @@ class CustomerPluginTest extends \PHPUnit\Framework\TestCase /** * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager */ - protected $objectManager; + private $objectManager; + + /** + * @var ExtensionAttributesFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $extensionFactoryMock; + + /** + * @var CustomerExtensionInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerExtensionMock; + + /** + * @var Subscriber|\PHPUnit_Framework_MockObject_MockObject + */ + private $subscriberResourceMock; + + /** + * @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerMock; protected function setUp() { @@ -44,92 +67,102 @@ protected function setUp() 'delete', 'updateSubscription', 'subscribeCustomerById', - 'unsubscribeCustomerById' + 'unsubscribeCustomerById', + 'isSubscribed', ] )->disableOriginalConstructor() ->getMock(); + $this->extensionFactoryMock = $this->getMockBuilder(ExtensionAttributesFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->customerExtensionMock = $this->getMockBuilder(CustomerExtensionInterface::class) + ->setMethods(['getIsSubscribed', 'setIsSubscribed']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->subscriberResourceMock = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerMock = $this->getMockBuilder(CustomerInterface::class) + ->setMethods(['getExtensionAttributes']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); $this->subscriberFactory->expects($this->any())->method('create')->willReturn($this->subscriber); - $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->plugin = $this->objectManager->getObject( \Magento\Newsletter\Model\Plugin\CustomerPlugin::class, [ - 'subscriberFactory' => $this->subscriberFactory + 'subscriberFactory' => $this->subscriberFactory, + 'extensionFactory' => $this->extensionFactoryMock, + 'subscriberResource' => $this->subscriberResourceMock, ] ); } - public function testAfterSaveWithoutIsSubscribed() + /** + * @param bool $subscriptionOriginalValue + * @param bool $subscriptionNewValue + * @dataProvider afterSaveDataProvider + * @return void + */ + public function testAfterSave($subscriptionOriginalValue, $subscriptionNewValue) { $customerId = 1; - /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $result */ + $result = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); /** @var CustomerRepository | \PHPUnit_Framework_MockObject_MockObject $subject */ $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); - $customer->expects($this->atLeastOnce()) - ->method("getId") - ->willReturn($customerId); + /** @var CustomerExtensionInterface|\PHPUnit_Framework_MockObject_MockObject $resultExtensionAttributes */ + $resultExtensionAttributes = $this->getMockBuilder(CustomerExtensionInterface::class) + ->setMethods(['getIsSubscribed', 'setIsSubscribed']) + ->getMockForAbstractClass(); + $result->expects($this->atLeastOnce())->method('getId')->willReturn($customerId); + $result->expects($this->any())->method('getExtensionAttributes')->willReturn(null); + $this->extensionFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($resultExtensionAttributes); + $result->expects($this->once()) + ->method('setExtensionAttributes') + ->with($resultExtensionAttributes) + ->willReturnSelf(); + $this->customerMock->expects($this->once()) + ->method('getExtensionAttributes') + ->willReturn($this->customerExtensionMock); + $resultExtensionAttributes->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn($subscriptionOriginalValue); + $this->customerExtensionMock->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn($subscriptionNewValue); + + if ($subscriptionOriginalValue !== $subscriptionNewValue) { + if ($subscriptionNewValue) { + $this->subscriber->expects($this->once())->method('subscribeCustomerById')->with($customerId); + } else { + $this->subscriber->expects($this->once())->method('unsubscribeCustomerById')->with($customerId); + } + $this->subscriber->expects($this->once())->method('isSubscribed')->willReturn($subscriptionNewValue); + $resultExtensionAttributes->expects($this->once())->method('setIsSubscribed')->with($subscriptionNewValue); + } - $this->assertEquals($customer, $this->plugin->afterSave($subject, $customer, $customer)); + $this->assertEquals($result, $this->plugin->afterSave($subject, $result, $this->customerMock)); } /** * @return array */ - public function afterSaveExtensionAttributeDataProvider() + public function afterSaveDataProvider() { return [ [true, true], - [false, false] + [false, false], + [true, false], + [false, true], ]; } - /** - * @param boolean $isSubscribed - * @param boolean $subscribeIsCreated - * @dataProvider afterSaveExtensionAttributeDataProvider - */ - public function testAfterSaveWithIsSubscribed($isSubscribed, $subscribeIsCreated) - { - $customerId = 1; - /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); - $extensionAttributes = $this - ->getMockBuilder(\Magento\Customer\Api\Data\CustomerExtensionInterface::class) - ->setMethods(["getIsSubscribed", "setIsSubscribed"]) - ->getMockForAbstractClass(); - - $extensionAttributes - ->expects($this->atLeastOnce()) - ->method("getIsSubscribed") - ->willReturn($isSubscribed); - - $customer->expects($this->atLeastOnce()) - ->method("getExtensionAttributes") - ->willReturn($extensionAttributes); - - if ($subscribeIsCreated) { - $this->subscriber->expects($this->once()) - ->method("subscribeCustomerById") - ->with($customerId); - } else { - $this->subscriber->expects($this->once()) - ->method("unsubscribeCustomerById") - ->with($customerId); - } - - /** @var CustomerRepository | \PHPUnit_Framework_MockObject_MockObject $subject */ - $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); - - $customer->expects($this->atLeastOnce()) - ->method("getId") - ->willReturn($customerId); - - $this->assertEquals($customer, $this->plugin->afterSave($subject, $customer, $customer)); - } - public function testAfterDelete() { $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); @@ -158,4 +191,80 @@ public function testAroundDeleteById() $this->assertEquals(true, $this->plugin->aroundDeleteById($subject, $deleteCustomerById, $customerId)); } + + /** + * @param int|null $subscriberStatusKey + * @param int|null $subscriberStatusValue + * @param bool $isSubscribed + * @dataProvider afterGetByIdDataProvider + * @return void + */ + public function testAfterGetByIdCreatesExtensionAttributesIfItIsNotSet( + $subscriberStatusKey, + $subscriberStatusValue, + $isSubscribed + ) { + $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $subscriber = [$subscriberStatusKey => $subscriberStatusValue]; + + $this->extensionFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($this->customerExtensionMock); + $this->customerMock->expects($this->once()) + ->method('setExtensionAttributes') + ->with($this->customerExtensionMock) + ->willReturnSelf(); + $this->customerMock->expects($this->any()) + ->method('getId') + ->willReturn(1); + $this->subscriberResourceMock->expects($this->once()) + ->method('loadByCustomerData') + ->with($this->customerMock) + ->willReturn($subscriber); + $this->customerExtensionMock->expects($this->once())->method('setIsSubscribed')->with($isSubscribed); + + $this->assertEquals( + $this->customerMock, + $this->plugin->afterGetById($subject, $this->customerMock) + ); + } + + public function testAfterGetByIdSetsIsSubscribedFlagIfItIsNotSet() + { + $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $subscriber = ['subscriber_id' => 1, 'subscriber_status' => 1]; + + $this->customerMock->expects($this->any()) + ->method('getExtensionAttributes') + ->willReturn($this->customerExtensionMock); + $this->customerExtensionMock->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn(null); + $this->subscriberResourceMock->expects($this->once()) + ->method('loadByCustomerData') + ->with($this->customerMock) + ->willReturn($subscriber); + $this->customerExtensionMock->expects($this->once()) + ->method('setIsSubscribed') + ->willReturnSelf(); + + $this->assertEquals( + $this->customerMock, + $this->plugin->afterGetById($subject, $this->customerMock) + ); + } + + /** + * @return array + */ + public function afterGetByIdDataProvider() + { + return [ + ['subscriber_status', 1, true], + ['subscriber_status', 2, false], + ['subscriber_status', 3, false], + ['subscriber_status', 4, false], + [null, null, false], + ]; + } } diff --git a/app/code/Magento/Newsletter/composer.json b/app/code/Magento/Newsletter/composer.json index 65ed8451c53d3..dc1334af295c8 100644 --- a/app/code/Magento/Newsletter/composer.json +++ b/app/code/Magento/Newsletter/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-cms": "*", diff --git a/app/code/Magento/Newsletter/etc/db_schema_whitelist.json b/app/code/Magento/Newsletter/etc/db_schema_whitelist.json index 237c675f5fcca..27a42a55a174c 100644 --- a/app/code/Magento/Newsletter/etc/db_schema_whitelist.json +++ b/app/code/Magento/Newsletter/etc/db_schema_whitelist.json @@ -11,7 +11,8 @@ }, "index": { "NEWSLETTER_SUBSCRIBER_CUSTOMER_ID": true, - "NEWSLETTER_SUBSCRIBER_STORE_ID": true + "NEWSLETTER_SUBSCRIBER_STORE_ID": true, + "NEWSLETTER_SUBSCRIBER_SUBSCRIBER_EMAIL": true }, "constraint": { "PRIMARY": true, @@ -112,4 +113,4 @@ "NLTTR_PROBLEM_SUBSCRIBER_ID_NLTTR_SUBSCRIBER_SUBSCRIBER_ID": true } } -} \ No newline at end of file +} diff --git a/app/code/Magento/OfflinePayments/composer.json b/app/code/Magento/OfflinePayments/composer.json index db2da1d4e7a5f..aa2e45b01e9f2 100644 --- a/app/code/Magento/OfflinePayments/composer.json +++ b/app/code/Magento/OfflinePayments/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-checkout": "*", "magento/module-payment": "*" diff --git a/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php b/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php index a258223e06777..1bd55cf5f1720 100644 --- a/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php +++ b/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php @@ -21,7 +21,7 @@ class Export extends \Magento\Framework\Data\Form\Element\AbstractElement * @param \Magento\Framework\Data\Form\Element\Factory $factoryElement * @param \Magento\Framework\Data\Form\Element\CollectionFactory $factoryCollection * @param \Magento\Framework\Escaper $escaper - * @param \Magento\Backend\Helper\Data $helper + * @param \Magento\Backend\Model\UrlInterface $backendUrl * @param array $data */ public function __construct( diff --git a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php index 5a3ad76f0410f..5b03ef0cb02bd 100644 --- a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php +++ b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php @@ -99,7 +99,7 @@ public function getBindings() } } else { $bind[':condition_name'] = $this->request->getConditionName(); - $bind[':condition_value'] = $this->request->getData($this->request->getConditionName()); + $bind[':condition_value'] = round($this->request->getData($this->request->getConditionName()), 4); } return $bind; diff --git a/app/code/Magento/OfflineShipping/composer.json b/app/code/Magento/OfflineShipping/composer.json index 23436374fa167..a3e1dad5de854 100644 --- a/app/code/Magento/OfflineShipping/composer.json +++ b/app/code/Magento/OfflineShipping/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -19,7 +19,7 @@ }, "suggest": { "magento/module-checkout": "*", - "magento/module-offline-shipping-sample-data": "Sample Data version:100.3.*" + "magento/module-offline-shipping-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/OfflineShipping/etc/db_schema.xml b/app/code/Magento/OfflineShipping/etc/db_schema.xml index 1a4ecd89582c6..80f4b56a1723d 100644 --- a/app/code/Magento/OfflineShipping/etc/db_schema.xml +++ b/app/code/Magento/OfflineShipping/etc/db_schema.xml @@ -45,15 +45,15 @@ - +
    - +
    - +
    diff --git a/app/code/Magento/PageCache/composer.json b/app/code/Magento/PageCache/composer.json index 3dae5aff40436..56385fcc528de 100644 --- a/app/code/Magento/PageCache/composer.json +++ b/app/code/Magento/PageCache/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-config": "*", diff --git a/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php b/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php index a6f9d4383918c..2ba3034072a52 100644 --- a/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php +++ b/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php @@ -5,14 +5,15 @@ */ namespace Magento\Payment\Gateway\Command; -use Magento\Framework\Phrase; use Magento\Payment\Gateway\CommandInterface; +use Magento\Payment\Gateway\ErrorMapper\ErrorMessageMapperInterface; +use Magento\Payment\Gateway\Http\ClientException; use Magento\Payment\Gateway\Http\ClientInterface; +use Magento\Payment\Gateway\Http\ConverterException; use Magento\Payment\Gateway\Http\TransferFactoryInterface; -use Magento\Payment\Gateway\Request; use Magento\Payment\Gateway\Request\BuilderInterface; -use Magento\Payment\Gateway\Response; use Magento\Payment\Gateway\Response\HandlerInterface; +use Magento\Payment\Gateway\Validator\ResultInterface; use Magento\Payment\Gateway\Validator\ValidatorInterface; use Psr\Log\LoggerInterface; @@ -54,6 +55,11 @@ class GatewayCommand implements CommandInterface */ private $logger; + /** + * @var ErrorMessageMapperInterface + */ + private $errorMessageMapper; + /** * @param BuilderInterface $requestBuilder * @param TransferFactoryInterface $transferFactory @@ -61,6 +67,7 @@ class GatewayCommand implements CommandInterface * @param LoggerInterface $logger * @param HandlerInterface $handler * @param ValidatorInterface $validator + * @param ErrorMessageMapperInterface|null $errorMessageMapper */ public function __construct( BuilderInterface $requestBuilder, @@ -68,7 +75,8 @@ public function __construct( ClientInterface $client, LoggerInterface $logger, HandlerInterface $handler = null, - ValidatorInterface $validator = null + ValidatorInterface $validator = null, + ErrorMessageMapperInterface $errorMessageMapper = null ) { $this->requestBuilder = $requestBuilder; $this->transferFactory = $transferFactory; @@ -76,6 +84,7 @@ public function __construct( $this->handler = $handler; $this->validator = $validator; $this->logger = $logger; + $this->errorMessageMapper = $errorMessageMapper; } /** @@ -84,6 +93,8 @@ public function __construct( * @param array $commandSubject * @return void * @throws CommandException + * @throws ClientException + * @throws ConverterException */ public function execute(array $commandSubject) { @@ -98,10 +109,7 @@ public function execute(array $commandSubject) array_merge($commandSubject, ['response' => $response]) ); if (!$result->isValid()) { - $this->logExceptions($result->getFailsDescription()); - throw new CommandException( - __('Transaction has been declined. Please try again later.') - ); + $this->processErrors($result); } } @@ -114,13 +122,34 @@ public function execute(array $commandSubject) } /** - * @param Phrase[] $fails - * @return void + * Tries to map error messages from validation result and logs processed message. + * Throws an exception with mapped message or default error. + * + * @param ResultInterface $result + * @throws CommandException */ - private function logExceptions(array $fails) + private function processErrors(ResultInterface $result) { - foreach ($fails as $failPhrase) { - $this->logger->critical((string) $failPhrase); + $messages = []; + $errorsSource = array_merge($result->getErrorCodes(), $result->getFailsDescription()); + foreach ($errorsSource as $errorCodeOrMessage) { + $errorCodeOrMessage = (string) $errorCodeOrMessage; + + // error messages mapper can be not configured if payment method doesn't have custom error messages. + if ($this->errorMessageMapper !== null) { + $mapped = (string) $this->errorMessageMapper->getMessage($errorCodeOrMessage); + if (!empty($mapped)) { + $messages[] = $mapped; + $errorCodeOrMessage = $mapped; + } + } + $this->logger->critical('Payment Error: ' . $errorCodeOrMessage); } + + throw new CommandException( + !empty($messages) + ? __(implode(PHP_EOL, $messages)) + : __('Transaction has been declined. Please try again later.') + ); } } diff --git a/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php new file mode 100644 index 0000000000000..2072615a39b92 --- /dev/null +++ b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php @@ -0,0 +1,44 @@ +messageMapping = $messageMapping; + } + + /** + * @inheritdoc + */ + public function getMessage(string $code) + { + $message = $this->messageMapping->get($code); + return $message ? __($message) : null; + } +} diff --git a/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php new file mode 100644 index 0000000000000..f09f49b7f8100 --- /dev/null +++ b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php @@ -0,0 +1,27 @@ +message` format and converts it to [code => message] array format. + */ +class XmlToArrayConverter implements ConverterInterface +{ + /** + * @inheritdoc + */ + public function convert($source) + { + $result = []; + $messageList = $source->getElementsByTagName('message'); + foreach ($messageList as $messageNode) { + $result[(string) $messageNode->getAttribute('code')] = (string) $messageNode->nodeValue; + } + return $result; + } +} diff --git a/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php b/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php index 45c6d61baeee6..f1a8950514152 100644 --- a/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php +++ b/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php @@ -32,14 +32,16 @@ public function __construct( * * @param bool $isValid * @param array $fails - * @return ResultInterface + * @param array $errorCodes + * @return void */ - protected function createResult($isValid, array $fails = []) + protected function createResult($isValid, array $fails = [], array $errorCodes = []) { return $this->resultInterfaceFactory->create( [ 'isValid' => (bool)$isValid, - 'failsDescription' => $fails + 'failsDescription' => $fails, + 'errorCodes' => $errorCodes ] ); } diff --git a/app/code/Magento/Payment/Gateway/Validator/Result.php b/app/code/Magento/Payment/Gateway/Validator/Result.php index 7266e4745d633..2567414473045 100644 --- a/app/code/Magento/Payment/Gateway/Validator/Result.php +++ b/app/code/Magento/Payment/Gateway/Validator/Result.php @@ -19,35 +19,47 @@ class Result implements ResultInterface */ private $failsDescription; + /** + * @var string[] + */ + private $errorCodes; + /** * @param bool $isValid * @param array $failsDescription + * @param array $errorCodes */ public function __construct( $isValid, - array $failsDescription = [] + array $failsDescription = [], + array $errorCodes = [] ) { $this->isValid = (bool)$isValid; $this->failsDescription = $failsDescription; + $this->errorCodes = $errorCodes; } /** - * Returns validation result - * - * @return bool + * {@inheritdoc} */ - public function isValid() + public function isValid(): bool { return $this->isValid; } /** - * Returns list of fails description - * - * @return Phrase[] + * {@inheritdoc} */ - public function getFailsDescription() + public function getFailsDescription(): array { return $this->failsDescription; } + + /** + * {@inheritdoc} + */ + public function getErrorCodes(): array + { + return $this->errorCodes; + } } diff --git a/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php b/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php index 9c93efe73f344..c1ad947e49c5b 100644 --- a/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php +++ b/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php @@ -28,4 +28,11 @@ public function isValid(); * @return Phrase[] */ public function getFailsDescription(); + + /** + * Returns list of error codes. + * + * @return string[] + */ + public function getErrorCodes(); } diff --git a/app/code/Magento/Payment/Helper/Data.php b/app/code/Magento/Payment/Helper/Data.php index f3565ea324290..5fd23c195f0c4 100644 --- a/app/code/Magento/Payment/Helper/Data.php +++ b/app/code/Magento/Payment/Helper/Data.php @@ -293,7 +293,9 @@ public function getPaymentMethodList($sorted = true, $asLabelValue = false, $wit foreach ($methods as $code => $title) { if (isset($groups[$code])) { $labelValues[$code]['label'] = $title; - $labelValues[$code]['value'] = null; + if (!isset($labelValues[$code]['value'])) { + $labelValues[$code]['value'] = null; + } } elseif (isset($groupRelations[$code])) { unset($labelValues[$code]); $labelValues[$groupRelations[$code]]['value'][$code] = ['value' => $code, 'label' => $title]; diff --git a/app/code/Magento/Payment/Model/Method/AbstractMethod.php b/app/code/Magento/Payment/Model/Method/AbstractMethod.php index 6e6a6773bd5f2..33200014c7ec1 100644 --- a/app/code/Magento/Payment/Model/Method/AbstractMethod.php +++ b/app/code/Magento/Payment/Model/Method/AbstractMethod.php @@ -6,12 +6,14 @@ namespace Magento\Payment\Model\Method; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DataObject; use Magento\Payment\Model\InfoInterface; use Magento\Payment\Model\MethodInterface; use Magento\Payment\Observer\AbstractDataAssignObserver; use Magento\Quote\Api\Data\PaymentMethodInterface; use Magento\Sales\Model\Order\Payment; +use Magento\Directory\Helper\Data as DirectoryHelper; /** * Payment method abstract model @@ -194,6 +196,11 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl */ protected $logger; + /** + * @var DirectoryHelper + */ + private $directory; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -205,6 +212,7 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data + * @param DirectoryHelper $directory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -217,7 +225,8 @@ public function __construct( \Magento\Payment\Model\Method\Logger $logger, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + DirectoryHelper $directory = null ) { parent::__construct( $context, @@ -231,6 +240,7 @@ public function __construct( $this->_paymentData = $paymentData; $this->_scopeConfig = $scopeConfig; $this->logger = $logger; + $this->directory = $directory ?: ObjectManager::getInstance()->get(DirectoryHelper::class); $this->initializeData($data); } @@ -584,11 +594,14 @@ public function validate() } else { $billingCountry = $paymentInfo->getQuote()->getBillingAddress()->getCountryId(); } + $billingCountry = $billingCountry ?: $this->directory->getDefaultCountry(); + if (!$this->canUseForCountry($billingCountry)) { throw new \Magento\Framework\Exception\LocalizedException( __('You can\'t use the payment type you selected to make payments to the billing country.') ); } + return $this; } diff --git a/app/code/Magento/Payment/Model/Method/Logger.php b/app/code/Magento/Payment/Model/Method/Logger.php index 74068c3b6fef0..9bf84ef4e4775 100644 --- a/app/code/Magento/Payment/Model/Method/Logger.php +++ b/app/code/Magento/Payment/Model/Method/Logger.php @@ -5,12 +5,11 @@ */ namespace Magento\Payment\Model\Method; +use Magento\Payment\Gateway\ConfigInterface; use Psr\Log\LoggerInterface; /** - * Class Logger for payment related information (request, response, etc.) which is used for debug - * - * @author Magento Core Team + * Class Logger for payment related information (request, response, etc.) which is used for debug. * * @api * @since 100.0.2 @@ -25,17 +24,17 @@ class Logger protected $logger; /** - * @var \Magento\Payment\Gateway\ConfigInterface + * @var ConfigInterface */ private $config; /** * @param LoggerInterface $logger - * @param \Magento\Payment\Gateway\ConfigInterface $config + * @param ConfigInterface|null $config */ public function __construct( LoggerInterface $logger, - \Magento\Payment\Gateway\ConfigInterface $config = null + ConfigInterface $config = null ) { $this->logger = $logger; $this->config = $config; @@ -69,7 +68,7 @@ public function debug(array $data, array $maskKeys = null, $forceDebug = null) */ private function getDebugReplaceFields() { - if ($this->config and $this->config->getValue('debugReplaceKeys')) { + if ($this->config && $this->config->getValue('debugReplaceKeys')) { return explode(',', $this->config->getValue('debugReplaceKeys')); } return []; @@ -82,7 +81,7 @@ private function getDebugReplaceFields() */ private function isDebugOn() { - return $this->config and (bool)$this->config->getValue('debug'); + return $this->config && (bool)$this->config->getValue('debug'); } /** diff --git a/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php b/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php index eb1e3d3a91b92..dd18d9f256d93 100644 --- a/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php +++ b/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php @@ -290,10 +290,8 @@ public function testGetMethodSuccess() public function testGetMethodNotTransparentInterface() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - __('We cannot retrieve the transparent payment method model object.') - ); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage((string)__('We cannot retrieve the transparent payment method model object.')); $methodMock = $this->getMockBuilder(\Magento\Payment\Model\MethodInterface::class) ->getMockForAbstractClass(); diff --git a/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php b/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php index df8bdc9bca54b..7acb595384332 100644 --- a/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php +++ b/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php @@ -6,11 +6,15 @@ namespace Magento\Payment\Test\Unit\Gateway\Command; use Magento\Payment\Gateway\Command\GatewayCommand; +use Magento\Payment\Gateway\ErrorMapper\ErrorMessageMapperInterface; use Magento\Payment\Gateway\Http\ClientInterface; use Magento\Payment\Gateway\Http\TransferFactoryInterface; +use Magento\Payment\Gateway\Http\TransferInterface; use Magento\Payment\Gateway\Request\BuilderInterface; use Magento\Payment\Gateway\Response\HandlerInterface; +use Magento\Payment\Gateway\Validator\ResultInterface; use Magento\Payment\Gateway\Validator\ValidatorInterface; +use PHPUnit_Framework_MockObject_MockObject as MockObject; use Psr\Log\LoggerInterface; /** @@ -18,175 +22,186 @@ */ class GatewayCommandTest extends \PHPUnit\Framework\TestCase { - /** @var GatewayCommand */ - protected $command; + /** + * @var GatewayCommand + */ + private $command; /** - * @var BuilderInterface|\PHPUnit_Framework_MockObject_MockObject + * @var BuilderInterface|MockObject */ - protected $requestBuilderMock; + private $requestBuilder; /** - * @var TransferFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + * @var TransferFactoryInterface|MockObject */ - protected $transferFactoryMock; + private $transferFactory; /** - * @var ClientInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ClientInterface|MockObject */ - protected $clientMock; + private $client; /** - * @var HandlerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var HandlerInterface|MockObject */ - protected $responseHandlerMock; + private $responseHandler; /** - * @var ValidatorInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ValidatorInterface|MockObject */ - protected $validatorMock; + private $validator; /** - * @var LoggerInterface |\PHPUnit_Framework_MockObject_MockObject + * @var LoggerInterface|MockObject */ private $logger; + /** + * @var ErrorMessageMapperInterface|MockObject + */ + private $errorMessageMapper; + protected function setUp() { - $this->requestBuilderMock = $this->createMock( - BuilderInterface::class - ); - $this->transferFactoryMock = $this->createMock( - TransferFactoryInterface::class - ); - $this->clientMock = $this->createMock( - ClientInterface::class - ); - $this->responseHandlerMock = $this->createMock( - HandlerInterface::class - ); - $this->validatorMock = $this->createMock( - ValidatorInterface::class - ); + $this->requestBuilder = $this->createMock(BuilderInterface::class); + $this->transferFactory = $this->createMock(TransferFactoryInterface::class); + $this->client = $this->createMock(ClientInterface::class); + $this->responseHandler = $this->createMock(HandlerInterface::class); + $this->validator = $this->createMock(ValidatorInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->errorMessageMapper = $this->createMock(ErrorMessageMapperInterface::class); $this->command = new GatewayCommand( - $this->requestBuilderMock, - $this->transferFactoryMock, - $this->clientMock, + $this->requestBuilder, + $this->transferFactory, + $this->client, $this->logger, - $this->responseHandlerMock, - $this->validatorMock + $this->responseHandler, + $this->validator, + $this->errorMessageMapper ); } public function testExecute() { $commandSubject = ['authorize']; - $request = [ - 'request_field1' => 'request_value1', - 'request_field2' => 'request_value2' - ]; - $response = ['response_field1' => 'response_value1']; - $validationResult = $this->getMockBuilder( - \Magento\Payment\Gateway\Validator\ResultInterface::class - ) - ->getMockForAbstractClass(); + $this->processRequest($commandSubject, true); - $transferO = $this->getMockBuilder( - \Magento\Payment\Gateway\Http\TransferInterface::class - ) - ->getMockForAbstractClass(); + $this->responseHandler->method('handle') + ->with($commandSubject, ['response_field1' => 'response_value1']); - $this->requestBuilderMock->expects(static::once()) - ->method('build') - ->with($commandSubject) - ->willReturn($request); + $this->command->execute($commandSubject); + } - $this->transferFactoryMock->expects(static::once()) - ->method('create') - ->with($request) - ->willReturn($transferO); + /** + * Checks a case when request fails. + * + * @expectedException \Magento\Payment\Gateway\Command\CommandException + * @expectedExceptionMessage Transaction has been declined. Please try again later. + */ + public function testExecuteValidationFail() + { + $commandSubject = ['authorize']; + $validationFailures = [ + __('Failure #1'), + __('Failure #2'), + ]; - $this->clientMock->expects(static::once()) - ->method('placeRequest') - ->with($transferO) - ->willReturn($response); - $this->validatorMock->expects(static::once()) - ->method('validate') - ->with(array_merge($commandSubject, ['response' =>$response])) - ->willReturn($validationResult); - $validationResult->expects(static::once()) - ->method('isValid') - ->willReturn(true); + $this->processRequest($commandSubject, false, $validationFailures); - $this->responseHandlerMock->expects(static::once()) - ->method('handle') - ->with($commandSubject, $response); + $this->logger->expects(self::exactly(count($validationFailures))) + ->method('critical') + ->withConsecutive( + [self::equalTo('Payment Error: ' . $validationFailures[0])], + [self::equalTo('Payment Error: ' . $validationFailures[1])] + ); $this->command->execute($commandSubject); } - public function testExecuteValidationFail() + /** + * Checks a case when request fails and response errors are mapped. + * + * @expectedException \Magento\Payment\Gateway\Command\CommandException + * @expectedExceptionMessage Failure Mapped + */ + public function testExecuteValidationFailWithMappedErrors() { - $this->expectException( - \Magento\Payment\Gateway\Command\CommandException::class - ); - $commandSubject = ['authorize']; - $request = [ - 'request_field1' => 'request_value1', - 'request_field2' => 'request_value2' - ]; - $response = ['response_field1' => 'response_value1']; $validationFailures = [ __('Failure #1'), __('Failure #2'), ]; - $validationResult = $this->getMockBuilder( - \Magento\Payment\Gateway\Validator\ResultInterface::class - ) - ->getMockForAbstractClass(); + $errorCodes = ['401']; + + $this->processRequest($commandSubject, false, $validationFailures, $errorCodes); + + $this->errorMessageMapper->method('getMessage') + ->willReturnMap( + [ + ['401', 'Unauthorized'], + ['Failure #1', 'Failure Mapped'], + ['Failure #2', null] + ] + ); + + $this->logger->expects(self::exactly(count(array_merge($validationFailures, $errorCodes)))) + ->method('critical') + ->withConsecutive( + [self::equalTo('Payment Error: Unauthorized')], + [self::equalTo('Payment Error: Failure Mapped')], + [self::equalTo('Payment Error: Failure #2')] + ); - $transferO = $this->getMockBuilder( - \Magento\Payment\Gateway\Http\TransferInterface::class - ) + $this->command->execute($commandSubject); + } + + /** + * Performs command actions like request, response and validation. + * + * @param array $commandSubject + * @param bool $validationResult + * @param array $validationFailures + * @param array $errorCodes + */ + private function processRequest( + array $commandSubject, + bool $validationResult, + array $validationFailures = [], + array $errorCodes = [] + ) { + $request = [ + 'request_field1' => 'request_value1', + 'request_field2' => 'request_value2' + ]; + $response = ['response_field1' => 'response_value1']; + $transferO = $this->getMockBuilder(TransferInterface::class) ->getMockForAbstractClass(); - $this->requestBuilderMock->expects(static::once()) - ->method('build') + $this->requestBuilder->method('build') ->with($commandSubject) ->willReturn($request); - $this->transferFactoryMock->expects(static::once()) - ->method('create') + $this->transferFactory->method('create') ->with($request) ->willReturn($transferO); - $this->clientMock->expects(static::once()) - ->method('placeRequest') + $this->client->method('placeRequest') ->with($transferO) ->willReturn($response); - $this->validatorMock->expects(static::once()) - ->method('validate') - ->with(array_merge($commandSubject, ['response' =>$response])) - ->willReturn($validationResult); - $validationResult->expects(static::once()) - ->method('isValid') - ->willReturn(false); - $validationResult->expects(static::once()) - ->method('getFailsDescription') - ->willReturn( - $validationFailures - ); - $this->logger->expects(static::exactly(count($validationFailures))) - ->method('critical') - ->withConsecutive( - [$validationFailures[0]], - [$validationFailures[1]] - ); + $result = $this->getMockBuilder(ResultInterface::class) + ->getMockForAbstractClass(); - $this->command->execute($commandSubject); + $this->validator->method('validate') + ->with(array_merge($commandSubject, ['response' => $response])) + ->willReturn($result); + $result->method('isValid') + ->willReturn($validationResult); + $result->method('getFailsDescription') + ->willReturn($validationFailures); + $result->method('getErrorCodes') + ->willReturn($errorCodes); } } diff --git a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php index 0c808cb7c1faa..3054a705373e4 100644 --- a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php +++ b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php @@ -62,7 +62,7 @@ public function testValidateAllowspecificTrue($storeId, $country, $allowspecific $this->resultFactoryMock->expects($this->once()) ->method('create') - ->with(['isValid' => $isValid, 'failsDescription' => []]) + ->with(['isValid' => $isValid, 'failsDescription' => [], 'errorCodes' => []]) ->willReturn($this->resultMock); $this->assertSame($this->resultMock, $this->model->validate($validationSubject)); @@ -90,7 +90,7 @@ public function testValidateAllowspecificFalse($storeId, $allowspecific, $isVali $this->resultFactoryMock->expects($this->once()) ->method('create') - ->with(['isValid' => $isValid, 'failsDescription' => []]) + ->with(['isValid' => $isValid, 'failsDescription' => [], 'errorCodes' => []]) ->willReturn($this->resultMock); $this->assertSame($this->resultMock, $this->model->validate($validationSubject)); diff --git a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php index 7b86db369b977..7352cb7a4ac6d 100644 --- a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php +++ b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php @@ -75,7 +75,8 @@ public function testValidate() ->with( [ 'isValid' => false, - 'failsDescription' => ['Fail'] + 'failsDescription' => ['Fail'], + 'errorCodes' => [] ] ) ->willReturn($compositeResult); diff --git a/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php b/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php deleted file mode 100644 index f0cb19ef0fa0f..0000000000000 --- a/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php +++ /dev/null @@ -1,89 +0,0 @@ -_objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); - $this->_factory = $objectManagerHelper->getObject( - \Magento\Payment\Model\Method\Factory::class, - ['objectManager' => $this->_objectManagerMock] - ); - } - - public function testCreateMethod() - { - $className = \Magento\Payment\Model\Method\AbstractMethod::class; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - [] - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->_factory->create($className)); - } - - public function testCreateMethodWithArguments() - { - $className = \Magento\Payment\Model\Method\AbstractMethod::class; - $data = ['param1', 'param2']; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - $data - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->_factory->create($className, $data)); - } - - /** - * @expectedException \Magento\Framework\Exception\LocalizedException - * @expectedExceptionMessage WrongClass class doesn't implement \Magento\Payment\Model\MethodInterface - */ - public function testWrongTypeException() - { - $className = 'WrongClass'; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - [] - )->will( - $this->returnValue($methodMock) - ); - - $this->_factory->create($className); - } -} diff --git a/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php b/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php deleted file mode 100644 index 9bdc90829f6fe..0000000000000 --- a/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php +++ /dev/null @@ -1,71 +0,0 @@ -objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); - - $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->factory = $objectManagerHelper->getObject( - \Magento\Payment\Model\Method\Specification\Factory::class, - ['objectManager' => $this->objectManagerMock] - ); - } - - public function testCreateMethod() - { - $className = \Magento\Payment\Model\Method\SpecificationInterface::class; - $methodMock = $this->createMock($className); - $this->objectManagerMock->expects( - $this->once() - )->method( - 'get' - )->with( - $className - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->factory->create($className)); - } - - /** - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage Specification must implement SpecificationInterface - */ - public function testWrongTypeException() - { - $className = 'WrongClass'; - $methodMock = $this->createMock($className); - $this->objectManagerMock->expects( - $this->once() - )->method( - 'get' - )->with( - $className - )->will( - $this->returnValue($methodMock) - ); - - $this->factory->create($className); - } -} diff --git a/app/code/Magento/Payment/composer.json b/app/code/Magento/Payment/composer.json index 39e025ebceede..293d36e093954 100644 --- a/app/code/Magento/Payment/composer.json +++ b/app/code/Magento/Payment/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-checkout": "*", "magento/module-config": "*", diff --git a/app/code/Magento/Payment/etc/di.xml b/app/code/Magento/Payment/etc/di.xml index e2de2244bff89..74f553cc64094 100644 --- a/app/code/Magento/Payment/etc/di.xml +++ b/app/code/Magento/Payment/etc/di.xml @@ -12,6 +12,7 @@ + @@ -36,4 +37,47 @@ Magento\Payment\Gateway\Config\Config + + + + Magento_Payment + error_mapping.xsd + + + + + Magento\Payment\Gateway\ErrorMapper\XmlToArrayConverter + Magento\Payment\Gateway\ErrorMapper\VirtualSchemaLocator + error_mapping.xml + + + + + Magento\Payment\Gateway\ErrorMapper\VirtualConfigReader + payment_error_mapper + + + + + Magento\Payment\Gateway\ErrorMapper\NullMappingData + + + + + + /var/log/payment.log + + + + + + Magento\Payment\Model\Method\VirtualDebug + + + + + + Magento\Payment\Model\Method\VirtualLogger + + diff --git a/app/code/Magento/Payment/etc/error_mapping.xsd b/app/code/Magento/Payment/etc/error_mapping.xsd new file mode 100644 index 0000000000000..97f3c181beb37 --- /dev/null +++ b/app/code/Magento/Payment/etc/error_mapping.xsd @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml index bbd06cd7c48f3..f1988f1ca86bb 100644 --- a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml +++ b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml @@ -46,6 +46,18 @@ $params = $block->getParams(); }); } ); + + var require = window.top.require; + require( + [ + 'jquery' + ], + function($) { + var parent = window.top; + $(parent).trigger('clearTimeout'); + $(parent.document).find('#multishipping-billing-form').submit(); + } + ); window.top.location = "escapeUrl($params['order_success']) ?>"; diff --git a/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php b/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php new file mode 100644 index 0000000000000..0cbd82798a2c1 --- /dev/null +++ b/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php @@ -0,0 +1,114 @@ +express = $express; + + parent::__construct( + $context, + $registry, + $salesConfig, + $reorderHelper, + $data + ); + } + + /** + * Constructor. + * + * @return void + * @throws LocalizedException + */ + protected function _construct() + { + parent::_construct(); + + $order = $this->getOrder(); + if ($order === null) { + return; + } + $message = __('Are you sure you want to authorize full order amount?'); + if ($this->_isAllowedAction('Magento_Paypal::authorization') && $this->canAuthorize($order)) { + $this->addButton( + 'order_authorize', + [ + 'label' => __('Authorize'), + 'class' => 'authorize', + 'onclick' => "confirmSetLocation('{$message}', '{$this->getPaymentAuthorizationUrl()}')", + ] + ); + } + } + + /** + * Returns URL for authorization of full order amount. + * + * @return string + */ + private function getPaymentAuthorizationUrl(): string + { + return $this->getUrl('paypal/express/authorization'); + } + + /** + * Checks if order available for payment authorization. + * + * @param Order $order + * @return bool + * @throws LocalizedException + */ + public function canAuthorize(Order $order): bool + { + if ($order->canUnhold() || $order->isPaymentReview()) { + return false; + } + + $state = $order->getState(); + if ($order->isCanceled() || $state === Order::STATE_COMPLETE || $state === Order::STATE_CLOSED) { + return false; + } + + return $this->express->isOrderAuthorizationAllowed($order->getPayment()); + } +} diff --git a/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php b/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php new file mode 100644 index 0000000000000..c6744734ded61 --- /dev/null +++ b/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php @@ -0,0 +1,118 @@ +express = $express; + + parent::__construct( + $context, + $coreRegistry, + $fileFactory, + $translateInline, + $resultPageFactory, + $resultJsonFactory, + $resultLayoutFactory, + $resultRawFactory, + $orderManagement, + $orderRepository, + $logger + ); + } + + /** + * Authorize full order payment amount. + * + * @return Redirect + */ + public function execute(): Redirect + { + $resultRedirect = $this->resultRedirectFactory->create(); + $order = $this->_initOrder(); + if ($order !== false) { + try { + $this->express->authorizeOrder($order); + $this->orderRepository->save($order); + $this->messageManager->addSuccessMessage(__('Payment authorization has been successfully created.')); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } catch (\Throwable $e) { + $this->messageManager->addErrorMessage(__('Unable to make payment authorization.')); + } + + $resultRedirect->setPath('sales/order/view', ['order_id' => $order->getId()]); + } else { + $resultRedirect->setPath('sales/order/index'); + } + + return $resultRedirect; + } +} diff --git a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php index eb4c35b02696c..055af4162d5f3 100644 --- a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php +++ b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php @@ -7,6 +7,7 @@ namespace Magento\Paypal\Controller\Express\AbstractExpress; +use Magento\Framework\Exception\LocalizedException; use Magento\Paypal\Model\Api\ProcessableException as ApiProcessableException; /** @@ -20,6 +21,11 @@ class PlaceOrder extends \Magento\Paypal\Controller\Express\AbstractExpress */ protected $agreementsValidator; + /** + * @var \Magento\Sales\Api\PaymentFailuresInterface + */ + private $paymentFailures; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Customer\Model\Session $customerSession @@ -30,6 +36,8 @@ class PlaceOrder extends \Magento\Paypal\Controller\Express\AbstractExpress * @param \Magento\Framework\Url\Helper\Data $urlHelper * @param \Magento\Customer\Model\Url $customerUrl * @param \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + * @param \Magento\Sales\Api\PaymentFailuresInterface|null $paymentFailures + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -40,9 +48,9 @@ public function __construct( \Magento\Framework\Session\Generic $paypalSession, \Magento\Framework\Url\Helper\Data $urlHelper, \Magento\Customer\Model\Url $customerUrl, - \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator, + \Magento\Sales\Api\PaymentFailuresInterface $paymentFailures = null ) { - $this->agreementsValidator = $agreementValidator; parent::__construct( $context, $customerSession, @@ -53,6 +61,11 @@ public function __construct( $urlHelper, $customerUrl ); + + $this->agreementsValidator = $agreementValidator; + $this->paymentFailures = $paymentFailures ? : $this->_objectManager->get( + \Magento\Sales\Api\PaymentFailuresInterface::class + ); } /** @@ -118,15 +131,27 @@ public function execute() return; } catch (ApiProcessableException $e) { $this->_processPaypalApiError($e); + } catch (LocalizedException $e) { + $this->processException($e, $e->getRawMessage()); } catch (\Exception $e) { - $this->messageManager->addExceptionMessage( - $e, - __('We can\'t place the order.') - ); - $this->_redirect('*/*/review'); + $this->processException($e, 'We can\'t place the order.'); } } + /** + * Process exception. + * + * @param \Exception $exception + * @param string $message + * + * @return void + */ + private function processException(\Exception $exception, string $message): void + { + $this->messageManager->addExceptionMessage($exception, __($message)); + $this->_redirect('*/*/review'); + } + /** * Process PayPal API's processable errors * @@ -135,6 +160,8 @@ public function execute() */ protected function _processPaypalApiError($exception) { + $this->paymentFailures->handle((int)$this->_getCheckoutSession()->getQuoteId(), $exception->getMessage()); + switch ($exception->getCode()) { case ApiProcessableException::API_MAX_PAYMENT_ATTEMPTS_EXCEEDED: case ApiProcessableException::API_TRANSACTION_EXPIRED: diff --git a/app/code/Magento/Paypal/Controller/Payflow.php b/app/code/Magento/Paypal/Controller/Payflow.php index ab21986bde3ba..78c0536e393ac 100644 --- a/app/code/Magento/Paypal/Controller/Payflow.php +++ b/app/code/Magento/Paypal/Controller/Payflow.php @@ -41,6 +41,11 @@ abstract class Payflow extends \Magento\Framework\App\Action\Action */ protected $_redirectBlockName = 'payflow.link.iframe'; + /** + * @var \Magento\Sales\Api\PaymentFailuresInterface + */ + private $paymentFailures; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Checkout\Model\Session $checkoutSession @@ -48,6 +53,7 @@ abstract class Payflow extends \Magento\Framework\App\Action\Action * @param \Magento\Paypal\Model\PayflowlinkFactory $payflowModelFactory * @param \Magento\Paypal\Helper\Checkout $checkoutHelper * @param \Psr\Log\LoggerInterface $logger + * @param \Magento\Sales\Api\PaymentFailuresInterface|null $paymentFailures */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -55,14 +61,19 @@ public function __construct( \Magento\Sales\Model\OrderFactory $orderFactory, \Magento\Paypal\Model\PayflowlinkFactory $payflowModelFactory, \Magento\Paypal\Helper\Checkout $checkoutHelper, - \Psr\Log\LoggerInterface $logger + \Psr\Log\LoggerInterface $logger, + \Magento\Sales\Api\PaymentFailuresInterface $paymentFailures = null ) { + parent::__construct($context); + $this->_checkoutSession = $checkoutSession; $this->_orderFactory = $orderFactory; $this->_logger = $logger; $this->_payflowModelFactory = $payflowModelFactory; $this->_checkoutHelper = $checkoutHelper; - parent::__construct($context); + $this->paymentFailures = $paymentFailures ?: $this->_objectManager->get( + \Magento\Sales\Api\PaymentFailuresInterface::class + ); } /** @@ -74,6 +85,10 @@ public function __construct( protected function _cancelPayment($errorMsg = '') { $errorMsg = trim(strip_tags($errorMsg)); + $order = $this->_checkoutSession->getLastRealOrder(); + if ($order->getId()) { + $this->paymentFailures->handle((int)$order->getQuoteId(), $errorMsg); + } $gotoSection = false; $this->_checkoutHelper->cancelCurrentOrder($errorMsg); diff --git a/app/code/Magento/Paypal/Controller/Transparent/Response.php b/app/code/Magento/Paypal/Controller/Transparent/Response.php index 23ac20ca8c87b..c54dd529588b9 100644 --- a/app/code/Magento/Paypal/Controller/Transparent/Response.php +++ b/app/code/Magento/Paypal/Controller/Transparent/Response.php @@ -14,6 +14,8 @@ use Magento\Paypal\Model\Payflow\Service\Response\Transaction; use Magento\Paypal\Model\Payflow\Service\Response\Validator\ResponseValidator; use Magento\Paypal\Model\Payflow\Transparent; +use Magento\Sales\Api\PaymentFailuresInterface; +use Magento\Framework\Session\Generic as Session; /** * Class Response @@ -47,6 +49,16 @@ class Response extends \Magento\Framework\App\Action\Action */ private $transparent; + /** + * @var PaymentFailuresInterface + */ + private $paymentFailures; + + /** + * @var Session + */ + private $sessionTransparent; + /** * Constructor * @@ -56,6 +68,8 @@ class Response extends \Magento\Framework\App\Action\Action * @param ResponseValidator $responseValidator * @param LayoutFactory $resultLayoutFactory * @param Transparent $transparent + * @param Session|null $sessionTransparent + * @param PaymentFailuresInterface|null $paymentFailures */ public function __construct( Context $context, @@ -63,7 +77,9 @@ public function __construct( Transaction $transaction, ResponseValidator $responseValidator, LayoutFactory $resultLayoutFactory, - Transparent $transparent + Transparent $transparent, + Session $sessionTransparent = null, + PaymentFailuresInterface $paymentFailures = null ) { parent::__construct($context); $this->coreRegistry = $coreRegistry; @@ -71,6 +87,8 @@ public function __construct( $this->responseValidator = $responseValidator; $this->resultLayoutFactory = $resultLayoutFactory; $this->transparent = $transparent; + $this->sessionTransparent = $sessionTransparent ?: $this->_objectManager->get(Session::class); + $this->paymentFailures = $paymentFailures ?: $this->_objectManager->get(PaymentFailuresInterface::class); } /** @@ -86,6 +104,7 @@ public function execute() } catch (LocalizedException $exception) { $parameters['error'] = true; $parameters['error_msg'] = $exception->getMessage(); + $this->paymentFailures->handle((int)$this->sessionTransparent->getQuoteId(), $parameters['error_msg']); } $this->coreRegistry->register(Iframe::REGISTRY_KEY, $parameters); diff --git a/app/code/Magento/Paypal/Model/Adminhtml/Express.php b/app/code/Magento/Paypal/Model/Adminhtml/Express.php new file mode 100644 index 0000000000000..6724f115dc8a7 --- /dev/null +++ b/app/code/Magento/Paypal/Model/Adminhtml/Express.php @@ -0,0 +1,181 @@ +authCommand = $authCommand; + + parent::__construct( + $context, + $registry, + $extensionFactory, + $customAttributeFactory, + $paymentData, + $scopeConfig, + $logger, + $proFactory, + $storeManager, + $urlBuilder, + $cartFactory, + $checkoutSession, + $exception, + $transactionRepository, + $transactionBuilder, + $resource, + $resourceCollection, + $data + ); + } + + /** + * Creates an authorization of requested amount. + * + * @param OrderInterface $order + * @return $this + * @throws LocalizedException + */ + public function authorizeOrder(OrderInterface $order) + { + $baseTotalDue = $order->getBaseTotalDue(); + + /** @var $payment Payment */ + $payment = $order->getPayment(); + if (!$payment || !$this->isOrderAuthorizationAllowed($payment)) { + throw new LocalizedException(__('Authorization is not allowed.')); + } + + $orderTransaction = $this->getOrderTransaction($payment); + + $api = $this->_callDoAuthorize($baseTotalDue, $payment, $orderTransaction->getTxnId()); + $this->_pro->importPaymentInfo($api, $payment); + + $payment->resetTransactionAdditionalInfo() + ->setIsTransactionClosed(false) + ->setTransactionId($api->getTransactionId()) + ->setParentTransactionId($orderTransaction->getTxnId()); + + $transaction = $payment->addTransaction(Transaction::TYPE_AUTH, null, true); + $message = $this->authCommand->execute($payment, $baseTotalDue, $payment->getOrder()); + $message = $payment->prependMessage($message); + + $payment->addTransactionCommentsToOrder($transaction, $message); + $payment->setAmountAuthorized($order->getTotalDue()); + $payment->setBaseAmountAuthorized($baseTotalDue); + + return $this; + } + + /** + * Checks if payment has authorization transactions. + * + * @param Payment $payment + * @return bool + */ + private function hasAuthorization(Payment $payment): bool + { + return (bool) ($payment->getAmountAuthorized() ?? 0); + } + + /** + * Checks if payment authorization allowed. + * + * @param Payment $payment + * @return bool + * @throws LocalizedException + */ + public function isOrderAuthorizationAllowed(Payment $payment): bool + { + if ($payment->getMethod() === Config::METHOD_EXPRESS && + $payment->getMethodInstance()->getConfigPaymentAction() === AbstractMethod::ACTION_ORDER + ) { + return !$this->hasAuthorization($payment); + } + + return false; + } +} diff --git a/app/code/Magento/Paypal/Model/Api/Nvp.php b/app/code/Magento/Paypal/Model/Api/Nvp.php index 6933c613ef748..624068395394d 100644 --- a/app/code/Magento/Paypal/Model/Api/Nvp.php +++ b/app/code/Magento/Paypal/Model/Api/Nvp.php @@ -844,7 +844,7 @@ public function callGetExpressCheckoutDetails() $request = $this->_exportToRequest($this->_getExpressCheckoutDetailsRequest); $response = $this->call(self::GET_EXPRESS_CHECKOUT_DETAILS, $request); $this->_importFromResponse($this->_paymentInformationResponse, $response); - $this->_exportAddressses($response); + $this->_exportAddresses($response); } /** @@ -1025,7 +1025,7 @@ public function callGetPalDetails() } /** - * Set Customer BillingA greement call + * Set Customer BillingAgreement call * * @return void * @link https://cms.paypal.com/us/cgi-bin/?&cmd=_render-content&content_ID=developer/e_howto_api_nvp_r_SetCustomerBillingAgreement @@ -1425,7 +1425,7 @@ protected function _deformatNVP($nvpstr) $nvpstr = strpos($nvpstr, "\r\n\r\n") !== false ? substr($nvpstr, strpos($nvpstr, "\r\n\r\n") + 4) : $nvpstr; while (strlen($nvpstr)) { - //postion of Key + //position of Key $keypos = strpos($nvpstr, '='); //position of value $valuepos = strpos($nvpstr, '&') ? strpos($nvpstr, '&') : strlen($nvpstr); @@ -1433,7 +1433,7 @@ protected function _deformatNVP($nvpstr) /*getting the Key and Value values and storing in a Associative Array*/ $keyval = substr($nvpstr, $intial, $keypos); $valval = substr($nvpstr, $keypos + 1, $valuepos - $keypos - 1); - //decoding the respose + //decoding the response $nvpArray[urldecode($keyval)] = urldecode($valval); $nvpstr = substr($nvpstr, $valuepos + 1, strlen($nvpstr)); } @@ -1461,8 +1461,21 @@ protected function _exportLineItems(array &$request, $i = 0) * * @param array $data * @return void + * @deprecated 100.2.2 typo in method name + * @see _exportAddresses */ protected function _exportAddressses($data) + { + $this->_exportAddresses($data); + } + + /** + * Create billing and shipping addresses basing on response data + * + * @param array $data + * @return void + */ + protected function _exportAddresses($data) { $address = new \Magento\Framework\DataObject(); \Magento\Framework\DataObject\Mapper::accumulateByMap($data, $address, $this->_billingAddressMap); diff --git a/app/code/Magento/Paypal/Model/Express.php b/app/code/Magento/Paypal/Model/Express.php index accb22b265335..4684abdc9be6d 100644 --- a/app/code/Magento/Paypal/Model/Express.php +++ b/app/code/Magento/Paypal/Model/Express.php @@ -10,6 +10,7 @@ use Magento\Paypal\Model\Express\Checkout as ExpressCheckout; use Magento\Quote\Api\Data\PaymentInterface; use Magento\Sales\Api\Data\OrderPaymentInterface; +use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Payment; use Magento\Sales\Model\Order\Payment\Transaction; use Magento\Quote\Model\Quote; @@ -363,7 +364,6 @@ public function getConfigData($field, $storeId = null) * @param \Magento\Framework\DataObject|\Magento\Payment\Model\InfoInterface|Payment $payment * @param float $amount * @return $this - * @throws \Magento\Framework\Exception\LocalizedException */ public function order(\Magento\Payment\Model\InfoInterface $payment, $amount) { @@ -375,63 +375,12 @@ public function order(\Magento\Payment\Model\InfoInterface $payment, $amount) } $payment->setAdditionalInformation($this->_isOrderPaymentActionKey, true); - if ($payment->getIsFraudDetected()) { return $this; } - $order = $payment->getOrder(); - $orderTransactionId = $payment->getTransactionId(); - - $api = $this->_callDoAuthorize($amount, $payment, $orderTransactionId); - - $state = \Magento\Sales\Model\Order::STATE_PROCESSING; - $status = true; - - $formattedPrice = $order->getBaseCurrency()->formatTxt($amount); - if ($payment->getIsTransactionPending()) { - $message = __('The ordering amount of %1 is pending approval on the payment gateway.', $formattedPrice); - $state = \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW; - } else { - $message = __('Ordered amount of %1', $formattedPrice); - } - - $transaction = $this->transactionBuilder->setPayment($payment) - ->setOrder($order) - ->setTransactionId($payment->getTransactionId()) - ->build(Transaction::TYPE_ORDER); - $payment->addTransactionCommentsToOrder($transaction, $message); - - $this->_pro->importPaymentInfo($api, $payment); - - if ($payment->getIsTransactionPending()) { - $message = __( - 'We\'ll authorize the amount of %1 as soon as the payment gateway approves it.', - $formattedPrice - ); - $state = \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW; - if ($payment->getIsFraudDetected()) { - $status = \Magento\Sales\Model\Order::STATUS_FRAUD; - } - } else { - $message = __('The authorized amount is %1.', $formattedPrice); - } - - $payment->resetTransactionAdditionalInfo(); - - $payment->setTransactionId($api->getTransactionId()); - $payment->setParentTransactionId($orderTransactionId); - - $transaction = $this->transactionBuilder->setPayment($payment) - ->setOrder($order) - ->setTransactionId($payment->getTransactionId()) - ->build(Transaction::TYPE_AUTH); - $payment->addTransactionCommentsToOrder($transaction, $message); - - $order->setState($state) - ->setStatus($status); + $payment->getOrder()->setActionFlag(Order::ACTION_FLAG_INVOICE, false); - $payment->setSkipOrderProcessing(true); return $this; } diff --git a/app/code/Magento/Paypal/Model/Express/Checkout.php b/app/code/Magento/Paypal/Model/Express/Checkout.php index e9f2c1b8415a8..9c9b4dc3e87a7 100644 --- a/app/code/Magento/Paypal/Model/Express/Checkout.php +++ b/app/code/Magento/Paypal/Model/Express/Checkout.php @@ -809,7 +809,9 @@ public function place($token, $shippingMethodCode = null) case \Magento\Sales\Model\Order::STATE_PROCESSING: case \Magento\Sales\Model\Order::STATE_COMPLETE: case \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW: - $this->orderSender->send($order); + if (!$order->getEmailSent()) { + $this->orderSender->send($order); + } $this->_checkoutSession->start(); break; default: @@ -897,7 +899,12 @@ protected function _setExportedAddressData($address, $exportedAddress) { // Exported data is more priority if we came from Express Checkout button $isButton = (bool)$this->_quote->getPayment()->getAdditionalInformation(self::PAYMENT_INFO_BUTTON); - if (!$isButton) { + + // Since country is required field for billing and shipping address, + // we consider the address information to be empty if country is empty. + $isEmptyAddress = ($address->getCountryId() === null); + + if (!$isButton && !$isEmptyAddress) { return; } diff --git a/app/code/Magento/Paypal/Model/Ipn.php b/app/code/Magento/Paypal/Model/Ipn.php index a370bbc77ffb2..9107762c54b69 100644 --- a/app/code/Magento/Paypal/Model/Ipn.php +++ b/app/code/Magento/Paypal/Model/Ipn.php @@ -7,9 +7,9 @@ namespace Magento\Paypal\Model; use Exception; +use Magento\Framework\Exception\LocalizedException; use Magento\Sales\Model\Order\Email\Sender\CreditmemoSender; use Magento\Sales\Model\Order\Email\Sender\OrderSender; -use Magento\Paypal\Model\Info; /** * PayPal Instant Payment Notification processor model @@ -164,11 +164,11 @@ protected function _processOrder() case Info::TXN_TYPE_NEW_CASE: $this->_registerDispute(); break; - // handle new adjustment is created + // handle new adjustment is created case Info::TXN_TYPE_ADJUSTMENT: $this->_registerAdjustment(); break; - //handle new transaction created + //handle new transaction created default: $this->_registerTransaction(); break; @@ -239,16 +239,16 @@ protected function _registerTransaction() case Info::PAYMENTSTATUS_COMPLETED: $this->_registerPaymentCapture(true); break; - // the holded payment was denied on paypal side + // the holded payment was denied on paypal side case Info::PAYMENTSTATUS_DENIED: $this->_registerPaymentDenial(); break; - // customer attempted to pay via bank account, but failed + // customer attempted to pay via bank account, but failed case Info::PAYMENTSTATUS_FAILED: // cancel order $this->_registerPaymentFailure(); break; - // payment was obtained, but money were not captured yet + // payment was obtained, but money were not captured yet case Info::PAYMENTSTATUS_PENDING: $this->_registerPaymentPending(); break; @@ -263,7 +263,7 @@ protected function _registerTransaction() case Info::PAYMENTSTATUS_REFUNDED: $this->_registerPaymentRefund(); break; - // authorization expire/void + // authorization expire/void case Info::PAYMENTSTATUS_EXPIRED: // break is intentionally omitted case Info::PAYMENTSTATUS_VOIDED: @@ -288,24 +288,12 @@ protected function _registerPaymentCapture($skipFraudDetection = false) $parentTransactionId = $this->getRequestData('parent_txn_id'); $this->_importPaymentInformation(); $payment = $this->_order->getPayment(); - $payment->setTransactionId( - $this->getRequestData('txn_id') - ); - $payment->setCurrencyCode( - $this->getRequestData('mc_currency') - ); - $payment->setPreparedMessage( - $this->_createIpnComment('') - ); - $payment->setParentTransactionId( - $parentTransactionId - ); - $payment->setShouldCloseParentTransaction( - 'Completed' === $this->getRequestData('auth_status') - ); - $payment->setIsTransactionClosed( - 0 - ); + $payment->setTransactionId($this->getRequestData('txn_id')); + $payment->setCurrencyCode($this->getRequestData('mc_currency')); + $payment->setPreparedMessage($this->_createIpnComment('')); + $payment->setParentTransactionId($parentTransactionId); + $payment->setShouldCloseParentTransaction('Completed' === $this->getRequestData('auth_status')); + $payment->setIsTransactionClosed(0); $payment->registerCaptureNotification( $this->getRequestData('mc_gross'), $skipFraudDetection && $parentTransactionId @@ -318,9 +306,9 @@ protected function _registerPaymentCapture($skipFraudDetection = false) $this->orderSender->send($this->_order); $this->_order->addStatusHistoryComment( __('You notified customer about invoice #%1.', $invoice->getIncrementId()) - )->setIsCustomerNotified( - true - )->save(); + ) + ->setIsCustomerNotified(true) + ->save(); } } @@ -334,15 +322,13 @@ protected function _registerPaymentDenial() { try { $this->_importPaymentInformation(); - $this->_order->getPayment()->setTransactionId( - $this->getRequestData('txn_id') - )->setNotificationResult( - true - )->setIsTransactionClosed( - true - )->deny(false); + $this->_order->getPayment() + ->setTransactionId($this->getRequestData('txn_id')) + ->setNotificationResult(true) + ->setIsTransactionClosed(true) + ->deny(false); $this->_order->save(); - } catch (\Magento\Framework\Exception\LocalizedException $e) { + } catch (LocalizedException $e) { if ($e->getMessage() != __('We cannot cancel this order.')) { throw $e; } @@ -386,13 +372,11 @@ public function _registerPaymentPending() $this->_importPaymentInformation(); - $this->_order->getPayment()->setPreparedMessage( - $this->_createIpnComment($this->_paypalInfo->explainPendingReason($reason)) - )->setTransactionId( - $this->getRequestData('txn_id') - )->setIsTransactionClosed( - 0 - )->update(false); + $this->_order->getPayment() + ->setPreparedMessage($this->_createIpnComment($this->_paypalInfo->explainPendingReason($reason))) + ->setTransactionId($this->getRequestData('txn_id')) + ->setIsTransactionClosed(0) + ->update(false); $this->_order->save(); } @@ -409,19 +393,12 @@ protected function _registerPaymentAuthorization() $payment->update(true); } else { $this->_importPaymentInformation(); - $payment->setPreparedMessage( - $this->_createIpnComment('') - )->setTransactionId( - $this->getRequestData('txn_id') - )->setParentTransactionId( - $this->getRequestData('parent_txn_id') - )->setCurrencyCode( - $this->getRequestData('mc_currency') - )->setIsTransactionClosed( - 0 - )->registerAuthorizationNotification( - $this->getRequestData('mc_gross') - ); + $payment->setPreparedMessage($this->_createIpnComment('')) + ->setTransactionId($this->getRequestData('txn_id')) + ->setParentTransactionId($this->getRequestData('parent_txn_id')) + ->setCurrencyCode($this->getRequestData('mc_currency')) + ->setIsTransactionClosed(0) + ->registerAuthorizationNotification($this->getRequestData('mc_gross')); } if (!$this->_order->getEmailSent()) { $this->orderSender->send($this->_order); @@ -449,12 +426,13 @@ protected function _registerPaymentReversal() { $reasonCode = $this->getRequestData('reason_code'); $reasonComment = $this->_paypalInfo->explainReasonCode($reasonCode); - $notificationAmount = $this->_order->getBaseCurrency()->formatTxt( - $this->getRequestData('mc_gross') + $this->getRequestData('mc_fee') - ); + $notificationAmount = $this->_order->getBaseCurrency() + ->formatTxt( + $this->getRequestData('mc_gross') + $this->getRequestData('mc_fee') + ); $paymentStatus = $this->_filterPaymentStatus($this->getRequestData('payment_status')); $orderStatus = $paymentStatus == - Info::PAYMENTSTATUS_REVERSED ? Info::ORDER_STATUS_REVERSED : Info::ORDER_STATUS_CANCELED_REVERSAL; + Info::PAYMENTSTATUS_REVERSED ? Info::ORDER_STATUS_REVERSED : Info::ORDER_STATUS_CANCELED_REVERSAL; //Change order status to PayPal Reversed/PayPal Cancelled Reversal if it is possible. $message = __( 'IPN "%1". %2 Transaction amount %3. Transaction ID: "%4"', @@ -464,8 +442,9 @@ protected function _registerPaymentReversal() $this->getRequestData('txn_id') ); $this->_order->setStatus($orderStatus); - $this->_order->save(); - $this->_order->addStatusHistoryComment($message, $orderStatus)->setIsCustomerNotified(false)->save(); + $this->_order->addStatusHistoryComment($message, $orderStatus) + ->setIsCustomerNotified(false) + ->save(); } /** @@ -478,17 +457,12 @@ protected function _registerPaymentRefund() $this->_importPaymentInformation(); $reason = $this->getRequestData('reason_code'); $isRefundFinal = !$this->_paypalInfo->isReversalDisputable($reason); - $payment = $this->_order->getPayment()->setPreparedMessage( - $this->_createIpnComment($this->_paypalInfo->explainReasonCode($reason)) - )->setTransactionId( - $this->getRequestData('txn_id') - )->setParentTransactionId( - $this->getRequestData('parent_txn_id') - )->setIsTransactionClosed( - $isRefundFinal - )->registerRefundNotification( - -1 * $this->getRequestData('mc_gross') - ); + $payment = $this->_order->getPayment() + ->setPreparedMessage($this->_createIpnComment($this->_paypalInfo->explainReasonCode($reason))) + ->setTransactionId($this->getRequestData('txn_id')) + ->setParentTransactionId($this->getRequestData('parent_txn_id')) + ->setIsTransactionClosed($isRefundFinal) + ->registerRefundNotification(-1 * $this->getRequestData('mc_gross')); $this->_order->save(); // TODO: there is no way to close a capture right now @@ -498,9 +472,9 @@ protected function _registerPaymentRefund() $this->creditmemoSender->send($creditMemo); $this->_order->addStatusHistoryComment( __('You notified customer about creditmemo #%1.', $creditMemo->getIncrementId()) - )->setIsCustomerNotified( - true - )->save(); + ) + ->setIsCustomerNotified(true) + ->save(); } } @@ -513,19 +487,14 @@ protected function _registerPaymentVoid() { $this->_importPaymentInformation(); - $parentTxnId = $this->getRequestData( - 'transaction_entity' - ) == 'auth' ? $this->getRequestData( - 'txn_id' - ) : $this->getRequestData( - 'parent_txn_id' - ); + $parentTxnId = $this->getRequestData('transaction_entity') == 'auth' + ? $this->getRequestData('txn_id') + : $this->getRequestData('parent_txn_id'); - $this->_order->getPayment()->setPreparedMessage( - $this->_createIpnComment('') - )->setParentTransactionId( - $parentTxnId - )->registerVoidNotification(); + $this->_order->getPayment() + ->setPreparedMessage($this->_createIpnComment('')) + ->setParentTransactionId($parentTxnId) + ->registerVoidNotification(); $this->_order->save(); } @@ -546,14 +515,14 @@ protected function _importPaymentInformation() // collect basic information $from = []; foreach ([ - Info::PAYER_ID, - 'payer_email' => Info::PAYER_EMAIL, - Info::PAYER_STATUS, - Info::ADDRESS_STATUS, - Info::PROTECTION_EL, - Info::PAYMENT_STATUS, - Info::PENDING_REASON, - ] as $privateKey => $publicKey) { + Info::PAYER_ID, + 'payer_email' => Info::PAYER_EMAIL, + Info::PAYER_STATUS, + Info::ADDRESS_STATUS, + Info::PROTECTION_EL, + Info::PAYMENT_STATUS, + Info::PENDING_REASON, + ] as $privateKey => $publicKey) { if (is_int($privateKey)) { $privateKey = $publicKey; } diff --git a/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php b/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php index 661d1f3814a0b..1ec7f4832bcb2 100644 --- a/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php +++ b/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php @@ -24,7 +24,7 @@ class AvsEmsCodeMapper implements PaymentVerificationInterface * * @var string */ - private static $unavailableCode = 'U'; + private static $unavailableCode = ''; /** * List of mapping AVS codes diff --git a/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php b/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php index 9d215ca6cbe17..da5599984b701 100644 --- a/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php +++ b/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php @@ -11,7 +11,6 @@ use Magento\Paypal\Model\Payflow\Transparent; use Magento\Paypal\Model\Payflowpro; use Magento\Quote\Model\Quote; -use Magento\Sales\Model\Order\Payment; /** * Class SecureToken @@ -59,6 +58,7 @@ public function __construct( */ public function requestToken(Quote $quote) { + $this->transparent->setStore($quote->getStoreId()); $request = $this->transparent->buildBasicRequest(); $request->setTrxtype(Payflowpro::TRXTYPE_AUTH_ONLY); diff --git a/app/code/Magento/Paypal/Model/Payflowlink.php b/app/code/Magento/Paypal/Model/Payflowlink.php index 792309bd76cf9..1955ef3c67661 100644 --- a/app/code/Magento/Paypal/Model/Payflowlink.php +++ b/app/code/Magento/Paypal/Model/Payflowlink.php @@ -10,6 +10,7 @@ use Magento\Payment\Model\Method\ConfigInterfaceFactory; use Magento\Paypal\Model\Payflow\Service\Response\Handler\HandlerInterface; use Magento\Sales\Api\Data\OrderPaymentInterface; +use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Email\Sender\OrderSender; /** @@ -239,11 +240,13 @@ public function initialize($paymentAction, $stateObject) case \Magento\Paypal\Model\Config::PAYMENT_ACTION_AUTH: case \Magento\Paypal\Model\Config::PAYMENT_ACTION_SALE: $payment = $this->getInfoInstance(); + /** @var Order $order */ $order = $payment->getOrder(); $order->setCanSendNewEmailFlag(false); $payment->setAmountAuthorized($order->getTotalDue()); $payment->setBaseAmountAuthorized($order->getBaseTotalDue()); $this->_generateSecureSilentPostHash($payment); + $this->setStore($order->getStoreId()); $request = $this->_buildTokenRequest($payment); $response = $this->postRequest($request, $this->getConfig()); $this->_processTokenErrors($response, $payment); diff --git a/app/code/Magento/Paypal/Model/Payflowpro.php b/app/code/Magento/Paypal/Model/Payflowpro.php index 125aa0f6e65a7..b5fdaf4ae9fd4 100644 --- a/app/code/Magento/Paypal/Model/Payflowpro.php +++ b/app/code/Magento/Paypal/Model/Payflowpro.php @@ -647,7 +647,7 @@ public function buildBasicRequest() * * @param DataObject $response * @return void - * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Payment\Gateway\Command\CommandException * @throws \Magento\Framework\Exception\State\InvalidTransitionException */ public function processErrors(DataObject $response) @@ -659,9 +659,9 @@ public function processErrors(DataObject $response) } elseif ($response->getResultCode() != self::RESPONSE_CODE_APPROVED && $response->getResultCode() != self::RESPONSE_CODE_FRAUDSERVICE_FILTER ) { - throw new \Magento\Framework\Exception\LocalizedException(__($response->getRespmsg())); + throw new \Magento\Payment\Gateway\Command\CommandException(__($response->getRespmsg())); } elseif ($response->getOrigresult() == self::RESPONSE_CODE_DECLINED_BY_FILTER) { - throw new \Magento\Framework\Exception\LocalizedException(__($response->getRespmsg())); + throw new \Magento\Payment\Gateway\Command\CommandException(__($response->getRespmsg())); } } diff --git a/app/code/Magento/Paypal/Model/Pro.php b/app/code/Magento/Paypal/Model/Pro.php index ce96829dafbe2..5e9159bd2c243 100644 --- a/app/code/Magento/Paypal/Model/Pro.php +++ b/app/code/Magento/Paypal/Model/Pro.php @@ -256,7 +256,7 @@ public function capture(\Magento\Framework\DataObject $payment, $amount) } $api = $this->getApi() ->setAuthorizationId($authTransactionId) - ->setIsCaptureComplete($payment->getShouldCloseParentTransaction()) + ->setIsCaptureComplete($payment->isCaptureFinal($amount)) ->setAmount($amount); $order = $payment->getOrder(); diff --git a/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php b/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php new file mode 100644 index 0000000000000..87abdf8264503 --- /dev/null +++ b/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php @@ -0,0 +1,49 @@ +express = $express; + } + + /** + * Checks a possibility to invoice of PayPal Express payments when payment action is "order". + * + * @param Order $order + * @param bool $result + * @return bool + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function afterCanInvoice(Order $order, bool $result): bool + { + if ($this->express->isOrderAuthorizationAllowed($order->getPayment())) { + return false; + } + + return $result; + } +} diff --git a/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php b/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php new file mode 100644 index 0000000000000..d8582b25c7be8 --- /dev/null +++ b/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php @@ -0,0 +1,54 @@ +express = $express; + } + + /** + * Checks a possibility to invoice of PayPal Express payments when payment action is "order". + * + * @param CanInvoice $subject + * @param array $result + * @param OrderInterface $order + * @return array + * @throws LocalizedException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterValidate(CanInvoice $subject, array $result, OrderInterface $order): array + { + if ($this->express->isOrderAuthorizationAllowed($order->getPayment())) { + $result[] = __('An invoice cannot be created when none of authorization transactions available.'); + } + + return $result; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php b/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php new file mode 100644 index 0000000000000..c08d83ac00505 --- /dev/null +++ b/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php @@ -0,0 +1,128 @@ +order = $this->createPartialMock( + Order::class, + ['canUnhold', 'isPaymentReview', 'getState', 'isCanceled', 'getPayment'] + ); + + $this->express = $this->createPartialMock( + Express::class, + ['isOrderAuthorizationAllowed'] + ); + + $this->payment = $this->createMock(Payment::class); + + $this->view = $objectManager->getObject( + View::class, + [ + 'express' => $this->express, + 'data' => [], + ] + ); + } + + /** + * Tests if authorization action is allowed for order. + * + * @param bool $canUnhold + * @param bool $isPaymentReview + * @param bool $isCanceled + * @param bool $authAllowed + * @param string $orderState + * @param bool $canAuthorize + * @throws LocalizedException + * @dataProvider orderDataProvider + */ + public function testIsOrderAuthorizationAllowed( + bool $canUnhold, + bool $isPaymentReview, + bool $isCanceled, + bool $authAllowed, + string $orderState, + bool $canAuthorize + ) { + $this->order->method('canUnhold') + ->willReturn($canUnhold); + + $this->order->method('isPaymentReview') + ->willReturn($isPaymentReview); + + $this->order->method('isCanceled') + ->willReturn($isCanceled); + + $this->order->method('getState') + ->willReturn($orderState); + + $this->order->method('getPayment') + ->willReturn($this->payment); + + $this->express->method('isOrderAuthorizationAllowed') + ->with($this->payment) + ->willReturn($authAllowed); + + $this->assertEquals($canAuthorize, $this->view->canAuthorize($this->order)); + } + + /** + * Data provider for order methods call. + * + * @return array + */ + public function orderDataProvider(): array + { + return [ + [true, false, false, true, Order::STATE_PROCESSING, false], + [false, true, false, true, Order::STATE_PROCESSING, false], + [false, false, true, true, Order::STATE_PROCESSING, false], + [false, false, false, false, Order::STATE_PROCESSING, false], + [false, false, false, true, Order::STATE_COMPLETE, false], + [false, false, false, true, Order::STATE_CLOSED, false], + [false, false, false, true, Order::STATE_PROCESSING, true], + ]; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php b/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php index e25864bbc2f3c..bd4da25cb84d0 100644 --- a/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php @@ -5,6 +5,7 @@ */ namespace Magento\Paypal\Test\Unit\Controller\Payflow; +use Magento\Sales\Api\PaymentFailuresInterface; use Magento\Checkout\Block\Onepage\Success; use Magento\Checkout\Model\Session; use Magento\Framework\App\Action\Context; @@ -90,6 +91,11 @@ class ReturnUrlTest extends \PHPUnit\Framework\TestCase */ private $objectManager; + /** + * @var PaymentFailuresInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentFailures; + /** * @inheritdoc */ @@ -138,6 +144,17 @@ protected function setUp() ->setMethods(['getLastRealOrderId', 'getLastRealOrder', 'restoreQuote']) ->getMock(); + $this->quote = $this->getMockBuilder(CartInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->context->expects($this->any())->method('getView')->willReturn($this->view); + $this->context->expects($this->any())->method('getRequest')->willReturn($this->request); + + $this->paymentFailures = $this->getMockBuilder(PaymentFailuresInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->context->method('getView') ->willReturn($this->view); $this->context->method('getRequest') @@ -148,6 +165,7 @@ protected function setUp() 'checkoutSession' => $this->checkoutSession, 'orderFactory' => $this->orderFactory, 'checkoutHelper' => $this->checkoutHelper, + 'paymentFailures' => $this->paymentFailures, ]); } @@ -321,6 +339,7 @@ public function testCheckAdvancedAcceptingByPaymentMethod() 'checkoutSession' => $this->checkoutSession, 'orderFactory' => $this->orderFactory, 'checkoutHelper' => $this->checkoutHelper, + 'paymentFailures' => $this->paymentFailures, ]); $returnUrl->execute(); diff --git a/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php b/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php index a10d103860c65..acefebb779200 100644 --- a/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php @@ -8,16 +8,16 @@ use Magento\Framework\App\Action\Context; use Magento\Framework\App\RequestInterface; use Magento\Framework\Registry; +use Magento\Framework\Session\Generic as Session; use Magento\Framework\View\Result\Layout; use Magento\Framework\View\Result\LayoutFactory; use Magento\Paypal\Controller\Transparent\Response; use Magento\Paypal\Model\Payflow\Service\Response\Transaction; use Magento\Paypal\Model\Payflow\Service\Response\Validator\ResponseValidator; use Magento\Paypal\Model\Payflow\Transparent; +use Magento\Sales\Api\PaymentFailuresInterface; /** - * Class ResponseTest - * * Test for class \Magento\Paypal\Controller\Transparent\Response * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -53,6 +53,19 @@ class ResponseTest extends \PHPUnit\Framework\TestCase */ private $payflowFacade; + /** + * @var PaymentFailuresInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentFailures; + + /** + * @var Session|\PHPUnit_Framework_MockObject_MockObject + */ + private $sessionTransparent; + + /** + * @inheritdoc + */ protected function setUp() { $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) @@ -97,6 +110,14 @@ protected function setUp() ->disableOriginalConstructor() ->setMethods([]) ->getMock(); + $this->paymentFailures = $this->getMockBuilder(PaymentFailuresInterface::class) + ->disableOriginalConstructor() + ->setMethods(['handle']) + ->getMock(); + $this->sessionTransparent = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->setMethods(['getQuoteId']) + ->getMock(); $this->object = new Response( $this->contextMock, @@ -104,7 +125,9 @@ protected function setUp() $this->transactionMock, $this->responseValidatorMock, $this->resultLayoutFactoryMock, - $this->payflowFacade + $this->payflowFacade, + $this->sessionTransparent, + $this->paymentFailures ); } @@ -131,6 +154,8 @@ public function testExecute() $this->resultLayoutMock->expects($this->once()) ->method('getLayout') ->willReturn($this->getLayoutMock()); + $this->paymentFailures->expects($this->never()) + ->method('handle'); $this->assertInstanceOf(\Magento\Framework\Controller\ResultInterface::class, $this->object->execute()); } @@ -156,6 +181,12 @@ public function testExecuteWithException() $this->resultLayoutMock->expects($this->once()) ->method('getLayout') ->willReturn($this->getLayoutMock()); + $this->sessionTransparent->method('getQuoteId') + ->willReturn(1); + $this->paymentFailures->expects($this->once()) + ->method('handle') + ->with(1) + ->willReturnSelf(); $this->assertInstanceOf(\Magento\Framework\Controller\ResultInterface::class, $this->object->execute()); } diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php new file mode 100644 index 0000000000000..0b7b9aeb2a7bb --- /dev/null +++ b/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php @@ -0,0 +1,218 @@ +nvp = $this->createPartialMock( + Nvp::class, + ['getData','setProcessableErrors', 'callDoAuthorization'] + ); + $this->nvp->method('getData')->willReturn([]); + $this->nvp->method('setProcessableErrors')->willReturnSelf(); + + $this->pro = $this->createPartialMock( + Pro::class, + ['setMethod', 'getApi', 'importPaymentInfo'] + ); + $this->pro->method('getApi')->willReturn($this->nvp); + + $this->transaction = $this->getMockForAbstractClass(TransactionInterface::class); + $this->transactionRepository = $this->createPartialMock( + TransactionRepository::class, + ['getByTransactionType'] + ); + $this->transactionRepository->method('getByTransactionType')->willReturn($this->transaction); + + $this->express = $objectManager->getObject( + Express::class, + [ + 'data' => [$this->pro], + 'transactionRepository' => $this->transactionRepository, + ] + ); + + $this->paymentInstance = $this->getMockForAbstractClass(MethodInterface::class); + $this->payment = $this->createPartialMock( + Payment::class, + [ + 'getAmountAuthorized', + 'getMethod', + 'getMethodInstance', + 'getId', + 'getOrder', + 'addTransaction', + 'addTransactionCommentsToOrder', + 'setAmountAuthorized', + ] + ); + $this->payment->method('getMethodInstance') + ->willReturn($this->paymentInstance); + + $this->payment->method('addTransaction') + ->willReturn($this->transaction); + } + + /** + * Tests payment authorization flow for order. + * + * @throws LocalizedException + */ + public function testAuthorizeOrder() + { + $this->order = $this->createPartialMock( + Order::class, + ['getId', 'getPayment', 'getTotalDue', 'getBaseTotalDue'] + ); + $this->order->method('getPayment') + ->willReturn($this->payment); + $this->order->method('getId') + ->willReturn(1); + + $totalDue = 15; + $baseTotalDue = 10; + + $this->order->method('getTotalDue') + ->willReturn($totalDue); + $this->order->method('getBaseTotalDue') + ->willReturn($baseTotalDue); + + $this->payment->method('getMethod') + ->willReturn('paypal_express'); + $this->payment->method('getId') + ->willReturn(1); + $this->payment->method('getOrder') + ->willReturn($this->order); + $this->payment->method('getAmountAuthorized') + ->willReturn(0); + + $this->paymentInstance->method('getConfigPaymentAction') + ->willReturn('order'); + + $this->nvp->expects(static::once()) + ->method('callDoAuthorization') + ->willReturnSelf(); + + $this->payment->expects(static::once()) + ->method('addTransaction') + ->with(Transaction::TYPE_AUTH) + ->willReturn($this->transaction); + + $this->payment->method('addTransactionCommentsToOrder') + ->with($this->transaction); + + $this->payment->method('setAmountAuthorized') + ->with($totalDue); + + $this->express->authorizeOrder($this->order); + } + + /** + * Checks if payment authorization is allowed. + * + * @param string $method + * @param string $action + * @param float $authorizedAmount + * @param bool $isAuthAllowed + * @throws LocalizedException + * @dataProvider paymentDataProvider + */ + public function testIsOrderAuthorizationAllowed( + string $method, + string $action, + float $authorizedAmount, + bool $isAuthAllowed + ) { + $this->payment->method('getMethod') + ->willReturn($method); + + $this->paymentInstance->method('getConfigPaymentAction') + ->willReturn($action); + + $this->payment->method('getAmountAuthorized') + ->willReturn($authorizedAmount); + + static::assertEquals($isAuthAllowed, $this->express->isOrderAuthorizationAllowed($this->payment)); + } + + /** + * Data provider for payment methods call. + * + * @return array + */ + public function paymentDataProvider(): array + { + return [ + ['paypal_express', 'sale', 10, false], + ['paypal_express', 'order', 50, false], + ['paypal_express', 'capture', 0, false], + ['paypal_express', 'order', 0, true], + ['braintree', 'authorize', 10, false], + ['braintree', 'authorize', 0, false], + ]; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php index 10c432da77202..2ef08e4d4226b 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php @@ -132,7 +132,9 @@ protected function _invokeNvpProperty(\Magento\Paypal\Model\Api\Nvp $nvpObject, public function testCall($response, $processableErrors, $exception, $exceptionMessage = '', $exceptionCode = null) { if (isset($exception)) { - $this->expectException($exception, $exceptionMessage, $exceptionCode); + $this->expectException($exception); + $this->expectExceptionMessage($exceptionMessage); + $this->expectExceptionCode($exceptionCode); } $this->curl->expects($this->once()) ->method('read') diff --git a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php index 1b8c33622e784..abedc573558f1 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php @@ -5,13 +5,21 @@ */ namespace Magento\Paypal\Test\Unit\Model; +use Magento\Checkout\Model\Session; use Magento\Framework\DataObject; use Magento\Framework\Event\ManagerInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Payment\Model\InfoInterface; use Magento\Payment\Observer\AbstractDataAssignObserver; +use Magento\Paypal\Model\Api\Nvp; use Magento\Paypal\Model\Api\ProcessableException as ApiProcessableException; use Magento\Paypal\Model\Express; +use Magento\Paypal\Model\Pro; use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; +use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface; +use \PHPUnit_Framework_MockObject_MockObject as MockObject; /** * Class ExpressTest @@ -38,123 +46,122 @@ class ExpressTest extends \PHPUnit\Framework\TestCase /** * @var Express */ - protected $_model; + private $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Session|MockObject */ - protected $_checkoutSession; + private $checkoutSession; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Pro|MockObject */ - protected $_pro; + private $pro; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Nvp|MockObject */ - protected $_nvp; + private $nvp; /** - * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager + * @var ObjectManager */ - protected $_helper; + private $helper; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var BuilderInterface|MockObject */ - protected $transactionBuilder; + private $transactionBuilder; /** - * @var ManagerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ManagerInterface|MockObject */ - private $eventManagerMock; + private $eventManager; protected function setUp() { - $this->_checkoutSession = $this->createPartialMock( - \Magento\Checkout\Model\Session::class, + $this->checkoutSession = $this->createPartialMock( + Session::class, ['getPaypalTransactionData', 'setPaypalTransactionData'] ); $this->transactionBuilder = $this->getMockForAbstractClass( - \Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface::class, + BuilderInterface::class, [], '', false, false ); - $this->_nvp = $this->createPartialMock( - \Magento\Paypal\Model\Api\Nvp::class, - ['setProcessableErrors', 'setAmount', 'setCurrencyCode', 'setTransactionId', 'callDoAuthorization'] + $this->nvp = $this->createPartialMock( + Nvp::class, + [ + 'setProcessableErrors', + 'setAmount', + 'setCurrencyCode', + 'setTransactionId', + 'callDoAuthorization', + 'setData', + ] ); - $this->_pro = $this->createPartialMock( - \Magento\Paypal\Model\Pro::class, + $this->pro = $this->createPartialMock( + Pro::class, ['setMethod', 'getApi', 'importPaymentInfo', 'resetApi'] ); - $this->eventManagerMock = $this->getMockBuilder(ManagerInterface::class) + $this->eventManager = $this->getMockBuilder(ManagerInterface::class) ->setMethods(['dispatch']) ->getMockForAbstractClass(); - $this->_pro->expects($this->any())->method('getApi')->will($this->returnValue($this->_nvp)); - $this->_helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->pro->expects($this->any())->method('getApi')->will($this->returnValue($this->nvp)); + $this->helper = new ObjectManager($this); } public function testSetApiProcessableErrors() { - $this->_nvp->expects($this->once())->method('setProcessableErrors')->with($this->errorCodes); + $this->nvp->expects($this->once())->method('setProcessableErrors')->with($this->errorCodes); - $this->_model = $this->_helper->getObject( + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, - 'transactionBuilder' => $this->transactionBuilder + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, + 'transactionBuilder' => $this->transactionBuilder, ] ); } + /** + * Tests order payment action. + * + * @return void + */ public function testOrder() { - $this->_nvp->expects($this->any())->method('setProcessableErrors')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setAmount')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setCurrencyCode')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setTransactionId')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('callDoAuthorization')->will($this->returnSelf()); - - $this->_checkoutSession->expects($this->once())->method('getPaypalTransactionData')->will( - $this->returnValue([]) - ); - $this->_checkoutSession->expects($this->once())->method('setPaypalTransactionData')->with([]); - - $currency = $this->createPartialMock(\Magento\Directory\Model\Currency::class, ['__wakeup', 'formatTxt']); - $paymentModel = $this->createPartialMock(\Magento\Sales\Model\Order\Payment::class, [ - '__wakeup', - 'getBaseCurrency', - 'getOrder', - 'getIsTransactionPending', - 'addStatusHistoryComment', - 'addTransactionCommentsToOrder' - ]); - $order = $this->createPartialMock( - \Magento\Sales\Model\Order::class, - ['setState', 'getBaseCurrency', 'getBaseCurrencyCode', 'setStatus'] - ); - $paymentModel->expects($this->any())->method('getOrder')->willReturn($order); - $order->expects($this->any())->method('getBaseCurrency')->willReturn($currency); - $order->expects($this->any())->method('setState')->with('payment_review')->willReturnSelf(); - $paymentModel->expects($this->any())->method('getIsTransactionPending')->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setOrder')->with($order)->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setPayment')->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setTransactionId')->will($this->returnSelf()); - $this->_model = $this->_helper->getObject( + $transactionData = ['TOKEN' => 'EC-7NJ4634216284232D']; + $this->checkoutSession + ->method('getPaypalTransactionData') + ->willReturn($transactionData); + + $order = $this->createPartialMock(Order::class, ['setActionFlag']); + $order->method('setActionFlag') + ->with(Order::ACTION_FLAG_INVOICE, false) + ->willReturnSelf(); + + $paymentModel = $this->createPartialMock(Payment::class, ['getOrder']); + $paymentModel->method('getOrder') + ->willReturn($order); + + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, - 'transactionBuilder' => $this->transactionBuilder + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, ] ); - $this->assertEquals($this->_model, $this->_model->order($paymentModel, 12.3)); + + $this->nvp->method('setData') + ->with($transactionData) + ->willReturnSelf(); + + static::assertEquals($this->model, $this->model->order($paymentModel, 12.3)); } public function testAssignData() @@ -180,18 +187,18 @@ public function testAssignData() ] ); - $this->_model = $this->_helper->getObject( + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, 'transactionBuilder' => $this->transactionBuilder, - 'eventDispatcher' => $this->eventManagerMock, + 'eventDispatcher' => $this->eventManager, ] ); $paymentInfo = $this->createMock(InfoInterface::class); - $this->_model->setInfoInstance($paymentInfo); + $this->model->setInfoInstance($paymentInfo); $this->parentAssignDataExpectation($data); @@ -203,7 +210,7 @@ public function testAssignData() [Express\Checkout::PAYMENT_INFO_TRANSPORT_TOKEN, $transportValue] ); - $this->_model->assignData($data); + $this->model->assignData($data); } /** @@ -213,21 +220,21 @@ private function parentAssignDataExpectation(DataObject $data) { $eventData = [ AbstractDataAssignObserver::METHOD_CODE => $this, - AbstractDataAssignObserver::MODEL_CODE => $this->_model->getInfoInstance(), + AbstractDataAssignObserver::MODEL_CODE => $this->model->getInfoInstance(), AbstractDataAssignObserver::DATA_CODE => $data ]; - $this->eventManagerMock->expects(static::exactly(2)) + $this->eventManager->expects(static::exactly(2)) ->method('dispatch') ->willReturnMap( [ [ - 'payment_method_assign_data_' . $this->_model->getCode(), - $eventData + 'payment_method_assign_data_' . $this->model->getCode(), + $eventData, ], [ 'payment_method_assign_data', - $eventData + $eventData, ] ] ); diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php index eb259043a2d4f..ea86a04206f7b 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php @@ -85,17 +85,17 @@ public function testGetCodeWithException() public function getCodeDataProvider() { return [ - ['avsZip' => null, 'avsStreet' => null, 'expected' => 'U'], - ['avsZip' => null, 'avsStreet' => 'Y', 'expected' => 'U'], - ['avsZip' => 'Y', 'avsStreet' => null, 'expected' => 'U'], + ['avsZip' => null, 'avsStreet' => null, 'expected' => ''], + ['avsZip' => null, 'avsStreet' => 'Y', 'expected' => ''], + ['avsZip' => 'Y', 'avsStreet' => null, 'expected' => ''], ['avsZip' => 'Y', 'avsStreet' => 'Y', 'expected' => 'Y'], ['avsZip' => 'N', 'avsStreet' => 'Y', 'expected' => 'A'], ['avsZip' => 'Y', 'avsStreet' => 'N', 'expected' => 'Z'], ['avsZip' => 'N', 'avsStreet' => 'N', 'expected' => 'N'], - ['avsZip' => 'X', 'avsStreet' => 'Y', 'expected' => 'U'], - ['avsZip' => 'N', 'avsStreet' => 'X', 'expected' => 'U'], - ['avsZip' => '', 'avsStreet' => 'Y', 'expected' => 'U'], - ['avsZip' => 'N', 'avsStreet' => '', 'expected' => 'U'] + ['avsZip' => 'X', 'avsStreet' => 'Y', 'expected' => ''], + ['avsZip' => 'N', 'avsStreet' => 'X', 'expected' => ''], + ['avsZip' => '', 'avsStreet' => 'Y', 'expected' => ''], + ['avsZip' => 'N', 'avsStreet' => '', 'expected' => ''] ]; } } diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php index d4a7db25cae89..d8e54ad28fcc8 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php @@ -10,6 +10,9 @@ use Magento\Framework\UrlInterface; use Magento\Paypal\Model\Payflow\Service\Request\SecureToken; use Magento\Paypal\Model\Payflow\Transparent; +use Magento\Paypal\Model\PayflowConfig; +use Magento\Quote\Model\Quote; +use PHPUnit_Framework_MockObject_MockObject as MockObject; /** * Test class for \Magento\Paypal\Model\Payflow\Service\Request\SecureToken @@ -19,23 +22,26 @@ class SecureTokenTest extends \PHPUnit\Framework\TestCase /** * @var SecureToken */ - protected $model; + private $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject|Transparent + * @var Transparent|MockObject */ - protected $transparent; + private $transparent; /** - * @var \PHPUnit_Framework_MockObject_MockObject|Random + * @var Random|MockObject */ - protected $mathRandom; + private $mathRandom; /** - * @var \PHPUnit_Framework_MockObject_MockObject|UrlInterface + * @var UrlInterface|MockObject */ - protected $url; + private $url; + /** + * @inheritdoc + */ protected function setUp() { $this->url = $this->createMock(\Magento\Framework\UrlInterface::class); @@ -52,11 +58,29 @@ protected function setUp() public function testRequestToken() { $request = new DataObject(); + $storeId = 1; $secureTokenID = 'Sdj46hDokds09c8k2klaGJdKLl032ekR'; + $response = new DataObject([ + 'result' => '0', + 'respmsg' => 'Approved', + 'securetoken' => '80IgSbabyj0CtBDWHZZeQN3', + 'securetokenid' => $secureTokenID, + 'result_code' => '0', + ]); + + $quote = $this->getMockBuilder(Quote::class) + ->disableOriginalConstructor() + ->getMock(); + $quote->expects($this->once()) + ->method('getStoreId') + ->willReturn($storeId); $this->transparent->expects($this->once()) ->method('buildBasicRequest') ->willReturn($request); + $this->transparent->expects($this->once()) + ->method('setStore') + ->with($storeId); $this->transparent->expects($this->once()) ->method('fillCustomerContacts'); $this->transparent->expects($this->once()) @@ -64,7 +88,7 @@ public function testRequestToken() ->willReturn($this->createMock(\Magento\Paypal\Model\PayflowConfig::class)); $this->transparent->expects($this->once()) ->method('postRequest') - ->willReturn(new DataObject()); + ->willReturn($response); $this->mathRandom->expects($this->once()) ->method('getUniqueHash') @@ -73,8 +97,6 @@ public function testRequestToken() $this->url->expects($this->exactly(3)) ->method('getUrl'); - $quote = $this->createMock(\Magento\Quote\Model\Quote::class); - $this->model->requestToken($quote); $this->assertEquals($secureTokenID, $request->getSecuretokenid()); diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php index b24ca640dea2d..dd1b4c3242732 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php @@ -23,8 +23,8 @@ public function testConstructorSuccess() public function testConstructorException() { - $this->expectException( - 'LogicException', + $this->expectException('LogicException'); + $this->expectExceptionMessage( 'Type mismatch. Expected type: HandlerInterface. Actual: string, Code: weird_handler' ); diff --git a/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php b/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php index 362615e965d1b..80c8194e07654 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php @@ -101,16 +101,20 @@ protected function setUp() public function testInitialize() { + $storeId = 1; $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->expects($this->exactly(2)) + ->method('getStoreId') + ->willReturn($storeId); $this->infoInstance->expects($this->any()) ->method('getOrder') - ->will($this->returnValue($order)); + ->willReturn($order); $this->infoInstance->expects($this->any()) ->method('setAdditionalInformation') - ->will($this->returnSelf()); + ->willReturnSelf(); $this->paypalConfig->expects($this->once()) ->method('getBuildNotationCode') - ->will($this->returnValue('build notation code')); + ->willReturn('build notation code'); $response = new \Magento\Framework\DataObject( [ @@ -148,6 +152,7 @@ public function testInitialize() $stateObject = new \Magento\Framework\DataObject(); $this->model->initialize(\Magento\Paypal\Model\Config::PAYMENT_ACTION_AUTH, $stateObject); + self::assertEquals($storeId, $this->model->getStore(), '{Store} should be set'); } /** diff --git a/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php b/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php index b0bc3d641743c..c8998f0c24129 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php @@ -119,6 +119,9 @@ public function testCapture() ->method('getOrder') ->willReturn($orderMock); + $paymentMock->method('isCaptureFinal') + ->willReturn(true); + $this->apiMock->expects(static::once()) ->method('getTransactionId') ->willReturn(45); @@ -236,16 +239,14 @@ protected function getPaymentMock() $paymentMock = $this->getMockBuilder(\Magento\Payment\Model\Info::class) ->disableOriginalConstructor() ->setMethods([ - 'getParentTransactionId', 'getOrder', 'getShouldCloseParentTransaction' + 'getParentTransactionId', 'getOrder', 'getShouldCloseParentTransaction', 'isCaptureFinal', ]) ->getMock(); $parentTransactionId = 43; $paymentMock->expects(static::once()) ->method('getParentTransactionId') ->willReturn($parentTransactionId); - $paymentMock->expects(static::once()) - ->method('getShouldCloseParentTransaction') - ->willReturn(true); + return $paymentMock; } diff --git a/app/code/Magento/Paypal/composer.json b/app/code/Magento/Paypal/composer.json index 7dc01c6512ddb..8bc05710452bb 100644 --- a/app/code/Magento/Paypal/composer.json +++ b/app/code/Magento/Paypal/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "lib-libxml": "*", "magento/framework": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/Paypal/etc/acl.xml b/app/code/Magento/Paypal/etc/acl.xml index 0344af13519e5..4373ebc4c12bf 100644 --- a/app/code/Magento/Paypal/etc/acl.xml +++ b/app/code/Magento/Paypal/etc/acl.xml @@ -33,6 +33,11 @@ + + + + + diff --git a/app/code/Magento/Paypal/etc/adminhtml/system.xml b/app/code/Magento/Paypal/etc/adminhtml/system.xml index 1c8da8127f8fe..c1ff4c9b1c6ca 100644 --- a/app/code/Magento/Paypal/etc/adminhtml/system.xml +++ b/app/code/Magento/Paypal/etc/adminhtml/system.xml @@ -32,6 +32,7 @@ + 1 paypal-top-section payments-other-header \Magento\Config\Block\System\Config\Form\Fieldset diff --git a/app/code/Magento/Paypal/etc/db_schema.xml b/app/code/Magento/Paypal/etc/db_schema.xml index f6f5448dbc2f0..2703ee4f5be30 100644 --- a/app/code/Magento/Paypal/etc/db_schema.xml +++ b/app/code/Magento/Paypal/etc/db_schema.xml @@ -145,7 +145,7 @@
    - +
    diff --git a/app/code/Magento/Paypal/etc/di.xml b/app/code/Magento/Paypal/etc/di.xml index 3071907af4717..c0141bbb3215e 100644 --- a/app/code/Magento/Paypal/etc/di.xml +++ b/app/code/Magento/Paypal/etc/di.xml @@ -67,6 +67,12 @@ + + + + + + Magento\Paypal\Model\Config::METHOD_WPP_PE_EXPRESS diff --git a/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml b/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml new file mode 100644 index 0000000000000..3c11109b7fd63 --- /dev/null +++ b/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/Paypal/view/adminhtml/templates/transparent/form.phtml b/app/code/Magento/Paypal/view/adminhtml/templates/transparent/form.phtml index cdd4779a2fd87..532fa88c4986a 100644 --- a/app/code/Magento/Paypal/view/adminhtml/templates/transparent/form.phtml +++ b/app/code/Magento/Paypal/view/adminhtml/templates/transparent/form.phtml @@ -135,7 +135,7 @@ $ccExpMonth = $block->getInfoData('cc_exp_month'); name="payment[is_active_payment_token_enabler]" class="admin__control-checkbox"/> diff --git a/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml index 4f514806eeb89..73c44faff5a57 100644 --- a/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml @@ -44,6 +44,15 @@ true + + false + + + false + + + false + diff --git a/app/code/Magento/Paypal/view/frontend/web/order-review.js b/app/code/Magento/Paypal/view/frontend/web/order-review.js index 566c9c8da06dc..2155b52f4081b 100644 --- a/app/code/Magento/Paypal/view/frontend/web/order-review.js +++ b/app/code/Magento/Paypal/view/frontend/web/order-review.js @@ -108,7 +108,7 @@ define([ }, /** - * trigger change for the update of shippping methods from server + * trigger change for the update of shipping methods from server */ _updateOrderHandler: function () { $(this.options.shippingSelector).trigger('change'); @@ -297,7 +297,7 @@ define([ this._updateOrderSubmit(true); this._toggleButton(this.options.updateOrderSelector, true); - // form data and callBack updated based on the shippping Form element + // form data and callBack updated based on the shipping Form element if (this.isShippingSubmitForm) { formData = $(this.options.shippingSubmitFormSelector).serialize() + '&isAjax=true'; diff --git a/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php b/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php index 2aaf0f30fe71d..f3720960ca6e5 100644 --- a/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php +++ b/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php @@ -50,6 +50,20 @@ class CheckExpirePersistentQuoteObserver implements ObserverInterface */ protected $_persistentData = null; + /** + * Request + * + * @var \Magento\Framework\App\RequestInterface + */ + private $request; + + /** + * Checkout Page path + * + * @var string + */ + private $checkoutPagePath = 'checkout'; + /** * @param \Magento\Persistent\Helper\Session $persistentSession * @param \Magento\Persistent\Helper\Data $persistentData @@ -57,6 +71,7 @@ class CheckExpirePersistentQuoteObserver implements ObserverInterface * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Customer\Model\Session $customerSession * @param \Magento\Checkout\Model\Session $checkoutSession + * @param \Magento\Framework\App\RequestInterface $request */ public function __construct( \Magento\Persistent\Helper\Session $persistentSession, @@ -64,7 +79,8 @@ public function __construct( \Magento\Persistent\Model\QuoteManager $quoteManager, \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Customer\Model\Session $customerSession, - \Magento\Checkout\Model\Session $checkoutSession + \Magento\Checkout\Model\Session $checkoutSession, + \Magento\Framework\App\RequestInterface $request ) { $this->_persistentSession = $persistentSession; $this->quoteManager = $quoteManager; @@ -72,6 +88,7 @@ public function __construct( $this->_checkoutSession = $checkoutSession; $this->_eventManager = $eventManager; $this->_persistentData = $persistentData; + $this->request = $request; } /** @@ -90,12 +107,32 @@ public function execute(\Magento\Framework\Event\Observer $observer) !$this->_persistentSession->isPersistent() && !$this->_customerSession->isLoggedIn() && $this->_checkoutSession->getQuoteId() && - !$observer->getControllerAction() instanceof \Magento\Checkout\Controller\Onepage - // persistent session does not expire on onepage checkout page to not spoil customer group id + !$this->isRequestFromCheckoutPage($this->request) + // persistent session does not expire on onepage checkout page ) { $this->_eventManager->dispatch('persistent_session_expired'); $this->quoteManager->expire(); $this->_customerSession->setCustomerId(null)->setCustomerGroupId(null); } } + + /** + * Check current request is coming from onepage checkout page. + * + * @param \Magento\Framework\App\RequestInterface $request + * @return bool + */ + private function isRequestFromCheckoutPage(\Magento\Framework\App\RequestInterface $request): bool + { + $requestUri = (string)$request->getRequestUri(); + $refererUri = (string)$request->getServer('HTTP_REFERER'); + + /** @var bool $isCheckoutPage */ + $isCheckoutPage = ( + false !== strpos($requestUri, $this->checkoutPagePath) || + false !== strpos($refererUri, $this->checkoutPagePath) + ); + + return $isCheckoutPage; + } } diff --git a/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php b/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php index a52e22a960e0b..8cad0b9f2dd89 100644 --- a/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php @@ -49,24 +49,39 @@ class CheckExpirePersistentQuoteObserverTest extends \PHPUnit\Framework\TestCase */ protected $eventManagerMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Framework\App\RequestInterface + */ + private $requestMock; + + /** + * @inheritdoc + */ protected function setUp() { $this->sessionMock = $this->createMock(\Magento\Persistent\Helper\Session::class); $this->customerSessionMock = $this->createMock(\Magento\Customer\Model\Session::class); $this->persistentHelperMock = $this->createMock(\Magento\Persistent\Helper\Data::class); - $this->observerMock - = $this->createPartialMock(\Magento\Framework\Event\Observer::class, ['getControllerAction', - '__wakeUp']); + $this->observerMock = $this->createPartialMock( + \Magento\Framework\Event\Observer::class, + ['getControllerAction','__wakeUp'] + ); $this->quoteManagerMock = $this->createMock(\Magento\Persistent\Model\QuoteManager::class); $this->eventManagerMock = $this->createMock(\Magento\Framework\Event\ManagerInterface::class); $this->checkoutSessionMock = $this->createMock(\Magento\Checkout\Model\Session::class); + $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getRequestUri', 'getServer']) + ->getMockForAbstractClass(); + $this->model = new \Magento\Persistent\Observer\CheckExpirePersistentQuoteObserver( $this->sessionMock, $this->persistentHelperMock, $this->quoteManagerMock, $this->eventManagerMock, $this->customerSessionMock, - $this->checkoutSessionMock + $this->checkoutSessionMock, + $this->requestMock ); } @@ -76,7 +91,7 @@ public function testExecuteWhenCanNotApplyPersistentData() ->expects($this->once()) ->method('canProcess') ->with($this->observerMock) - ->will($this->returnValue(false)); + ->willReturn(false); $this->persistentHelperMock->expects($this->never())->method('isEnabled'); $this->model->execute($this->observerMock); } @@ -87,31 +102,97 @@ public function testExecuteWhenPersistentIsNotEnabled() ->expects($this->once()) ->method('canProcess') ->with($this->observerMock) - ->will($this->returnValue(true)); - $this->persistentHelperMock->expects($this->once())->method('isEnabled')->will($this->returnValue(false)); + ->willReturn(true); + $this->persistentHelperMock->expects($this->once())->method('isEnabled')->willReturn(false); $this->eventManagerMock->expects($this->never())->method('dispatch'); $this->model->execute($this->observerMock); } - public function testExecuteWhenPersistentIsEnabled() - { + /** + * Test method \Magento\Persistent\Observer\CheckExpirePersistentQuoteObserver::execute when persistent is enabled. + * + * @param string $refererUri + * @param string $requestUri + * @param \PHPUnit_Framework_MockObject_Matcher_InvokedCount $expireCounter + * @param \PHPUnit_Framework_MockObject_Matcher_InvokedCount $dispatchCounter + * @param \PHPUnit_Framework_MockObject_Matcher_InvokedCount $setCustomerIdCounter + * @return void + * @dataProvider requestDataProvider + */ + public function testExecuteWhenPersistentIsEnabled( + string $refererUri, + string $requestUri, + \PHPUnit_Framework_MockObject_Matcher_InvokedCount $expireCounter, + \PHPUnit_Framework_MockObject_Matcher_InvokedCount $dispatchCounter, + \PHPUnit_Framework_MockObject_Matcher_InvokedCount $setCustomerIdCounter + ): void { $this->persistentHelperMock ->expects($this->once()) ->method('canProcess') ->with($this->observerMock) - ->will($this->returnValue(true)); - $this->persistentHelperMock->expects($this->once())->method('isEnabled')->will($this->returnValue(true)); - $this->sessionMock->expects($this->once())->method('isPersistent')->will($this->returnValue(false)); - $this->customerSessionMock->expects($this->once())->method('isLoggedIn')->will($this->returnValue(false)); - $this->checkoutSessionMock->expects($this->once())->method('getQuoteId')->will($this->returnValue(10)); - $this->observerMock->expects($this->once())->method('getControllerAction'); - $this->eventManagerMock->expects($this->once())->method('dispatch'); - $this->quoteManagerMock->expects($this->once())->method('expire'); + ->willReturn(true); + $this->persistentHelperMock->expects($this->once())->method('isEnabled')->willReturn(true); + $this->sessionMock->expects($this->once())->method('isPersistent')->willReturn(false); $this->customerSessionMock - ->expects($this->once()) + ->expects($this->atLeastOnce()) + ->method('isLoggedIn') + ->willReturn(false); + $this->checkoutSessionMock + ->expects($this->atLeastOnce()) + ->method('getQuoteId') + ->willReturn(10); + $this->eventManagerMock->expects($dispatchCounter)->method('dispatch'); + $this->quoteManagerMock->expects($expireCounter)->method('expire'); + $this->customerSessionMock + ->expects($setCustomerIdCounter) ->method('setCustomerId') ->with(null) - ->will($this->returnSelf()); + ->willReturnSelf(); + $this->requestMock->expects($this->atLeastOnce())->method('getRequestUri')->willReturn($refererUri); + $this->requestMock + ->expects($this->atLeastOnce()) + ->method('getServer') + ->with('HTTP_REFERER') + ->willReturn($requestUri); $this->model->execute($this->observerMock); } + + /** + * Request Data Provider + * + * @return array + */ + public function requestDataProvider() + { + return [ + [ + 'refererUri' => 'checkout', + 'requestUri' => 'index', + 'expireCounter' => $this->never(), + 'dispatchCounter' => $this->never(), + 'setCustomerIdCounter' => $this->never(), + ], + [ + 'refererUri' => 'checkout', + 'requestUri' => 'checkout', + 'expireCounter' => $this->never(), + 'dispatchCounter' => $this->never(), + 'setCustomerIdCounter' => $this->never(), + ], + [ + 'refererUri' => 'index', + 'requestUri' => 'checkout', + 'expireCounter' => $this->never(), + 'dispatchCounter' => $this->never(), + 'setCustomerIdCounter' => $this->never(), + ], + [ + 'refererUri' => 'index', + 'requestUri' => 'index', + 'expireCounter' => $this->once(), + 'dispatchCounter' => $this->once(), + 'setCustomerIdCounter' => $this->once(), + ], + ]; + } } diff --git a/app/code/Magento/Persistent/composer.json b/app/code/Magento/Persistent/composer.json index 13083cb4fb83f..4a304c549b760 100644 --- a/app/code/Magento/Persistent/composer.json +++ b/app/code/Magento/Persistent/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-checkout": "*", "magento/module-cron": "*", diff --git a/app/code/Magento/Persistent/etc/db_schema.xml b/app/code/Magento/Persistent/etc/db_schema.xml index 31adf5be6f0c4..68678fc60f096 100644 --- a/app/code/Magento/Persistent/etc/db_schema.xml +++ b/app/code/Magento/Persistent/etc/db_schema.xml @@ -37,7 +37,7 @@
    - +
    diff --git a/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php b/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php index 0971dd4c27664..61d8d1987c2d7 100644 --- a/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php +++ b/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php @@ -60,10 +60,7 @@ public function getImage(Product $product, $imageId, $attributes = []) $this->appEmulation->startEnvironmentEmulation($storeId, Area::AREA_FRONTEND, true); try { - $image = $this->imageBuilder->setProduct($product) - ->setImageId($imageId) - ->setAttributes($attributes) - ->create(); + $image = $this->imageBuilder->create($product, $imageId, $attributes); } catch (\Exception $exception) { $this->appEmulation->stopEnvironmentEmulation(); throw $exception; diff --git a/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php b/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php index 39b07161934f8..172e5f486f30b 100644 --- a/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php +++ b/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php @@ -54,23 +54,16 @@ public function testGetImage() $imageId = 'test_image_id'; $attributes = []; - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - $imageMock = $this->getMockBuilder(\Magento\Catalog\Block\Product\Image::class) - ->disableOriginalConstructor() - ->getMock(); - $storeMock = $this->getMockBuilder(\Magento\Store\Api\Data\StoreInterface::class) - ->disableOriginalConstructor() - ->getMock(); + $productMock = $this->createMock(\Magento\Catalog\Model\Product::class); + $imageMock = $this->createMock(\Magento\Catalog\Block\Product\Image::class); + $storeMock = $this->createMock(\Magento\Store\Api\Data\StoreInterface::class); $this->storeManagerMock->expects($this->atLeastOnce())->method('getStore')->willReturn($storeMock); - $storeMock->expects($this->atLeastOnce())->method('getId')->willReturn(42); $this->emulationMock->expects($this->once())->method('startEnvironmentEmulation'); - $this->imageBuilderMock->expects($this->once())->method('setProduct')->with($productMock)->willReturnSelf(); - $this->imageBuilderMock->expects($this->once())->method('setImageId')->with($imageId)->willReturnSelf(); - $this->imageBuilderMock->expects($this->once())->method('setAttributes')->with($attributes)->willReturnSelf(); - $this->imageBuilderMock->expects($this->once())->method('create')->willReturn($imageMock); + $this->imageBuilderMock->expects($this->once()) + ->method('create') + ->with($productMock, $imageId, $attributes) + ->willReturn($imageMock); $this->emulationMock->expects($this->once())->method('stopEnvironmentEmulation'); $this->assertEquals($imageMock, $this->model->getImage($productMock, $imageId, $attributes)); @@ -84,6 +77,7 @@ public function testGetImage() */ public function testGetImageThrowsAnException() { + $imageId = 1; $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); @@ -93,13 +87,13 @@ public function testGetImageThrowsAnException() $this->emulationMock->expects($this->once())->method('startEnvironmentEmulation'); $this->storeManagerMock->expects($this->atLeastOnce())->method('getStore')->willReturn($storeMock); - $storeMock->expects($this->atLeastOnce())->method('getId')->willReturn(42); $this->imageBuilderMock->expects($this->once()) - ->method('setProduct') + ->method('create') + ->with($productMock, $imageId) ->willThrowException(new \Exception("Image Builder Exception")); $this->emulationMock->expects($this->once())->method('stopEnvironmentEmulation'); - $this->model->getImage($productMock, 1); + $this->model->getImage($productMock, $imageId); } } diff --git a/app/code/Magento/ProductAlert/composer.json b/app/code/Magento/ProductAlert/composer.json index 1ad6e2e742f15..e8488187f839a 100644 --- a/app/code/Magento/ProductAlert/composer.json +++ b/app/code/Magento/ProductAlert/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php b/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php index 78730d625e44c..2277aa980f66c 100644 --- a/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php +++ b/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php @@ -11,6 +11,8 @@ */ namespace Magento\ProductVideo\Block\Product\View; +use Magento\Catalog\Model\Product\Image\UrlBuilder; + /** * @api * @since 100.0.2 @@ -28,7 +30,7 @@ class Gallery extends \Magento\Catalog\Block\Product\View\Gallery * @param \Magento\Framework\Json\EncoderInterface $jsonEncoder * @param \Magento\ProductVideo\Helper\Media $mediaHelper * @param array $data - * @param \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface $imagesConfigFactory + * @param \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface|null $imagesConfigFactory * @param array $galleryImagesConfig */ public function __construct( diff --git a/app/code/Magento/ProductVideo/composer.json b/app/code/Magento/ProductVideo/composer.json index 8e834f3eaa0ed..829816321c1a5 100644 --- a/app/code/Magento/ProductVideo/composer.json +++ b/app/code/Magento/ProductVideo/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/magento-composer-installer": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/Quote/Api/ChangeQuoteControlInterface.php b/app/code/Magento/Quote/Api/ChangeQuoteControlInterface.php new file mode 100644 index 0000000000000..4212b289c4bcf --- /dev/null +++ b/app/code/Magento/Quote/Api/ChangeQuoteControlInterface.php @@ -0,0 +1,25 @@ +userContext = $userContext; + } + + /** + * {@inheritdoc} + */ + public function isAllowed(CartInterface $quote): bool + { + switch ($this->userContext->getUserType()) { + case UserContextInterface::USER_TYPE_CUSTOMER: + $isAllowed = ($quote->getCustomerId() == $this->userContext->getUserId()); + break; + case UserContextInterface::USER_TYPE_GUEST: + $isAllowed = ($quote->getCustomerId() === null); + break; + case UserContextInterface::USER_TYPE_ADMIN: + case UserContextInterface::USER_TYPE_INTEGRATION: + $isAllowed = true; + break; + default: + $isAllowed = false; + } + + return $isAllowed; + } +} diff --git a/app/code/Magento/Quote/Model/PaymentMethodManagement.php b/app/code/Magento/Quote/Model/PaymentMethodManagement.php index 91d8fe4dbcffd..b6e4bcf5ccc8f 100644 --- a/app/code/Magento/Quote/Model/PaymentMethodManagement.php +++ b/app/code/Magento/Quote/Model/PaymentMethodManagement.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Quote\Model; @@ -52,38 +53,37 @@ public function set($cartId, \Magento\Quote\Api\Data\PaymentInterface $method) { /** @var \Magento\Quote\Model\Quote $quote */ $quote = $this->quoteRepository->get($cartId); - + $quote->setTotalsCollectedFlag(false); $method->setChecks([ \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_CHECKOUT, \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_COUNTRY, \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_CURRENCY, \Magento\Payment\Model\Method\AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, ]); - $payment = $quote->getPayment(); - - $data = $method->getData(); - $payment->importData($data); if ($quote->isVirtual()) { - $quote->getBillingAddress()->setPaymentMethod($payment->getMethod()); + $address = $quote->getBillingAddress(); } else { + $address = $quote->getShippingAddress(); // check if shipping address is set - if ($quote->getShippingAddress()->getCountryId() === null) { + if ($address->getCountryId() === null) { throw new InvalidTransitionException( __('The shipping address is missing. Set the address and try again.') ); } - $quote->getShippingAddress()->setPaymentMethod($payment->getMethod()); - } - if (!$quote->isVirtual() && $quote->getShippingAddress()) { - $quote->getShippingAddress()->setCollectShippingRates(true); + $address->setCollectShippingRates(true); } + $paymentData = $method->getData(); + $payment = $quote->getPayment(); + $payment->importData($paymentData); + $address->setPaymentMethod($payment->getMethod()); + if (!$this->zeroTotalValidator->isApplicable($payment->getMethodInstance(), $quote)) { throw new InvalidTransitionException(__('The requested Payment Method is not available.')); } - $quote->setTotalsCollectedFlag(false)->collectTotals()->save(); + $quote->save(); return $quote->getPayment()->getId(); } diff --git a/app/code/Magento/Quote/Model/Quote.php b/app/code/Magento/Quote/Model/Quote.php index 5039af9a9081d..3b19c6495dd99 100644 --- a/app/code/Magento/Quote/Model/Quote.php +++ b/app/code/Magento/Quote/Model/Quote.php @@ -14,6 +14,7 @@ use Magento\Quote\Model\Quote\Address; use Magento\Quote\Model\Quote\Address\Total as AddressTotal; use Magento\Sales\Model\Status; +use Magento\Framework\App\ObjectManager; /** * Quote model @@ -353,6 +354,11 @@ class Quote extends AbstractExtensibleModel implements \Magento\Quote\Api\Data\C */ protected $shippingAddressesItems; + /** + * @var \Magento\Sales\Model\OrderIncrementIdChecker + */ + private $orderIncrementIdChecker; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -394,6 +400,7 @@ class Quote extends AbstractExtensibleModel implements \Magento\Quote\Api\Data\C * @param \Magento\Framework\Model\ResourceModel\AbstractResource|null $resource * @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection * @param array $data + * @param \Magento\Sales\Model\OrderIncrementIdChecker|null $orderIncrementIdChecker * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -436,7 +443,8 @@ public function __construct( \Magento\Quote\Model\ShippingAssignmentFactory $shippingAssignmentFactory, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + \Magento\Sales\Model\OrderIncrementIdChecker $orderIncrementIdChecker = null ) { $this->quoteValidator = $quoteValidator; $this->_catalogProduct = $catalogProduct; @@ -471,6 +479,8 @@ public function __construct( $this->totalsReader = $totalsReader; $this->shippingFactory = $shippingFactory; $this->shippingAssignmentFactory = $shippingAssignmentFactory; + $this->orderIncrementIdChecker = $orderIncrementIdChecker ?: ObjectManager::getInstance() + ->get(\Magento\Sales\Model\OrderIncrementIdChecker::class); parent::__construct( $context, $registry, @@ -2011,7 +2021,7 @@ public function getErrors() foreach ($this->getMessages() as $message) { /* @var $error \Magento\Framework\Message\AbstractMessage */ if ($message->getType() == \Magento\Framework\Message\MessageInterface::TYPE_ERROR) { - array_push($errors, $message); + $errors[] = $message; } } return $errors; @@ -2184,7 +2194,7 @@ public function reserveOrderId() } else { //checking if reserved order id was already used for some order //if yes reserving new one if not using old one - if ($this->_getResource()->isOrderIncrementIdUsed($this->getReservedOrderId())) { + if ($this->orderIncrementIdChecker->isIncrementIdUsed($this->getReservedOrderId())) { $this->setReservedOrderId($this->_getResource()->getReservedOrderId($this)); } } @@ -2378,8 +2388,9 @@ protected function _afterLoad() { // collect totals and save me, if required if (1 == $this->getTriggerRecollect()) { - $this->collectTotals()->save(); - $this->setTriggerRecollect(0); + $this->collectTotals() + ->setTriggerRecollect(0) + ->save(); } return parent::_afterLoad(); } diff --git a/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php b/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php index 3eff09faac1f5..79b518fc54534 100644 --- a/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php +++ b/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php @@ -3,10 +3,10 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Quote\Model\QuoteRepository\Plugin; -use Magento\Authorization\Model\UserContextInterface; -use Magento\Quote\Model\Quote; +use Magento\Quote\Api\ChangeQuoteControlInterface; use Magento\Framework\Exception\StateException; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Api\Data\CartInterface; @@ -17,24 +17,23 @@ class AccessChangeQuoteControl { /** - * @var UserContextInterface + * @var ChangeQuoteControlInterface $changeQuoteControl */ - private $userContext; + private $changeQuoteControl; /** - * @param UserContextInterface $userContext + * @param ChangeQuoteControlInterface $changeQuoteControl */ - public function __construct( - UserContextInterface $userContext - ) { - $this->userContext = $userContext; + public function __construct(ChangeQuoteControlInterface $changeQuoteControl) + { + $this->changeQuoteControl = $changeQuoteControl; } /** * Checks if change quote's customer id is allowed for current user. * * @param CartRepositoryInterface $subject - * @param Quote $quote + * @param CartInterface $quote * @throws StateException if Guest has customer_id or Customer's customer_id not much with user_id * or unknown user's type * @return void @@ -42,34 +41,8 @@ public function __construct( */ public function beforeSave(CartRepositoryInterface $subject, CartInterface $quote) { - if (!$this->isAllowed($quote)) { + if (! $this->changeQuoteControl->isAllowed($quote)) { throw new StateException(__("Invalid state change requested")); } } - - /** - * Checks if user is allowed to change the quote. - * - * @param Quote $quote - * @return bool - */ - private function isAllowed(Quote $quote) - { - switch ($this->userContext->getUserType()) { - case UserContextInterface::USER_TYPE_CUSTOMER: - $isAllowed = ($quote->getCustomerId() == $this->userContext->getUserId()); - break; - case UserContextInterface::USER_TYPE_GUEST: - $isAllowed = ($quote->getCustomerId() === null); - break; - case UserContextInterface::USER_TYPE_ADMIN: - case UserContextInterface::USER_TYPE_INTEGRATION: - $isAllowed = true; - break; - default: - $isAllowed = false; - } - - return $isAllowed; - } } diff --git a/app/code/Magento/Quote/Model/ResourceModel/Quote.php b/app/code/Magento/Quote/Model/ResourceModel/Quote.php index e171d88dae6f1..946c0e0c5f3b8 100644 --- a/app/code/Magento/Quote/Model/ResourceModel/Quote.php +++ b/app/code/Magento/Quote/Model/ResourceModel/Quote.php @@ -170,29 +170,6 @@ public function getReservedOrderId($quote) ->getNextValue(); } - /** - * Check if order increment ID is already used. - * Method can be used to avoid collisions of order IDs. - * - * @param int $orderIncrementId - * @return bool - */ - public function isOrderIncrementIdUsed($orderIncrementId) - { - /** @var \Magento\Framework\DB\Adapter\AdapterInterface $adapter */ - $adapter = $this->getConnection(); - $bind = [':increment_id' => $orderIncrementId]; - /** @var \Magento\Framework\DB\Select $select */ - $select = $adapter->select(); - $select->from($this->getTable('sales_order'), 'entity_id')->where('increment_id = :increment_id'); - $entity_id = $adapter->fetchOne($select, $bind); - if ($entity_id > 0) { - return true; - } - - return false; - } - /** * Mark quotes - that depend on catalog price rules - to be recollected on demand * diff --git a/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php b/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php index 68b077fcdb965..f18d1fa1b06e5 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Quote\Test\Unit\Model; @@ -152,8 +153,8 @@ public function testSetVirtualProduct() ->with($paymentMethod) ->willReturnSelf(); - $quoteMock->expects($this->exactly(2))->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->exactly(2))->method('isVirtual')->willReturn(true); + $quoteMock->method('getPayment')->willReturn($paymentMock); + $quoteMock->expects($this->once())->method('isVirtual')->willReturn(true); $quoteMock->expects($this->once())->method('getBillingAddress')->willReturn($billingAddressMock); $methodInstance = $this->getMockForAbstractClass(\Magento\Payment\Model\MethodInterface::class); @@ -165,7 +166,6 @@ public function testSetVirtualProduct() ->willReturn(true); $quoteMock->expects($this->once())->method('setTotalsCollectedFlag')->with(false)->willReturnSelf(); - $quoteMock->expects($this->once())->method('collectTotals')->willReturnSelf(); $quoteMock->expects($this->once())->method('save')->willReturnSelf(); $paymentMock->expects($this->once())->method('getId')->willReturn($paymentId); @@ -218,9 +218,9 @@ public function testSetVirtualProductThrowsExceptionIfPaymentMethodNotAvailable( ->with($paymentMethod) ->willReturnSelf(); - $quoteMock->expects($this->once())->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->exactly(2))->method('isVirtual')->willReturn(true); - $quoteMock->expects($this->once())->method('getBillingAddress')->willReturn($billingAddressMock); + $quoteMock->method('getPayment')->willReturn($paymentMock); + $quoteMock->method('isVirtual')->willReturn(true); + $quoteMock->method('getBillingAddress')->willReturn($billingAddressMock); $methodInstance = $this->getMockForAbstractClass(\Magento\Payment\Model\MethodInterface::class); $paymentMock->expects($this->once())->method('getMethodInstance')->willReturn($methodInstance); @@ -268,17 +268,20 @@ public function testSetSimpleProduct() $shippingAddressMock = $this->createPartialMock( \Magento\Quote\Model\Quote\Address::class, - ['getCountryId', 'setPaymentMethod'] + ['getCountryId', 'setPaymentMethod', 'setCollectShippingRates'] ); $shippingAddressMock->expects($this->once())->method('getCountryId')->willReturn(100); $shippingAddressMock->expects($this->once()) ->method('setPaymentMethod') ->with($paymentMethod) ->willReturnSelf(); + $shippingAddressMock->expects($this->once()) + ->method('setCollectShippingRates') + ->with(true); - $quoteMock->expects($this->exactly(2))->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->exactly(2))->method('isVirtual')->willReturn(false); - $quoteMock->expects($this->exactly(4))->method('getShippingAddress')->willReturn($shippingAddressMock); + $quoteMock->method('getPayment')->willReturn($paymentMock); + $quoteMock->method('isVirtual')->willReturn(false); + $quoteMock->method('getShippingAddress')->willReturn($shippingAddressMock); $methodInstance = $this->getMockForAbstractClass(\Magento\Payment\Model\MethodInterface::class); $paymentMock->expects($this->once())->method('getMethodInstance')->willReturn($methodInstance); @@ -289,7 +292,6 @@ public function testSetSimpleProduct() ->willReturn(true); $quoteMock->expects($this->once())->method('setTotalsCollectedFlag')->with(false)->willReturnSelf(); - $quoteMock->expects($this->once())->method('collectTotals')->willReturnSelf(); $quoteMock->expects($this->once())->method('save')->willReturnSelf(); $paymentMock->expects($this->once())->method('getId')->willReturn($paymentId); @@ -303,7 +305,6 @@ public function testSetSimpleProduct() public function testSetSimpleProductTrowsExceptionIfShippingAddressNotSet() { $cartId = 100; - $methodData = ['method' => 'data']; $quoteMock = $this->createPartialMock( \Magento\Quote\Model\Quote::class, @@ -311,6 +312,7 @@ public function testSetSimpleProductTrowsExceptionIfShippingAddressNotSet() ); $this->quoteRepositoryMock->expects($this->once())->method('get')->with($cartId)->willReturn($quoteMock); + /** @var \Magento\Quote\Model\Quote\Payment|\PHPUnit_Framework_MockObject_MockObject $methodMock */ $methodMock = $this->createPartialMock(\Magento\Quote\Model\Quote\Payment::class, ['setChecks', 'getData']); $methodMock->expects($this->once()) ->method('setChecks') @@ -321,17 +323,13 @@ public function testSetSimpleProductTrowsExceptionIfShippingAddressNotSet() \Magento\Payment\Model\Method\AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, ]) ->willReturnSelf(); - $methodMock->expects($this->once())->method('getData')->willReturn($methodData); - - $paymentMock = $this->createPartialMock(\Magento\Quote\Model\Quote\Payment::class, ['importData']); - $paymentMock->expects($this->once())->method('importData')->with($methodData)->willReturnSelf(); + $methodMock->expects($this->never())->method('getData'); $shippingAddressMock = $this->createPartialMock(\Magento\Quote\Model\Quote\Address::class, ['getCountryId']); $shippingAddressMock->expects($this->once())->method('getCountryId')->willReturn(null); - $quoteMock->expects($this->once())->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->once())->method('isVirtual')->willReturn(false); - $quoteMock->expects($this->once())->method('getShippingAddress')->willReturn($shippingAddressMock); + $quoteMock->method('isVirtual')->willReturn(false); + $quoteMock->method('getShippingAddress')->willReturn($shippingAddressMock); $this->model->set($cartId, $methodMock); } diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php index f330ebda17317..043e04319362d 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php @@ -3,9 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Quote\Test\Unit\Model\QuoteRepository\Plugin; use Magento\Authorization\Model\UserContextInterface; +use Magento\Quote\Model\ChangeQuoteControl; use Magento\Quote\Model\QuoteRepository\Plugin\AccessChangeQuoteControl; use Magento\Quote\Model\Quote; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; @@ -34,6 +36,11 @@ class AccessChangeQuoteControlTest extends \PHPUnit\Framework\TestCase */ private $quoteRepositoryMock; + /** + * @var ChangeQuoteControl|MockObject + */ + private $changeQuoteControlMock; + protected function setUp() { $this->userContextMock = $this->getMockBuilder(UserContextInterface::class) @@ -50,15 +57,19 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->changeQuoteControlMock = $this->getMockBuilder(ChangeQuoteControl::class) + ->disableOriginalConstructor() + ->getMock(); + $objectManagerHelper = new ObjectManager($this); $this->accessChangeQuoteControl = $objectManagerHelper->getObject( AccessChangeQuoteControl::class, - ['userContext' => $this->userContextMock] + ['changeQuoteControl' => $this->changeQuoteControlMock] ); } /** - * User with role Customer and customer_id much with context user_id. + * User with role Customer and customer_id matches context user_id. */ public function testBeforeSaveForCustomer() { @@ -68,6 +79,9 @@ public function testBeforeSaveForCustomer() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -81,11 +95,15 @@ public function testBeforeSaveForCustomer() */ public function testBeforeSaveException() { - $this->userContextMock->method('getUserType') - ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); $this->quoteMock->method('getCustomerId') ->willReturn(2); + $this->userContextMock->method('getUserType') + ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); + + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } @@ -100,6 +118,9 @@ public function testBeforeSaveForAdmin() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_ADMIN); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -116,6 +137,9 @@ public function testBeforeSaveForGuest() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_GUEST); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -135,6 +159,9 @@ public function testBeforeSaveForGuestException() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_GUEST); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } @@ -152,6 +179,9 @@ public function testBeforeSaveForUnknownUserTypeException() $this->userContextMock->method('getUserType') ->willReturn(10); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } } diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php index 451382abb4684..a92d360bad35b 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php @@ -145,6 +145,11 @@ class QuoteTest extends \PHPUnit\Framework\TestCase */ private $itemProcessor; + /** + * @var \Magento\Sales\Model\OrderIncrementIdChecker|\PHPUnit_Framework_MockObject_MockObject + */ + private $orderIncrementIdChecker; + /** * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -256,6 +261,7 @@ protected function setUp() \Magento\Customer\Api\Data\CustomerInterfaceFactory::class, ['create'] ); + $this->orderIncrementIdChecker = $this->createMock(\Magento\Sales\Model\OrderIncrementIdChecker::class); $this->quote = (new ObjectManager($this)) ->getObject( \Magento\Quote\Model\Quote::class, @@ -280,9 +286,10 @@ protected function setUp() 'extensionAttributesJoinProcessor' => $this->extensionAttributesJoinProcessorMock, 'customerDataFactory' => $this->customerDataFactoryMock, 'itemProcessor' => $this->itemProcessor, + 'orderIncrementIdChecker' => $this->orderIncrementIdChecker, 'data' => [ - 'reserved_order_id' => 1000001 - ] + 'reserved_order_id' => 1000001, + ], ] ); } @@ -1222,28 +1229,32 @@ public function testGetAllItems() } /** - * Test to verify if existing reserved_order_id in use + * Test to verify if existing reserved_order_id in use. * * @param bool $isReservedOrderIdExist * @param int $reservedOrderId + * @return void * @dataProvider reservedOrderIdDataProvider */ - public function testReserveOrderId($isReservedOrderIdExist, $reservedOrderId) + public function testReserveOrderId(bool $isReservedOrderIdExist, int $reservedOrderId): void { - $this->resourceMock + $this->orderIncrementIdChecker ->expects($this->once()) - ->method('isOrderIncrementIdUsed') + ->method('isIncrementIdUsed') ->with(1000001)->willReturn($isReservedOrderIdExist); $this->resourceMock->expects($this->any())->method('getReservedOrderId')->willReturn($reservedOrderId); $this->quote->reserveOrderId(); $this->assertEquals($reservedOrderId, $this->quote->getReservedOrderId()); } - public function reservedOrderIdDataProvider() + /** + * @return array + */ + public function reservedOrderIdDataProvider(): array { return [ 'id_already_in_use' => [true, 100002], - 'id_not_in_use' => [false, 1000001] + 'id_not_in_use' => [false, 1000001], ]; } } diff --git a/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php b/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php index a121cd55a98b4..7136e8260a880 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php @@ -6,35 +6,17 @@ namespace Magento\Quote\Test\Unit\Model\ResourceModel; -use Magento\Framework\App\ResourceConnection; -use Magento\Framework\DB\Adapter\Pdo\Mysql; -use Magento\Framework\DB\Select; use Magento\Framework\DB\Sequence\SequenceInterface; -use Magento\Framework\Model\ResourceModel\Db\Context; -use Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite; -use Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Quote\Model\Quote; use Magento\Quote\Model\ResourceModel\Quote as QuoteResource; use Magento\SalesSequence\Model\Manager; +/** + * Unit test for \Magento\Quote\Model\ResourceModel\Quote. + */ class QuoteTest extends \PHPUnit\Framework\TestCase { - /** - * @var ResourceConnection - */ - private $resourceMock; - - /** - * @var Mysql - */ - private $adapterMock; - - /** - * @var Select - */ - private $selectMock; - /** * @var Quote|\PHPUnit_Framework_MockObject_MockObject */ @@ -55,98 +37,31 @@ class QuoteTest extends \PHPUnit\Framework\TestCase */ private $model; + /** + * @inheritdoc + */ protected function setUp() { $objectManagerHelper = new ObjectManager($this); - $this->selectMock = $this->getMockBuilder(Select::class) - ->disableOriginalConstructor() - ->getMock(); - $this->selectMock->expects($this->any())->method('from')->will($this->returnSelf()); - $this->selectMock->expects($this->any())->method('where'); - - $this->adapterMock = $this->getMockBuilder(Mysql::class) - ->disableOriginalConstructor() - ->getMock(); - $this->adapterMock->expects($this->any())->method('select')->will($this->returnValue($this->selectMock)); - - $this->resourceMock = $this->getMockBuilder(ResourceConnection::class) - ->disableOriginalConstructor() - ->getMock(); - $this->resourceMock->expects( - $this->any() - )->method( - 'getConnection' - )->will( - $this->returnValue($this->adapterMock) - ); - - $context = $this->getMockBuilder(Context::class) - ->disableOriginalConstructor() - ->getMock(); - $context->expects( - $this->once() - )->method( - 'getResources' - )->will( - $this->returnValue($this->resourceMock) - ); - - $snapshot = $this->getMockBuilder(Snapshot::class) - ->disableOriginalConstructor() - ->getMock(); - $relationComposite = $this->getMockBuilder(RelationComposite::class) - ->disableOriginalConstructor() - ->getMock(); - $this->quoteMock = $this->getMockBuilder(Quote::class) - ->disableOriginalConstructor() - ->getMock(); - $this->sequenceManagerMock = $this->getMockBuilder(Manager::class) - ->disableOriginalConstructor() - ->getMock(); - $this->sequenceMock = $this->getMockBuilder(SequenceInterface::class) - ->disableOriginalConstructor() - ->getMock(); + $this->quoteMock = $this->createMock(Quote::class); + $this->sequenceManagerMock = $this->createMock(Manager::class); + $this->sequenceMock = $this->createMock(SequenceInterface::class); $this->model = $objectManagerHelper->getObject( QuoteResource::class, [ - 'context' => $context, - 'entitySnapshot' => $snapshot, - 'entityRelationComposite' => $relationComposite, 'sequenceManager' => $this->sequenceManagerMock, - 'connectionName' => null, ] ); } /** - * Unit test to verify if isOrderIncrementIdUsed method works with different types increment ids - * - * @param array $value - * @dataProvider isOrderIncrementIdUsedDataProvider - */ - public function testIsOrderIncrementIdUsed($value) - { - $expectedBind = [':increment_id' => $value]; - $this->adapterMock->expects($this->once())->method('fetchOne')->with($this->selectMock, $expectedBind); - $this->model->isOrderIncrementIdUsed($value); - } - - /** - * @return array - */ - public function isOrderIncrementIdUsedDataProvider() - { - return [[100000001], ['10000000001'], ['M10000000001']]; - } - - /** - * /** - * @param $entityType - * @param $storeId - * @param $reservedOrderId + * @param string $entityType + * @param int $storeId + * @param string $reservedOrderId + * @return void * @dataProvider getReservedOrderIdDataProvider */ - public function testGetReservedOrderId($entityType, $storeId, $reservedOrderId) + public function testGetReservedOrderId(string $entityType, int $storeId, string $reservedOrderId): void { $this->sequenceManagerMock->expects($this->once()) ->method('getSequence') @@ -170,7 +85,7 @@ public function getReservedOrderIdDataProvider(): array return [ [\Magento\Sales\Model\Order::ENTITY, 1, '1000000001'], [\Magento\Sales\Model\Order::ENTITY, 2, '2000000001'], - [\Magento\Sales\Model\Order::ENTITY, 3, '3000000001'] + [\Magento\Sales\Model\Order::ENTITY, 3, '3000000001'], ]; } } diff --git a/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php b/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php index 682045c0cdb25..b82d1fa6c7839 100644 --- a/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php +++ b/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php @@ -160,8 +160,8 @@ public function testDispatch($isWebsiteScope, $websites) public function dispatchDataProvider() { return [ - [true, ['website1']], - [true, ['website1', 'website2']], + [true, [['website1']]], + [true, [['website1'], ['website2']]], [false, ['website1']], [false, ['website1', 'website2']], ]; diff --git a/app/code/Magento/Quote/composer.json b/app/code/Magento/Quote/composer.json index 2821c755ecfac..0281c75507f54 100644 --- a/app/code/Magento/Quote/composer.json +++ b/app/code/Magento/Quote/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-authorization": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/Quote/etc/db_schema.xml b/app/code/Magento/Quote/etc/db_schema.xml index ff127b9c06f0e..3bd7122e65d7f 100644 --- a/app/code/Magento/Quote/etc/db_schema.xml +++ b/app/code/Magento/Quote/etc/db_schema.xml @@ -7,7 +7,7 @@ --> - +
    - + @@ -101,7 +101,7 @@
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    + diff --git a/app/code/Magento/QuoteAnalytics/composer.json b/app/code/Magento/QuoteAnalytics/composer.json index 4b84ce756aca1..90dae1ec2adca 100644 --- a/app/code/Magento/QuoteAnalytics/composer.json +++ b/app/code/Magento/QuoteAnalytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-quote-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-quote": "*" }, diff --git a/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php b/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php index a43b33b5a8cdf..c4760bd9d28c3 100644 --- a/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php +++ b/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php @@ -174,7 +174,7 @@ private function buildFooter(array $footer) * correct HTML format. * * @param string $content - * @returns string + * @return string */ private function formatContentWithLinks($content) { diff --git a/app/code/Magento/ReleaseNotification/composer.json b/app/code/Magento/ReleaseNotification/composer.json index 6c42bcc3f109f..97b312b74ff74 100644 --- a/app/code/Magento/ReleaseNotification/composer.json +++ b/app/code/Magento/ReleaseNotification/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-release-notification", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/module-user": "*", "magento/module-backend": "*", "magento/module-ui": "*", diff --git a/app/code/Magento/Reports/composer.json b/app/code/Magento/Reports/composer.json index 227455468a80c..f2ead8fedff74 100644 --- a/app/code/Magento/Reports/composer.json +++ b/app/code/Magento/Reports/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/RequireJs/composer.json b/app/code/Magento/RequireJs/composer.json index 32272eacbc10f..e48082a69319c 100644 --- a/app/code/Magento/RequireJs/composer.json +++ b/app/code/Magento/RequireJs/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*" }, "type": "magento2-module", diff --git a/app/code/Magento/Review/Block/Product/ReviewRenderer.php b/app/code/Magento/Review/Block/Product/ReviewRenderer.php index 8aa10d2437cbb..3cd15aba30420 100644 --- a/app/code/Magento/Review/Block/Product/ReviewRenderer.php +++ b/app/code/Magento/Review/Block/Product/ReviewRenderer.php @@ -18,8 +18,8 @@ class ReviewRenderer extends \Magento\Framework\View\Element\Template implements * @var array */ protected $_availableTemplates = [ - self::FULL_VIEW => 'helper/summary.phtml', - self::SHORT_VIEW => 'helper/summary_short.phtml', + self::FULL_VIEW => 'Magento_Review::helper/summary.phtml', + self::SHORT_VIEW => 'Magento_Review::helper/summary_short.phtml', ]; /** diff --git a/app/code/Magento/Review/composer.json b/app/code/Magento/Review/composer.json index d145eeab27ec5..17e0d9bebcf50 100644 --- a/app/code/Magento/Review/composer.json +++ b/app/code/Magento/Review/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -18,7 +18,7 @@ }, "suggest": { "magento/module-cookie": "*", - "magento/module-review-sample-data": "Sample Data version:100.3.*" + "magento/module-review-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Review/view/frontend/templates/redirect.phtml b/app/code/Magento/Review/view/frontend/templates/redirect.phtml index fc74cadacb319..2fdb5e90a9c18 100644 --- a/app/code/Magento/Review/view/frontend/templates/redirect.phtml +++ b/app/code/Magento/Review/view/frontend/templates/redirect.phtml @@ -8,9 +8,6 @@ ?> getProduct()->getProductUrl()}#info-product_reviews"); exit; ?> diff --git a/app/code/Magento/Review/view/frontend/templates/view.phtml b/app/code/Magento/Review/view/frontend/templates/view.phtml index 564a6e1a7c537..205ce5c20572a 100644 --- a/app/code/Magento/Review/view/frontend/templates/view.phtml +++ b/app/code/Magento/Review/view/frontend/templates/view.phtml @@ -49,7 +49,7 @@
    - escapeHtml(__('Back to Product Reviews')) ?> + escapeHtml(__('Back to Product Reviews')) ?>
    diff --git a/app/code/Magento/ReviewAnalytics/composer.json b/app/code/Magento/ReviewAnalytics/composer.json index f82d4a4272cdb..73f534451580c 100644 --- a/app/code/Magento/ReviewAnalytics/composer.json +++ b/app/code/Magento/ReviewAnalytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-review-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-review": "*" }, diff --git a/app/code/Magento/Robots/composer.json b/app/code/Magento/Robots/composer.json index b7e1d11c6d933..6f812857873b8 100644 --- a/app/code/Magento/Robots/composer.json +++ b/app/code/Magento/Robots/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-store": "*" }, diff --git a/app/code/Magento/Rss/Model/Rss.php b/app/code/Magento/Rss/Model/Rss.php index 96f71133cb832..e37ee263b8301 100644 --- a/app/code/Magento/Rss/Model/Rss.php +++ b/app/code/Magento/Rss/Model/Rss.php @@ -3,12 +3,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Rss\Model; use Magento\Framework\App\ObjectManager; use Magento\Framework\App\Rss\DataProviderInterface; use Magento\Framework\Serialize\SerializerInterface; -use Zend\Feed\Writer\FeedFactory; +use Magento\Framework\App\FeedFactoryInterface; /** * Provides functionality to work with RSS feeds @@ -28,6 +31,11 @@ class Rss */ protected $cache; + /** + * @var \Magento\Framework\App\FeedFactoryInterface + */ + private $feedFactory; + /** * @var SerializerInterface */ @@ -38,13 +46,16 @@ class Rss * * @param \Magento\Framework\App\CacheInterface $cache * @param SerializerInterface|null $serializer + * @param FeedFactoryInterface|null $feedFactory */ public function __construct( \Magento\Framework\App\CacheInterface $cache, - SerializerInterface $serializer = null + SerializerInterface $serializer = null, + FeedFactoryInterface $feedFactory = null ) { $this->cache = $cache; $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); + $this->feedFactory = $feedFactory ?: ObjectManager::getInstance()->get(FeedFactoryInterface::class); } /** @@ -90,10 +101,12 @@ public function setDataProvider(DataProviderInterface $dataProvider) /** * @return string + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\RuntimeException */ public function createRssXml() { - $feed = FeedFactory::factory($this->getFeeds()); - return $feed->export('rss'); + $feed = $this->feedFactory->create($this->getFeeds(), FeedFactoryInterface::FORMAT_RSS); + return $feed->getFormattedContent(); } } diff --git a/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php b/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php index 58fd541bab8cb..a601f8fb2d1d7 100644 --- a/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php +++ b/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php @@ -104,14 +104,22 @@ public function testExecuteWithException() $dataProvider = $this->createMock(\Magento\Framework\App\Rss\DataProviderInterface::class); $dataProvider->expects($this->once())->method('isAllowed')->will($this->returnValue(true)); - $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider']); + $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider', 'createRssXml']); $rssModel->expects($this->once())->method('setDataProvider')->will($this->returnSelf()); + $exceptionMock = new \Magento\Framework\Exception\RuntimeException( + new \Magento\Framework\Phrase('Any message') + ); + + $rssModel->expects($this->once())->method('createRssXml')->will( + $this->throwException($exceptionMock) + ); + $this->response->expects($this->once())->method('setHeader')->will($this->returnSelf()); $this->rssFactory->expects($this->once())->method('create')->will($this->returnValue($rssModel)); $this->rssManager->expects($this->once())->method('getProvider')->will($this->returnValue($dataProvider)); - $this->expectException(InvalidArgumentException::class); + $this->expectException(\Magento\Framework\Exception\RuntimeException::class); $this->controller->execute(); } } diff --git a/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php b/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php index e8f7a85382f27..30415155d5f6e 100644 --- a/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php +++ b/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php @@ -53,6 +53,7 @@ protected function setUp() ->disableOriginalConstructor()->getMock(); $objectManagerHelper = new ObjectManagerHelper($this); + $this->controller = $objectManagerHelper->getObject( \Magento\Rss\Controller\Feed\Index::class, [ @@ -91,14 +92,22 @@ public function testExecuteWithException() $dataProvider = $this->createMock(\Magento\Framework\App\Rss\DataProviderInterface::class); $dataProvider->expects($this->once())->method('isAllowed')->will($this->returnValue(true)); - $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider']); + $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider', 'createRssXml']); $rssModel->expects($this->once())->method('setDataProvider')->will($this->returnSelf()); + $exceptionMock = new \Magento\Framework\Exception\RuntimeException( + new \Magento\Framework\Phrase('Any message') + ); + + $rssModel->expects($this->once())->method('createRssXml')->will( + $this->throwException($exceptionMock) + ); + $this->response->expects($this->once())->method('setHeader')->will($this->returnSelf()); $this->rssFactory->expects($this->once())->method('create')->will($this->returnValue($rssModel)); $this->rssManager->expects($this->once())->method('getProvider')->will($this->returnValue($dataProvider)); - $this->expectException(InvalidArgumentException::class); + $this->expectException(\Magento\Framework\Exception\RuntimeException::class); $this->controller->execute(); } } diff --git a/app/code/Magento/Rss/Test/Unit/Model/RssTest.php b/app/code/Magento/Rss/Test/Unit/Model/RssTest.php index 34f9940556a93..f2888e4296b40 100644 --- a/app/code/Magento/Rss/Test/Unit/Model/RssTest.php +++ b/app/code/Magento/Rss/Test/Unit/Model/RssTest.php @@ -19,7 +19,7 @@ class RssTest extends \PHPUnit\Framework\TestCase /** * @var array */ - protected $feedData = [ + private $feedData = [ 'title' => 'Feed Title', 'link' => 'http://magento.com/rss/link', 'description' => 'Feed Description', @@ -33,6 +33,27 @@ class RssTest extends \PHPUnit\Framework\TestCase ], ]; + /** + * @var string + */ + private $feedXml = ' + + + <![CDATA[Feed Title]]> + http://magento.com/rss/link + + Sat, 22 Apr 2017 13:21:12 +0200 + Zend\Feed + http://blogs.law.harvard.edu/tech/rss + + <![CDATA[Feed 1 Title]]> + http://magento.com/rss/link/id/1 + + Sat, 22 Apr 2017 13:21:12 +0200 + + +'; + /** * @var ObjectManagerHelper */ @@ -43,6 +64,16 @@ class RssTest extends \PHPUnit\Framework\TestCase */ private $cacheMock; + /** + * @var \Magento\Framework\App\FeedFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $feedFactoryMock; + + /** + * @var \Magento\Framework\App\FeedInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $feedMock; + /** * @var SerializerInterface|\PHPUnit_Framework_MockObject_MockObject */ @@ -52,11 +83,15 @@ protected function setUp() { $this->cacheMock = $this->createMock(\Magento\Framework\App\CacheInterface::class); $this->serializerMock = $this->createMock(SerializerInterface::class); + $this->feedFactoryMock = $this->createMock(\Magento\Framework\App\FeedFactoryInterface::class); + $this->feedMock = $this->createMock(\Magento\Framework\App\FeedInterface::class); + $this->objectManagerHelper = new ObjectManagerHelper($this); $this->rss = $this->objectManagerHelper->getObject( \Magento\Rss\Model\Rss::class, [ 'cache' => $this->cacheMock, + 'feedFactory' => $this->feedFactoryMock, 'serializer' => $this->serializerMock ] ); @@ -116,14 +151,16 @@ public function testCreateRssXml() $dataProvider->expects($this->any())->method('getCacheLifetime')->will($this->returnValue(100)); $dataProvider->expects($this->any())->method('getRssData')->will($this->returnValue($this->feedData)); + $this->feedMock->expects($this->once()) + ->method('getFormattedContent') + ->willReturn($this->feedXml); + + $this->feedFactoryMock->expects($this->once()) + ->method('create') + ->with($this->feedData, \Magento\Framework\App\FeedFactoryInterface::FORMAT_RSS) + ->will($this->returnValue($this->feedMock)); + $this->rss->setDataProvider($dataProvider); - $result = $this->rss->createRssXml(); - $this->assertContains('', $result); - $this->assertContains('Feed Title', $result); - $this->assertContains('Feed 1 Title', $result); - $this->assertContains('http://magento.com/rss/link', $result); - $this->assertContains('http://magento.com/rss/link/id/1', $result); - $this->assertContains('Feed Description', $result); - $this->assertContains('', $result); + $this->assertNotNull($this->rss->createRssXml()); } } diff --git a/app/code/Magento/Rss/composer.json b/app/code/Magento/Rss/composer.json index cc24ad54e6553..5b6c34688c31a 100644 --- a/app/code/Magento/Rss/composer.json +++ b/app/code/Magento/Rss/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-customer": "*", diff --git a/app/code/Magento/Rule/Model/Action/AbstractAction.php b/app/code/Magento/Rule/Model/Action/AbstractAction.php index fb15edf8a4893..4d56f6cc56edc 100644 --- a/app/code/Magento/Rule/Model/Action/AbstractAction.php +++ b/app/code/Magento/Rule/Model/Action/AbstractAction.php @@ -49,13 +49,16 @@ public function __construct( $this->loadAttributeOptions()->loadOperatorOptions()->loadValueOptions(); - foreach (array_keys($this->getAttributeOption()) as $attr) { - $this->setAttribute($attr); - break; + $attributes = $this->getAttributeOption(); + if ($attributes) { + reset($attributes); + $this->setAttribute(key($attributes)); } - foreach (array_keys($this->getOperatorOption()) as $operator) { - $this->setOperator($operator); - break; + + $operators = $this->getOperatorOption(); + if ($operators) { + reset($operators); + $this->setOperator(key($operators)); } } diff --git a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php index 01e715f06a27f..e3bec2d9959b1 100644 --- a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php +++ b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php @@ -24,7 +24,6 @@ abstract class AbstractCondition extends \Magento\Framework\DataObject implement { /** * Defines which operators will be available for this condition - * * @var string */ protected $_inputType = null; @@ -84,17 +83,13 @@ public function __construct(Context $context, array $data = []) $options = $this->getAttributeOptions(); if ($options) { - foreach (array_keys($options) as $attr) { - $this->setAttribute($attr); - break; - } + reset($options); + $this->setAttribute(key($options)); } $options = $this->getOperatorOptions(); if ($options) { - foreach (array_keys($options) as $operator) { - $this->setOperator($operator); - break; - } + reset($options); + $this->setOperator(key($options)); } } @@ -160,14 +155,13 @@ public function getForm() */ public function asArray(array $arrAttributes = []) { - $out = [ + return [ 'type' => $this->getType(), 'attribute' => $this->getAttribute(), 'operator' => $this->getOperator(), 'value' => $this->getValue(), 'is_value_processed' => $this->getIsValueParsed(), ]; - return $out; } /** @@ -205,7 +199,7 @@ public function getMappedSqlField() */ public function asXml() { - $xml = "" . + return "" . $this->getType() . "" . "" . @@ -217,7 +211,6 @@ public function asXml() "" . $this->getValue() . ""; - return $xml; } /** @@ -244,8 +237,7 @@ public function loadXml($xml) if (is_string($xml)) { $xml = simplexml_load_string($xml); } - $arr = (array)$xml; - $this->loadArray($arr); + $this->loadArray((array)$xml); return $this; } @@ -304,10 +296,7 @@ public function loadOperatorOptions() */ public function getInputType() { - if (null === $this->_inputType) { - return 'string'; - } - return $this->_inputType; + return null === $this->_inputType ? 'string' : $this->_inputType; } /** @@ -348,12 +337,11 @@ public function loadValueOptions() */ public function getValueSelectOptions() { - $valueOption = $opt = []; + $opt = []; if ($this->hasValueOption()) { - $valueOption = (array)$this->getValueOption(); - } - foreach ($valueOption as $key => $value) { - $opt[] = ['value' => $key, 'label' => $value]; + foreach ((array)$this->getValueOption() as $key => $value) { + $opt[] = ['value' => $key, 'label' => $value]; + } } return $opt; } @@ -470,13 +458,12 @@ public function getNewChildName() */ public function asHtml() { - $html = $this->getTypeElementHtml() . + return $this->getTypeElementHtml() . $this->getAttributeElementHtml() . $this->getOperatorElementHtml() . $this->getValueElementHtml() . $this->getRemoveLinkHtml() . $this->getChooserContainerHtml(); - return $html; } /** @@ -484,8 +471,7 @@ public function asHtml() */ public function asHtmlRecursive() { - $html = $this->asHtml(); - return $html; + return $this->asHtml(); } /** @@ -520,9 +506,10 @@ public function getTypeElementHtml() public function getAttributeElement() { if (null === $this->getAttribute()) { - foreach (array_keys($this->getAttributeOption()) as $option) { - $this->setAttribute($option); - break; + $options = $this->getAttributeOption(); + if ($options) { + reset($options); + $this->setAttribute(key($options)); } } return $this->getForm()->addField( @@ -558,10 +545,8 @@ public function getOperatorElement() { $options = $this->getOperatorSelectOptions(); if ($this->getOperator() === null) { - foreach ($options as $option) { - $this->setOperator($option['value']); - break; - } + $option = reset($options); + $this->setOperator($option['value']); } $elementId = sprintf('%s__%s__operator', $this->getPrefix(), $this->getId()); @@ -654,8 +639,7 @@ public function getValueElementHtml() public function getAddLinkHtml() { $src = $this->_assetRepo->getUrl('images/rule_component_add.gif'); - $html = ''; - return $html; + return ''; } /** @@ -676,11 +660,7 @@ public function getRemoveLinkHtml() public function getChooserContainerHtml() { $url = $this->getValueElementChooserUrl(); - $html = ''; - if ($url) { - $html = '
    '; - } - return $html; + return $url ? '
    ' : ''; } /** @@ -690,8 +670,7 @@ public function getChooserContainerHtml() */ public function asString($format = '') { - $str = $this->getAttributeName() . ' ' . $this->getOperatorName() . ' ' . $this->getValueName(); - return $str; + return $this->getAttributeName() . ' ' . $this->getOperatorName() . ' ' . $this->getValueName(); } /** @@ -700,8 +679,7 @@ public function asString($format = '') */ public function asStringRecursive($level = 0) { - $str = str_pad('', $level * 3, ' ', STR_PAD_LEFT) . $this->asString(); - return $str; + return str_pad('', $level * 3, ' ', STR_PAD_LEFT) . $this->asString(); } /** @@ -740,12 +718,10 @@ public function validateAttribute($validatedValue) case '==': case '!=': if (is_array($value)) { - if (is_array($validatedValue)) { - $result = array_intersect($value, $validatedValue); - $result = !empty($result); - } else { + if (!is_array($validatedValue)) { return false; } + $result = !empty(array_intersect($value, $validatedValue)); } else { if (is_array($validatedValue)) { $result = count($validatedValue) == 1 && array_shift($validatedValue) == $value; @@ -759,18 +735,16 @@ public function validateAttribute($validatedValue) case '>': if (!is_scalar($validatedValue)) { return false; - } else { - $result = $validatedValue <= $value; } + $result = $validatedValue <= $value; break; case '>=': case '<': if (!is_scalar($validatedValue)) { return false; - } else { - $result = $validatedValue >= $value; } + $result = $validatedValue >= $value; break; case '{}': @@ -783,12 +757,11 @@ public function validateAttribute($validatedValue) } } } elseif (is_array($value)) { - if (is_array($validatedValue)) { - $result = array_intersect($value, $validatedValue); - $result = !empty($result); - } else { + if (!is_array($validatedValue)) { return false; } + $result = array_intersect($value, $validatedValue); + $result = !empty($result); } else { if (is_array($validatedValue)) { $result = in_array($value, $validatedValue); @@ -833,13 +806,13 @@ protected function _compareValues($validatedValue, $value, $strict = true) { if ($strict && is_numeric($validatedValue) && is_numeric($value)) { return $validatedValue == $value; - } else { - $validatePattern = preg_quote($validatedValue, '~'); - if ($strict) { - $validatePattern = '^' . $validatePattern . '$'; - } - return (bool)preg_match('~' . $validatePattern . '~iu', $value); } + + $validatePattern = preg_quote($validatedValue, '~'); + if ($strict) { + $validatePattern = '^' . $validatePattern . '$'; + } + return (bool)preg_match('~' . $validatePattern . '~iu', $value); } /** diff --git a/app/code/Magento/Rule/Model/Condition/Combine.php b/app/code/Magento/Rule/Model/Condition/Combine.php index 24ed1cb497472..48873aec66295 100644 --- a/app/code/Magento/Rule/Model/Condition/Combine.php +++ b/app/code/Magento/Rule/Model/Condition/Combine.php @@ -46,10 +46,8 @@ public function __construct(Context $context, array $data = []) $this->loadAggregatorOptions(); $options = $this->getAggregatorOptions(); if ($options) { - foreach (array_keys($options) as $aggregator) { - $this->setAggregator($aggregator); - break; - } + reset($options); + $this->setAggregator(key($options)); } } @@ -90,9 +88,10 @@ public function getAggregatorName() public function getAggregatorElement() { if ($this->getAggregator() === null) { - foreach (array_keys($this->getAggregatorOption()) as $key) { - $this->setAggregator($key); - break; + $options = $this->getAggregatorOption(); + if ($options) { + reset($options); + $this->setAggregator(key($options)); } } return $this->getForm()->addField( diff --git a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php index 41a55f4c25166..e1c9bf99f2675 100644 --- a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php +++ b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php @@ -6,9 +6,14 @@ namespace Magento\Rule\Model\Condition\Sql; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DB\Select; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Rule\Model\Condition\AbstractCondition; use Magento\Rule\Model\Condition\Combine; +use Magento\Eav\Api\AttributeRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Eav\Model\Entity\Collection\AbstractCollection; /** * Class SQL Builder @@ -41,12 +46,22 @@ class Builder */ protected $_expressionFactory; + /** + * @var AttributeRepositoryInterface + */ + private $attributeRepository; + /** * @param ExpressionFactory $expressionFactory + * @param AttributeRepositoryInterface|null $attributeRepository */ - public function __construct(ExpressionFactory $expressionFactory) - { + public function __construct( + ExpressionFactory $expressionFactory, + AttributeRepositoryInterface $attributeRepository = null + ) { $this->_expressionFactory = $expressionFactory; + $this->attributeRepository = $attributeRepository ?: + ObjectManager::getInstance()->get(AttributeRepositoryInterface::class); } /** @@ -88,14 +103,14 @@ protected function _getChildCombineTablesToJoin(Combine $combine, $tables = []) /** * Join tables from conditions combination to collection * - * @param \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection + * @param AbstractCollection $collection * @param Combine $combine * @return $this */ protected function _joinTablesToCollection( - \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection, + AbstractCollection $collection, Combine $combine - ) { + ): Builder { foreach ($this->_getCombineTablesToJoin($combine) as $alias => $joinTable) { /** @var $condition AbstractCondition */ $collection->getSelect()->joinLeft( @@ -104,6 +119,7 @@ protected function _joinTablesToCollection( isset($joinTable['columns']) ? $joinTable['columns'] : '*' ); } + return $this; } @@ -112,11 +128,15 @@ protected function _joinTablesToCollection( * * @param AbstractCondition $condition * @param string $value + * @param bool $isDefaultStoreUsed * @return string * @throws \Magento\Framework\Exception\LocalizedException */ - protected function _getMappedSqlCondition(AbstractCondition $condition, $value = '') - { + protected function _getMappedSqlCondition( + AbstractCondition $condition, + string $value = '', + bool $isDefaultStoreUsed = true + ): string { $argument = $condition->getMappedSqlField(); // If rule hasn't valid argument - create negative expression to prevent incorrect rule behavior. @@ -130,9 +150,16 @@ protected function _getMappedSqlCondition(AbstractCondition $condition, $value = throw new \Magento\Framework\Exception\LocalizedException(__('Unknown condition operator')); } + $defaultValue = 0; + // Check if attribute has a table with default value and add it to the query + if ($this->canAttributeHaveDefaultValue($condition->getAttribute(), $isDefaultStoreUsed)) { + $defaultField = 'at_' . $condition->getAttribute() . '_default.value'; + $defaultValue = $this->_connection->quoteIdentifier($defaultField); + } + $sql = str_replace( ':field', - $this->_connection->getIfNullSql($this->_connection->quoteIdentifier($argument), 0), + $this->_connection->getIfNullSql($this->_connection->quoteIdentifier($argument), $defaultValue), $this->_conditionOperatorMap[$conditionOperator] ); @@ -144,11 +171,15 @@ protected function _getMappedSqlCondition(AbstractCondition $condition, $value = /** * @param Combine $combine * @param string $value + * @param bool $isDefaultStoreUsed * @return string * @SuppressWarnings(PHPMD.NPathComplexity) */ - protected function _getMappedSqlCombination(Combine $combine, $value = '') - { + protected function _getMappedSqlCombination( + Combine $combine, + string $value = '', + bool $isDefaultStoreUsed = true + ): string { $out = (!empty($value) ? $value : ''); $value = ($combine->getValue() ? '' : ' NOT '); $getAggregator = $combine->getAggregator(); @@ -158,33 +189,68 @@ protected function _getMappedSqlCombination(Combine $combine, $value = '') $con = ($getAggregator == 'any' ? Select::SQL_OR : Select::SQL_AND); $con = (isset($conditions[$key+1]) ? $con : ''); if ($condition instanceof Combine) { - $out .= $this->_getMappedSqlCombination($condition, $value); + $out .= $this->_getMappedSqlCombination($condition, $value, $isDefaultStoreUsed); } else { - $out .= $this->_getMappedSqlCondition($condition, $value); + $out .= $this->_getMappedSqlCondition($condition, $value, $isDefaultStoreUsed); } $out .= $out ? (' ' . $con) : ''; } + return $this->_expressionFactory->create(['expression' => $out]); } /** * Attach conditions filter to collection * - * @param \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection + * @param AbstractCollection $collection * @param Combine $combine - * * @return void */ public function attachConditionToCollection( - \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection, + AbstractCollection $collection, Combine $combine - ) { + ): void { $this->_connection = $collection->getResource()->getConnection(); $this->_joinTablesToCollection($collection, $combine); - $whereExpression = (string)$this->_getMappedSqlCombination($combine); + $isDefaultStoreUsed = $this->checkIsDefaultStoreUsed($collection); + $whereExpression = (string)$this->_getMappedSqlCombination($combine, '', $isDefaultStoreUsed); if (!empty($whereExpression)) { // Select ::where method adds braces even on empty expression $collection->getSelect()->where($whereExpression); } } + + /** + * Check is default store used. + * + * @param AbstractCollection $collection + * @return bool + */ + private function checkIsDefaultStoreUsed(AbstractCollection $collection): bool + { + return (int)$collection->getStoreId() === (int)$collection->getDefaultStoreId(); + } + + /** + * Check if attribute can have default value. + * + * @param string $attributeCode + * @param bool $isDefaultStoreUsed + * @return bool + */ + private function canAttributeHaveDefaultValue(string $attributeCode, bool $isDefaultStoreUsed): bool + { + if ($isDefaultStoreUsed) { + return false; + } + + try { + $attribute = $this->attributeRepository->get(Product::ENTITY, $attributeCode); + } catch (NoSuchEntityException $e) { + // It's not exceptional case as we want to check if we have such attribute or not + return false; + } + + return !$attribute->isScopeGlobal(); + } } diff --git a/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php b/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php index f53098c4bb97e..daf7b1462c722 100644 --- a/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php +++ b/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php @@ -35,7 +35,12 @@ public function testAttachConditionToCollection() { $collection = $this->createPartialMock( \Magento\Eav\Model\Entity\Collection\AbstractCollection::class, - ['getResource', 'getSelect'] + [ + 'getResource', + 'getSelect', + 'getStoreId', + 'getDefaultStoreId', + ] ); $combine = $this->createPartialMock(\Magento\Rule\Model\Condition\Combine::class, ['getConditions']); $resource = $this->createPartialMock(\Magento\Framework\DB\Adapter\Pdo\Mysql::class, ['getConnection']); @@ -53,10 +58,15 @@ public function testAttachConditionToCollection() $collection->expects($this->once()) ->method('getResource') ->will($this->returnValue($resource)); - $collection->expects($this->any()) ->method('getSelect') ->will($this->returnValue($select)); + $collection->expects($this->once()) + ->method('getStoreId') + ->willReturn(1); + $collection->expects($this->once()) + ->method('getDefaultStoreId') + ->willReturn(1); $resource->expects($this->once()) ->method('getConnection') diff --git a/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php b/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php index d8c0cc470f55e..f78ee4f345d0d 100644 --- a/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php +++ b/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php @@ -78,7 +78,8 @@ public function testCreateExceptionClass() ->expects($this->never()) ->method('create'); - $this->expectException(\InvalidArgumentException::class, 'Class does not exist'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Class does not exist'); $this->conditionFactory->create($type); } @@ -92,7 +93,8 @@ public function testCreateExceptionType() ->method('create') ->with($type) ->willReturn(new \stdClass()); - $this->expectException(\InvalidArgumentException::class, 'Class does not implement condition interface'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Class does not implement condition interface'); $this->conditionFactory->create($type); } } diff --git a/app/code/Magento/Rule/composer.json b/app/code/Magento/Rule/composer.json index 3287bd961e9df..15f72b8ec24a0 100644 --- a/app/code/Magento/Rule/composer.json +++ b/app/code/Magento/Rule/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "lib-libxml": "*", "magento/framework": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/Rule/view/adminhtml/web/conditions-data-normalizer.js b/app/code/Magento/Rule/view/adminhtml/web/conditions-data-normalizer.js new file mode 100644 index 0000000000000..8eebc9ef0bd40 --- /dev/null +++ b/app/code/Magento/Rule/view/adminhtml/web/conditions-data-normalizer.js @@ -0,0 +1,140 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'underscore' +], function ($, _) { + 'use strict'; + + /** + * @constructor + */ + var ConditionsDataNormalizer = function () { + this.patterns = { + validate: /^[a-z0-9_.-][a-z0-9_.-]*(?:\[(?:\d*|[a-z0-9_.-]+)\])*$/i, + key: /[a-z0-9_.-]+|(?=\[\])/gi, + push: /^$/, + fixed: /^\d+$/, + named: /^[a-z0-9_.-]+$/i + }; + }; + + ConditionsDataNormalizer.prototype = { + /** + * Will convert an object: + * { + * "foo[bar][1][baz]": 123, + * "foo[bar][1][blah]": 321 + * "foo[bar][1--1][ah]": 456 + * } + * + * to + * { + * "foo": { + * "bar": { + * "1": { + * "baz": 123, + * "blah": 321 + * }, + * "1--1": { + * "ah": 456 + * } + * } + * } + * } + */ + normalize: function normalize(value) { + var el, _this = this; + + this.pushes = {}; + this.data = {}; + + _.each(value, function (e, i) { + el = {}; + el[i] = e; + + _this._addPair({ + name: i, + value: e + }); + }); + + return this.data; + }, + + /** + * @param {Object} base + * @param {String} key + * @param {String} value + * @return {Object} + * @private + */ + _build: function build(base, key, value) { + base[key] = value; + + return base; + }, + + /** + * @param {Object} root + * @param {String} value + * @return {*} + * @private + */ + _makeObject: function makeObject(root, value) { + var keys = root.match(this.patterns.key), + k, idx; // nest, nest, ..., nest + + while ((k = keys.pop()) !== undefined) { + // foo[] + if (this.patterns.push.test(k)) { + idx = this._incrementPush(root.replace(/\[\]$/, '')); + value = this._build([], idx, value); + } // foo[n] + else if (this.patterns.fixed.test(k)) { + value = this._build({}, k, value); + } // foo; foo[bar] + else if (this.patterns.named.test(k)) { + value = this._build({}, k, value); + } + } + + return value; + }, + + /** + * @param {String} key + * @return {Number} + * @private + */ + _incrementPush: function incrementPush(key) { + if (this.pushes[key] === undefined) { + this.pushes[key] = 0; + } + + return this.pushes[key]++; + }, + + /** + * @param {Object} pair + * @return {Object} + * @private + */ + _addPair: function addPair(pair) { + var obj = this._makeObject(pair.name, pair.value); + + if (!this.patterns.validate.test(pair.name)) { + return this; + } + + this.data = $.extend(true, this.data, obj); + + return this; + } + }; + + return ConditionsDataNormalizer; +}); diff --git a/app/code/Magento/Rule/view/adminhtml/web/rules.js b/app/code/Magento/Rule/view/adminhtml/web/rules.js index 5c4be367b9cb3..b094b9818364a 100644 --- a/app/code/Magento/Rule/view/adminhtml/web/rules.js +++ b/app/code/Magento/Rule/view/adminhtml/web/rules.js @@ -137,7 +137,7 @@ define([ }, onSuccess: function (transport) { if (this._processSuccess(transport)) { - $(chooser).update(transport.responseText); + jQuery(chooser).html(transport.responseText); this.showChooserLoaded(chooser, transport); jQuery(chooser).trigger('contentUpdated'); } diff --git a/app/code/Magento/Sales/Api/OrderCustomerDelegateInterface.php b/app/code/Magento/Sales/Api/OrderCustomerDelegateInterface.php new file mode 100644 index 0000000000000..2902903b0b7d0 --- /dev/null +++ b/app/code/Magento/Sales/Api/OrderCustomerDelegateInterface.php @@ -0,0 +1,25 @@ +_orderCollectionFactory = $orderCollectionFactory; $this->_customerSession = $customerSession; $this->_orderConfig = $orderConfig; - parent::__construct($context, $data); $this->_isScopePrivate = true; + $this->storeManager = $storeManager ?: ObjectManager::getInstance() + ->get(StoreManagerInterface::class); + parent::__construct($context, $data); } /** @@ -55,11 +76,22 @@ public function __construct( protected function _construct() { parent::_construct(); + $this->getRecentOrders(); + } + + /** + * Get recently placed orders. By default they will be limited by 5. + */ + private function getRecentOrders() + { $orders = $this->_orderCollectionFactory->create()->addAttributeToSelect( '*' )->addAttributeToFilter( 'customer_id', $this->_customerSession->getCustomerId() + )->addAttributeToFilter( + 'store_id', + $this->storeManager->getStore()->getId() )->addAttributeToFilter( 'status', ['in' => $this->_orderConfig->getVisibleOnFrontStatuses()] @@ -67,7 +99,7 @@ protected function _construct() 'created_at', 'desc' )->setPageSize( - '5' + self::ORDER_LIMIT )->load(); $this->setOrders($orders); } diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php index 2ba8467ff6864..8488e402caf69 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php @@ -9,6 +9,7 @@ use Magento\Backend\App\Action\Context; use Magento\Ui\Component\MassAction\Filter; use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; +use Magento\Sales\Api\OrderManagementInterface; class MassCancel extends \Magento\Sales\Controller\Adminhtml\Order\AbstractMassAction { @@ -16,16 +17,29 @@ class MassCancel extends \Magento\Sales\Controller\Adminhtml\Order\AbstractMassA * Authorization level of a basic admin session */ const ADMIN_RESOURCE = 'Magento_Sales::cancel'; + + /** + * @var OrderManagementInterface + */ + private $orderManagement; /** * @param Context $context * @param Filter $filter * @param CollectionFactory $collectionFactory + * @param OrderManagementInterface|null $orderManagement */ - public function __construct(Context $context, Filter $filter, CollectionFactory $collectionFactory) - { + public function __construct( + Context $context, + Filter $filter, + CollectionFactory $collectionFactory, + OrderManagementInterface $orderManagement = null + ) { parent::__construct($context, $filter); $this->collectionFactory = $collectionFactory; + $this->orderManagement = $orderManagement ?: \Magento\Framework\App\ObjectManager::getInstance()->get( + \Magento\Sales\Api\OrderManagementInterface::class + ); } /** @@ -38,11 +52,10 @@ protected function massAction(AbstractCollection $collection) { $countCancelOrder = 0; foreach ($collection->getItems() as $order) { - if (!$order->canCancel()) { + $isCanceled = $this->orderManagement->cancel($order->getEntityId()); + if ($isCanceled === false) { continue; } - $order->cancel(); - $order->save(); $countCancelOrder++; } $countNonCancelOrder = $collection->count() - $countCancelOrder; diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php index ebd6ff4a79b06..2eb54c9814ef7 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php @@ -9,6 +9,7 @@ use Magento\Backend\App\Action\Context; use Magento\Ui\Component\MassAction\Filter; use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; +use Magento\Sales\Api\OrderManagementInterface; class MassUnhold extends AbstractMassAction { @@ -16,16 +17,29 @@ class MassUnhold extends AbstractMassAction * Authorization level of a basic admin session */ const ADMIN_RESOURCE = 'Magento_Sales::unhold'; + + /** + * @var OrderManagementInterface + */ + private $orderManagement; /** * @param Context $context * @param Filter $filter * @param CollectionFactory $collectionFactory + * @param OrderManagementInterface|null $orderManagement */ - public function __construct(Context $context, Filter $filter, CollectionFactory $collectionFactory) - { + public function __construct( + Context $context, + Filter $filter, + CollectionFactory $collectionFactory, + OrderManagementInterface $orderManagement = null + ) { parent::__construct($context, $filter); $this->collectionFactory = $collectionFactory; + $this->orderManagement = $orderManagement ?: \Magento\Framework\App\ObjectManager::getInstance()->get( + \Magento\Sales\Api\OrderManagementInterface::class + ); } /** @@ -40,12 +54,10 @@ protected function massAction(AbstractCollection $collection) /** @var \Magento\Sales\Model\Order $order */ foreach ($collection->getItems() as $order) { - $order->load($order->getId()); if (!$order->canUnhold()) { continue; } - $order->unhold(); - $order->save(); + $this->orderManagement->unHold($order->getEntityId()); $countUnHoldOrder++; } diff --git a/app/code/Magento/Sales/CustomerData/LastOrderedItems.php b/app/code/Magento/Sales/CustomerData/LastOrderedItems.php index 3cd3afbfa4d22..f2f36979f30b2 100644 --- a/app/code/Magento/Sales/CustomerData/LastOrderedItems.php +++ b/app/code/Magento/Sales/CustomerData/LastOrderedItems.php @@ -6,6 +6,9 @@ namespace Magento\Sales\CustomerData; use Magento\Customer\CustomerData\SectionSourceInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Psr\Log\LoggerInterface; /** * Returns information for "Recently Ordered" widget. @@ -54,25 +57,41 @@ class LastOrderedItems implements SectionSourceInterface */ private $_storeManager; + /** + * @var \Magento\Catalog\Api\ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory * @param \Magento\Sales\Model\Order\Config $orderConfig * @param \Magento\Customer\Model\Session $customerSession * @param \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param ProductRepositoryInterface $productRepository + * @param LoggerInterface $logger */ public function __construct( \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory, \Magento\Sales\Model\Order\Config $orderConfig, \Magento\Customer\Model\Session $customerSession, \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry, - \Magento\Store\Model\StoreManagerInterface $storeManager + \Magento\Store\Model\StoreManagerInterface $storeManager, + ProductRepositoryInterface $productRepository, + LoggerInterface $logger ) { $this->_orderCollectionFactory = $orderCollectionFactory; $this->_orderConfig = $orderConfig; $this->_customerSession = $customerSession; $this->stockRegistry = $stockRegistry; $this->_storeManager = $storeManager; + $this->productRepository = $productRepository; + $this->logger = $logger; } /** @@ -108,11 +127,23 @@ protected function getItems() $website = $this->_storeManager->getStore()->getWebsiteId(); /** @var \Magento\Sales\Model\Order\Item $item */ foreach ($order->getParentItemsRandomCollection($limit) as $item) { - if ($item->hasData('product') && in_array($website, $item->getProduct()->getWebsiteIds())) { + /** @var \Magento\Catalog\Model\Product $product */ + try { + $product = $this->productRepository->getById( + $item->getProductId(), + false, + $this->_storeManager->getStore()->getId() + ); + } catch (NoSuchEntityException $noEntityException) { + $this->logger->critical($noEntityException); + continue; + } + if (isset($product) && in_array($website, $product->getWebsiteIds())) { + $url = $product->isVisibleInSiteVisibility() ? $product->getProductUrl() : null; $items[] = [ 'id' => $item->getId(), 'name' => $item->getName(), - 'url' => $item->getProduct()->getProductUrl(), + 'url' => $url, 'is_saleable' => $this->isItemAvailableForReorder($item), ]; } @@ -136,7 +167,7 @@ protected function isItemAvailableForReorder(\Magento\Sales\Model\Order\Item $or $orderItem->getStore()->getWebsiteId() ); return $stockItem->getIsInStock(); - } catch (\Magento\Framework\Exception\NoSuchEntityException $noEntityException) { + } catch (NoSuchEntityException $noEntityException) { return false; } } diff --git a/app/code/Magento/Sales/Model/AdminOrder/Create.php b/app/code/Magento/Sales/Model/AdminOrder/Create.php index 679693abd5540..f34f8a681085d 100644 --- a/app/code/Magento/Sales/Model/AdminOrder/Create.php +++ b/app/code/Magento/Sales/Model/AdminOrder/Create.php @@ -714,9 +714,10 @@ public function getCustomerCart() $this->_cart = $this->quoteFactory->create(); $customerId = (int)$this->getSession()->getCustomerId(); + $storeId = (int)$this->getSession()->getStoreId(); if ($customerId) { try { - $this->_cart = $this->quoteRepository->getForCustomer($customerId); + $this->_cart = $this->quoteRepository->getForCustomer($customerId, [$storeId]); } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { $this->_cart->setStore($this->getSession()->getStore()); $customerData = $this->customerRepository->getById($customerId); diff --git a/app/code/Magento/Sales/Model/EmailSenderHandler.php b/app/code/Magento/Sales/Model/EmailSenderHandler.php index 73d4eacdd1fc8..fe8f1685fe525 100644 --- a/app/code/Magento/Sales/Model/EmailSenderHandler.php +++ b/app/code/Magento/Sales/Model/EmailSenderHandler.php @@ -5,6 +5,8 @@ */ namespace Magento\Sales\Model; +use Magento\Sales\Model\Order\Email\Container\IdentityInterface; + /** * Sales emails sending * @@ -41,22 +43,42 @@ class EmailSenderHandler */ protected $globalConfig; + /** + * @var IdentityInterface + */ + private $identityContainer; + + /** + * @var \Magento\Store\Model\StoreManagerInterface + */ + private $storeManager; + /** * @param \Magento\Sales\Model\Order\Email\Sender $emailSender * @param \Magento\Sales\Model\ResourceModel\EntityAbstract $entityResource * @param \Magento\Sales\Model\ResourceModel\Collection\AbstractCollection $entityCollection * @param \Magento\Framework\App\Config\ScopeConfigInterface $globalConfig + * @param IdentityInterface|null $identityContainer + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @throws \InvalidArgumentException */ public function __construct( \Magento\Sales\Model\Order\Email\Sender $emailSender, \Magento\Sales\Model\ResourceModel\EntityAbstract $entityResource, \Magento\Sales\Model\ResourceModel\Collection\AbstractCollection $entityCollection, - \Magento\Framework\App\Config\ScopeConfigInterface $globalConfig + \Magento\Framework\App\Config\ScopeConfigInterface $globalConfig, + IdentityInterface $identityContainer = null, + \Magento\Store\Model\StoreManagerInterface $storeManager = null ) { $this->emailSender = $emailSender; $this->entityResource = $entityResource; $this->entityCollection = $entityCollection; $this->globalConfig = $globalConfig; + + $this->identityContainer = $identityContainer ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Sales\Model\Order\Email\Container\NullIdentity::class); + $this->storeManager = $storeManager ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Store\Model\StoreManagerInterface::class); } /** @@ -69,14 +91,50 @@ public function sendEmails() $this->entityCollection->addFieldToFilter('send_email', ['eq' => 1]); $this->entityCollection->addFieldToFilter('email_sent', ['null' => true]); - /** @var \Magento\Sales\Model\AbstractModel $item */ - foreach ($this->entityCollection->getItems() as $item) { - if ($this->emailSender->send($item, true)) { - $this->entityResource->save( - $item->setEmailSent(true) - ); + /** @var \Magento\Store\Api\Data\StoreInterface[] $stores */ + $stores = $this->getStores(clone $this->entityCollection); + + /** @var \Magento\Store\Model\Store $store */ + foreach ($stores as $store) { + $this->identityContainer->setStore($store); + if (!$this->identityContainer->isEnabled()) { + continue; + } + $entityCollection = clone $this->entityCollection; + $entityCollection->addFieldToFilter('store_id', $store->getId()); + + /** @var \Magento\Sales\Model\AbstractModel $item */ + foreach ($entityCollection->getItems() as $item) { + if ($this->emailSender->send($item, true)) { + $this->entityResource->save( + $item->setEmailSent(true) + ); + } } } } } + + /** + * Get stores for given entities. + * + * @param ResourceModel\Collection\AbstractCollection $entityCollection + * @return \Magento\Store\Api\Data\StoreInterface[] + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function getStores( + \Magento\Sales\Model\ResourceModel\Collection\AbstractCollection $entityCollection + ): array { + $stores = []; + + $entityCollection->addAttributeToSelect('store_id')->getSelect()->group('store_id'); + /** @var \Magento\Sales\Model\EntityInterface $item */ + foreach ($entityCollection->getItems() as $item) { + /** @var \Magento\Store\Model\StoreManagerInterface $store */ + $store = $this->storeManager->getStore($item->getStoreId()); + $stores[$item->getStoreId()] = $store; + } + + return $stores; + } } diff --git a/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php b/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php index 14d4ccae22446..991453bc6745e 100644 --- a/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php +++ b/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php @@ -7,6 +7,7 @@ use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Sales\Api\CreditmemoCommentRepositoryInterface; @@ -14,7 +15,13 @@ use Magento\Sales\Api\Data\CreditmemoCommentInterfaceFactory; use Magento\Sales\Api\Data\CreditmemoCommentSearchResultInterfaceFactory; use Magento\Sales\Model\Spi\CreditmemoCommentResourceInterface; +use Magento\Sales\Model\Order\Email\Sender\CreditmemoCommentSender; +use Magento\Sales\Api\CreditmemoRepositoryInterface; +use Psr\Log\LoggerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CommentRepository implements CreditmemoCommentRepositoryInterface { /** @@ -37,22 +44,48 @@ class CommentRepository implements CreditmemoCommentRepositoryInterface */ private $collectionProcessor; + /** + * @var CreditmemoCommentSender + */ + private $creditmemoCommentSender; + + /** + * @var CreditmemoRepositoryInterface + */ + private $creditmemoRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param CreditmemoCommentResourceInterface $commentResource * @param CreditmemoCommentInterfaceFactory $commentFactory * @param CreditmemoCommentSearchResultInterfaceFactory $searchResultFactory * @param CollectionProcessorInterface $collectionProcessor + * @param CreditmemoCommentSender|null $creditmemoCommentSender + * @param CreditmemoRepositoryInterface|null $creditmemoRepository + * @param LoggerInterface|null $logger */ public function __construct( CreditmemoCommentResourceInterface $commentResource, CreditmemoCommentInterfaceFactory $commentFactory, CreditmemoCommentSearchResultInterfaceFactory $searchResultFactory, - CollectionProcessorInterface $collectionProcessor + CollectionProcessorInterface $collectionProcessor, + CreditmemoCommentSender $creditmemoCommentSender = null, + CreditmemoRepositoryInterface $creditmemoRepository = null, + LoggerInterface $logger = null ) { $this->commentResource = $commentResource; $this->commentFactory = $commentFactory; $this->searchResultFactory = $searchResultFactory; $this->collectionProcessor = $collectionProcessor; + $this->creditmemoCommentSender = $creditmemoCommentSender + ?: ObjectManager::getInstance()->get(CreditmemoCommentSender::class); + $this->creditmemoRepository = $creditmemoRepository + ?: ObjectManager::getInstance()->get(CreditmemoRepositoryInterface::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -97,8 +130,16 @@ public function save(CreditmemoCommentInterface $entity) try { $this->commentResource->save($entity); } catch (\Exception $e) { - throw new CouldNotSaveException(__('Could not save the comment.'), $e); + throw new CouldNotSaveException(__('Could not save the creditmemo comment.'), $e); } + + try { + $creditmemo = $this->creditmemoRepository->get($entity->getParentId()); + $this->creditmemoCommentSender->send($creditmemo, $entity->getIsCustomerNotified(), $entity->getComment()); + } catch (\Exception $exception) { + $this->logger->critical($exception); + } + return $entity; } } diff --git a/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php b/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php index 5f1bba4c8f2c2..e61c2597975bb 100644 --- a/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php +++ b/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php @@ -5,13 +5,16 @@ */ namespace Magento\Sales\Model\Order; +use Magento\Bundle\Ui\DataProvider\Product\Listing\Collector\BundlePrice; +use Magento\Sales\Api\Data\OrderItemInterface; + /** * Factory class for @see \Magento\Sales\Model\Order\Creditmemo */ class CreditmemoFactory { /** - * Quote convert object + * Order convert object. * * @var \Magento\Sales\Model\Convert\Order */ @@ -63,31 +66,15 @@ public function createByOrder(\Magento\Sales\Model\Order $order, array $data = [ { $totalQty = 0; $creditmemo = $this->convertor->toCreditmemo($order); - $qtys = isset($data['qtys']) ? $data['qtys'] : []; + $qtyList = isset($data['qtys']) ? $data['qtys'] : []; foreach ($order->getAllItems() as $orderItem) { - if (!$this->canRefundItem($orderItem, $qtys)) { + if (!$this->canRefundItem($orderItem, $qtyList)) { continue; } $item = $this->convertor->itemToCreditmemoItem($orderItem); - if ($orderItem->isDummy()) { - if (isset($data['qtys'][$orderItem->getParentItemId()])) { - $parentQty = $data['qtys'][$orderItem->getParentItemId()]; - } else { - $parentQty = $orderItem->getParentItem() ? $orderItem->getParentItem()->getQtyToRefund() : 1; - } - $qty = $this->calculateProductOptions($orderItem, $parentQty); - $orderItem->setLockedDoShip(true); - } else { - if (isset($qtys[$orderItem->getId()])) { - $qty = (double)$qtys[$orderItem->getId()]; - } elseif (!count($qtys)) { - $qty = $orderItem->getQtyToRefund(); - } else { - continue; - } - } + $qty = $this->getQtyToRefund($orderItem, $qtyList); $totalQty += $qty; $item->setQty($qty); $creditmemo->addItem($item); @@ -106,72 +93,31 @@ public function createByOrder(\Magento\Sales\Model\Order $order, array $data = [ * @param \Magento\Sales\Model\Order\Invoice $invoice * @param array $data * @return Creditmemo - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) */ public function createByInvoice(\Magento\Sales\Model\Order\Invoice $invoice, array $data = []) { $order = $invoice->getOrder(); $totalQty = 0; - $qtys = isset($data['qtys']) ? $data['qtys'] : []; + $qtyList = isset($data['qtys']) ? $data['qtys'] : []; $creditmemo = $this->convertor->toCreditmemo($order); $creditmemo->setInvoice($invoice); - $invoiceQtysRefunded = []; - foreach ($invoice->getOrder()->getCreditmemosCollection() as $createdCreditmemo) { - if ($createdCreditmemo->getState() != Creditmemo::STATE_CANCELED && - $createdCreditmemo->getInvoiceId() == $invoice->getId() - ) { - foreach ($createdCreditmemo->getAllItems() as $createdCreditmemoItem) { - $orderItemId = $createdCreditmemoItem->getOrderItem()->getId(); - if (isset($invoiceQtysRefunded[$orderItemId])) { - $invoiceQtysRefunded[$orderItemId] += $createdCreditmemoItem->getQty(); - } else { - $invoiceQtysRefunded[$orderItemId] = $createdCreditmemoItem->getQty(); - } - } - } - } - - $invoiceQtysRefundLimits = []; - foreach ($invoice->getAllItems() as $invoiceItem) { - $invoiceQtyCanBeRefunded = $invoiceItem->getQty(); - $orderItemId = $invoiceItem->getOrderItem()->getId(); - if (isset($invoiceQtysRefunded[$orderItemId])) { - $invoiceQtyCanBeRefunded = $invoiceQtyCanBeRefunded - $invoiceQtysRefunded[$orderItemId]; - } - $invoiceQtysRefundLimits[$orderItemId] = $invoiceQtyCanBeRefunded; - } + $invoiceRefundLimitsQtyList = $this->getInvoiceRefundLimitsQtyList($invoice); foreach ($invoice->getAllItems() as $invoiceItem) { + /** @var OrderItemInterface $orderItem */ $orderItem = $invoiceItem->getOrderItem(); - if (!$this->canRefundItem($orderItem, $qtys, $invoiceQtysRefundLimits)) { + if (!$this->canRefundItem($orderItem, $qtyList, $invoiceRefundLimitsQtyList)) { continue; } - $item = $this->convertor->itemToCreditmemoItem($orderItem); - if ($orderItem->isDummy()) { - if (isset($data['qtys'][$orderItem->getParentItemId()])) { - $parentQty = $data['qtys'][$orderItem->getParentItemId()]; - } else { - $parentQty = $orderItem->getParentItem() ? $orderItem->getParentItem()->getQtyToRefund() : 1; - } - $qty = $this->calculateProductOptions($orderItem, $parentQty); - } else { - if (isset($qtys[$orderItem->getId()])) { - $qty = (double)$qtys[$orderItem->getId()]; - } elseif (!count($qtys)) { - $qty = $orderItem->getQtyToRefund(); - } else { - continue; - } - if (isset($invoiceQtysRefundLimits[$orderItem->getId()])) { - $qty = min($qty, $invoiceQtysRefundLimits[$orderItem->getId()]); - } - } - $qty = min($qty, $invoiceItem->getQty()); + $qty = min( + $this->getQtyToRefund($orderItem, $qtyList, $invoiceRefundLimitsQtyList), + $invoiceItem->getQty() + ); $totalQty += $qty; + $item = $this->convertor->itemToCreditmemoItem($orderItem); $item->setQty($qty); $creditmemo->addItem($item); } @@ -179,15 +125,7 @@ public function createByInvoice(\Magento\Sales\Model\Order\Invoice $invoice, arr $this->initData($creditmemo, $data); if (!isset($data['shipping_amount'])) { - $isShippingInclTax = $this->taxConfig->displaySalesShippingInclTax($order->getStoreId()); - if ($isShippingInclTax) { - $baseAllowedAmount = $order->getBaseShippingInclTax() - - $order->getBaseShippingRefunded() - - $order->getBaseShippingTaxRefunded(); - } else { - $baseAllowedAmount = $order->getBaseShippingAmount() - $order->getBaseShippingRefunded(); - $baseAllowedAmount = min($baseAllowedAmount, $invoice->getBaseShippingAmount()); - } + $baseAllowedAmount = $this->getShippingAmount($invoice); $creditmemo->setBaseShippingAmount($baseAllowedAmount); } @@ -272,11 +210,11 @@ protected function initData($creditmemo, $data) } /** - * @param \Magento\Sales\Api\Data\OrderItemInterface $orderItem + * @param Item $orderItem * @param int $parentQty * @return int */ - private function calculateProductOptions(\Magento\Sales\Api\Data\OrderItemInterface $orderItem, $parentQty) + private function calculateProductOptions(Item $orderItem, int $parentQty): int { $qty = $parentQty; $productOptions = $orderItem->getProductOptions(); @@ -290,4 +228,113 @@ private function calculateProductOptions(\Magento\Sales\Api\Data\OrderItemInterf } return $qty; } + + /** + * Gets list of quantities based on invoice refunded items. + * + * @param Invoice $invoice + * @return array + */ + private function getInvoiceRefundedQtyList(Invoice $invoice): array + { + $invoiceRefundedQtyList = []; + foreach ($invoice->getOrder()->getCreditmemosCollection() as $creditmemo) { + if ($creditmemo->getState() !== Creditmemo::STATE_CANCELED && + $creditmemo->getInvoiceId() === $invoice->getId() + ) { + foreach ($creditmemo->getAllItems() as $creditmemoItem) { + $orderItemId = $creditmemoItem->getOrderItem()->getId(); + if (isset($invoiceRefundedQtyList[$orderItemId])) { + $invoiceRefundedQtyList[$orderItemId] += $creditmemoItem->getQty(); + } else { + $invoiceRefundedQtyList[$orderItemId] = $creditmemoItem->getQty(); + } + } + } + } + + return $invoiceRefundedQtyList; + } + + /** + * Gets limits of refund based on invoice items. + * + * @param Invoice $invoice + * @return array + */ + private function getInvoiceRefundLimitsQtyList(Invoice $invoice): array + { + $invoiceRefundLimitsQtyList = []; + $invoiceRefundedQtyList = $this->getInvoiceRefundedQtyList($invoice); + + foreach ($invoice->getAllItems() as $invoiceItem) { + $qtyCanBeRefunded = $invoiceItem->getQty(); + $orderItemId = $invoiceItem->getOrderItem()->getId(); + if (isset($invoiceRefundedQtyList[$orderItemId])) { + $qtyCanBeRefunded = $qtyCanBeRefunded - $invoiceRefundedQtyList[$orderItemId]; + } + $invoiceRefundLimitsQtyList[$orderItemId] = $qtyCanBeRefunded; + } + + return $invoiceRefundLimitsQtyList; + } + + /** + * Gets quantity of items to refund based on order item. + * + * @param Item $orderItem + * @param array $qtyList + * @param array $refundLimits + * @return float + */ + private function getQtyToRefund(Item $orderItem, array $qtyList, array $refundLimits = []): float + { + $qty = 0; + if ($orderItem->isDummy()) { + if (isset($qtyList[$orderItem->getParentItemId()])) { + $parentQty = $qtyList[$orderItem->getParentItemId()]; + } elseif ($orderItem->getProductType() === BundlePrice::PRODUCT_TYPE) { + $parentQty = $orderItem->getQtyInvoiced(); + } else { + $parentQty = $orderItem->getParentItem() ? $orderItem->getParentItem()->getQtyToRefund() : 1; + } + $qty = $this->calculateProductOptions($orderItem, $parentQty); + } else { + if (isset($qtyList[$orderItem->getId()])) { + $qty = $qtyList[$orderItem->getId()]; + } elseif (!count($qtyList)) { + $qty = $orderItem->getQtyToRefund(); + } else { + return (float)$qty; + } + + if (isset($refundLimits[$orderItem->getId()])) { + $qty = min($qty, $refundLimits[$orderItem->getId()]); + } + } + + return (float)$qty; + } + + /** + * Gets shipping amount based on invoice. + * + * @param Invoice $invoice + * @return float + */ + private function getShippingAmount(Invoice $invoice): float + { + $order = $invoice->getOrder(); + $isShippingInclTax = $this->taxConfig->displaySalesShippingInclTax($order->getStoreId()); + if ($isShippingInclTax) { + $amount = $order->getBaseShippingInclTax() - + $order->getBaseShippingRefunded() - + $order->getBaseShippingTaxRefunded(); + } else { + $amount = $order->getBaseShippingAmount() - $order->getBaseShippingRefunded(); + $amount = min($amount, $invoice->getBaseShippingAmount()); + } + + return (float)$amount; + } } diff --git a/app/code/Magento/Sales/Model/Order/CustomerManagement.php b/app/code/Magento/Sales/Model/Order/CustomerManagement.php index 466f3ff8adddb..ae3f940dbb2ba 100644 --- a/app/code/Magento/Sales/Model/Order/CustomerManagement.php +++ b/app/code/Magento/Sales/Model/Order/CustomerManagement.php @@ -6,12 +6,18 @@ namespace Magento\Sales\Model\Order; +use Magento\Customer\Api\Data\AddressInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\AlreadyExistsException; +use Magento\Sales\Api\Data\OrderAddressInterface; use Magento\Quote\Model\Quote\Address as QuoteAddress; +use Magento\Quote\Model\Quote\AddressFactory as QuoteAddressFactory; +use Magento\Sales\Api\Data\OrderInterface; /** * Class CustomerManagement + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementInterface { @@ -21,17 +27,17 @@ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementIn protected $accountManagement; /** - * @var \Magento\Customer\Api\Data\CustomerInterfaceFactory + * @deprecated */ protected $customerFactory; /** - * @var \Magento\Customer\Api\Data\AddressInterfaceFactory + * @deprecated */ protected $addressFactory; /** - * @var \Magento\Customer\Api\Data\RegionInterfaceFactory + * @deprecated */ protected $regionFactory; @@ -41,15 +47,20 @@ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementIn protected $orderRepository; /** - * @var \Magento\Framework\DataObject\Copy + * @deprecated */ protected $objectCopyService; /** - * @var \Magento\Quote\Model\Quote\AddressFactory + * @var QuoteAddressFactory */ private $quoteAddressFactory; + /** + * @var OrderCustomerExtractor + */ + private $customerExtractor; + /** * @param \Magento\Framework\DataObject\Copy $objectCopyService * @param \Magento\Customer\Api\AccountManagementInterface $accountManagement @@ -57,7 +68,8 @@ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementIn * @param \Magento\Customer\Api\Data\AddressInterfaceFactory $addressFactory * @param \Magento\Customer\Api\Data\RegionInterfaceFactory $regionFactory * @param \Magento\Sales\Api\OrderRepositoryInterface $orderRepository - * @param \Magento\Quote\Model\Quote\AddressFactory|null $quoteAddressFactory + * @param QuoteAddressFactory|null $quoteAddressFactory + * @param OrderCustomerExtractor|null $orderCustomerExtractor */ public function __construct( \Magento\Framework\DataObject\Copy $objectCopyService, @@ -66,7 +78,8 @@ public function __construct( \Magento\Customer\Api\Data\AddressInterfaceFactory $addressFactory, \Magento\Customer\Api\Data\RegionInterfaceFactory $regionFactory, \Magento\Sales\Api\OrderRepositoryInterface $orderRepository, - \Magento\Quote\Model\Quote\AddressFactory $quoteAddressFactory = null + QuoteAddressFactory $quoteAddressFactory = null, + OrderCustomerExtractor $orderCustomerExtractor = null ) { $this->objectCopyService = $objectCopyService; $this->accountManagement = $accountManagement; @@ -74,9 +87,10 @@ public function __construct( $this->customerFactory = $customerFactory; $this->addressFactory = $addressFactory; $this->regionFactory = $regionFactory; - $this->quoteAddressFactory = $quoteAddressFactory ?: ObjectManager::getInstance()->get( - \Magento\Quote\Model\Quote\AddressFactory::class - ); + $this->quoteAddressFactory = $quoteAddressFactory + ?: ObjectManager::getInstance()->get(QuoteAddressFactory::class); + $this->customerExtractor = $orderCustomerExtractor + ?? ObjectManager::getInstance()->get(OrderCustomerExtractor::class); } /** @@ -86,50 +100,23 @@ public function create($orderId) { $order = $this->orderRepository->get($orderId); if ($order->getCustomerId()) { - throw new AlreadyExistsException(__("This order already has associated customer account")); - } - $customerData = $this->objectCopyService->copyFieldsetToTarget( - 'order_address', - 'to_customer', - $order->getBillingAddress(), - [] - ); - $addresses = $order->getAddresses(); - foreach ($addresses as $address) { - if (!$this->isNeededToSaveAddress($address->getData('quote_address_id'))) { - continue; - } - $addressData = $this->objectCopyService->copyFieldsetToTarget( - 'order_address', - 'to_customer_address', - $address, - [] + throw new AlreadyExistsException( + __('This order already has associated customer account') ); - /** @var \Magento\Customer\Api\Data\AddressInterface $customerAddress */ - $customerAddress = $this->addressFactory->create(['data' => $addressData]); - switch ($address->getAddressType()) { - case QuoteAddress::ADDRESS_TYPE_BILLING: - $customerAddress->setIsDefaultBilling(true); - break; - case QuoteAddress::ADDRESS_TYPE_SHIPPING: - $customerAddress->setIsDefaultShipping(true); - break; - } + } - if (is_string($address->getRegion())) { - /** @var \Magento\Customer\Api\Data\RegionInterface $region */ - $region = $this->regionFactory->create(); - $region->setRegion($address->getRegion()); - $region->setRegionCode($address->getRegionCode()); - $region->setRegionId($address->getRegionId()); - $customerAddress->setRegion($region); + $customer = $this->customerExtractor->extract($orderId); + /** @var AddressInterface[] $filteredAddresses */ + $filteredAddresses = []; + foreach ($customer->getAddresses() as $address) { + if ($this->needToSaveAddress($order, $address)) { + $filteredAddresses[] = $address; } - $customerData['addresses'][] = $customerAddress; } + $customer->setAddresses($filteredAddresses); - /** @var \Magento\Customer\Api\Data\CustomerInterface $customer */ - $customer = $this->customerFactory->create(['data' => $customerData]); $account = $this->accountManagement->createAccount($customer); + $order = $this->orderRepository->get($orderId); $order->setCustomerId($account->getId()); $order->setCustomerIsGuest(0); $this->orderRepository->save($order); @@ -138,21 +125,36 @@ public function create($orderId) } /** - * Check if we need to save address in address book. - * - * @param int $quoteAddressId + * @param OrderInterface $order + * @param AddressInterface $address * * @return bool */ - private function isNeededToSaveAddress($quoteAddressId) - { - $saveInAddressBook = true; + private function needToSaveAddress( + OrderInterface $order, + AddressInterface $address + ): bool { + /** @var OrderAddressInterface|null $orderAddress */ + $orderAddress = null; + if ($address->isDefaultBilling()) { + $orderAddress = $order->getBillingAddress(); + } elseif ($address->isDefaultShipping()) { + $orderAddress = $order->getShippingAddress(); + } + if ($orderAddress) { + $quoteAddressId = $orderAddress->getData('quote_address_id'); + if ($quoteAddressId) { + /** @var QuoteAddress $quote */ + $quote = $this->quoteAddressFactory->create() + ->load($quoteAddressId); + if ($quote && $quote->getId()) { + return (bool)(int)$quote->getData('save_in_address_book'); + } + } - $quoteAddress = $this->quoteAddressFactory->create()->load($quoteAddressId); - if ($quoteAddress && $quoteAddress->getId()) { - $saveInAddressBook = (int)$quoteAddress->getData('save_in_address_book'); + return true; } - return $saveInAddressBook; + return false; } } diff --git a/app/code/Magento/Sales/Model/Order/Email/Container/IdentityInterface.php b/app/code/Magento/Sales/Model/Order/Email/Container/IdentityInterface.php index 55ce24e23e1ec..1d481a8acee65 100644 --- a/app/code/Magento/Sales/Model/Order/Email/Container/IdentityInterface.php +++ b/app/code/Magento/Sales/Model/Order/Email/Container/IdentityInterface.php @@ -3,7 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -namespace Magento\Sales\Model\Order\Email\Container; + +namespace Magento\Sales\Model\Order\Email\Container; use Magento\Store\Model\Store; diff --git a/app/code/Magento/Sales/Model/Order/Email/Container/NullIdentity.php b/app/code/Magento/Sales/Model/Order/Email/Container/NullIdentity.php new file mode 100644 index 0000000000000..22348aa9ee2f6 --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/Email/Container/NullIdentity.php @@ -0,0 +1,59 @@ + $this->getFormattedShippingAddress($order), 'formattedBillingAddress' => $this->getFormattedBillingAddress($order), ]; - $transport = new DataObject($transport); + $transportObject = new DataObject($transport); + /** + * Event argument `transport` is @deprecated. Use `transportObject` instead. + */ $this->eventManager->dispatch( 'email_order_set_template_vars_before', - ['sender' => $this, 'transport' => $transport] + ['sender' => $this, 'transport' => $transportObject->getData(), 'transportObject' => $transportObject] ); - $this->templateContainer->setTemplateVars($transport->getData()); + $this->templateContainer->setTemplateVars($transportObject->getData()); parent::prepareTemplate($order); } diff --git a/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php b/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php index 858490132e0c7..9ad06cea775e8 100644 --- a/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php +++ b/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php @@ -7,6 +7,7 @@ use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Sales\Api\Data\InvoiceCommentInterface; @@ -14,7 +15,13 @@ use Magento\Sales\Api\Data\InvoiceCommentSearchResultInterfaceFactory; use Magento\Sales\Api\InvoiceCommentRepositoryInterface; use Magento\Sales\Model\Spi\InvoiceCommentResourceInterface; +use Magento\Sales\Model\Order\Email\Sender\InvoiceCommentSender; +use Magento\Sales\Api\InvoiceRepositoryInterface; +use Psr\Log\LoggerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CommentRepository implements InvoiceCommentRepositoryInterface { /** @@ -37,22 +44,48 @@ class CommentRepository implements InvoiceCommentRepositoryInterface */ private $collectionProcessor; + /** + * @var InvoiceCommentSender + */ + private $invoiceCommentSender; + + /** + * @var InvoiceRepositoryInterface + */ + private $invoiceRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param InvoiceCommentResourceInterface $commentResource * @param InvoiceCommentInterfaceFactory $commentFactory * @param InvoiceCommentSearchResultInterfaceFactory $searchResultFactory * @param CollectionProcessorInterface $collectionProcessor + * @param InvoiceCommentSender|null $invoiceCommentSender + * @param InvoiceRepositoryInterface|null $invoiceRepository + * @param LoggerInterface|null $logger */ public function __construct( InvoiceCommentResourceInterface $commentResource, InvoiceCommentInterfaceFactory $commentFactory, InvoiceCommentSearchResultInterfaceFactory $searchResultFactory, - CollectionProcessorInterface $collectionProcessor + CollectionProcessorInterface $collectionProcessor, + InvoiceCommentSender $invoiceCommentSender = null, + InvoiceRepositoryInterface $invoiceRepository = null, + LoggerInterface $logger = null ) { $this->commentResource = $commentResource; $this->commentFactory = $commentFactory; $this->searchResultFactory = $searchResultFactory; $this->collectionProcessor = $collectionProcessor; + $this->invoiceCommentSender = $invoiceCommentSender + ?:ObjectManager::getInstance()->get(InvoiceCommentSender::class); + $this->invoiceRepository = $invoiceRepository + ?:ObjectManager::getInstance()->get(InvoiceRepositoryInterface::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -99,6 +132,14 @@ public function save(InvoiceCommentInterface $entity) } catch (\Exception $e) { throw new CouldNotSaveException(__('Could not save the invoice comment.'), $e); } + + try { + $invoice = $this->invoiceRepository->get($entity->getParentId()); + $this->invoiceCommentSender->send($invoice, $entity->getIsCustomerNotified(), $entity->getComment()); + } catch (\Exception $exception) { + $this->logger->critical($exception); + } + return $entity; } } diff --git a/app/code/Magento/Sales/Model/Order/ItemRepository.php b/app/code/Magento/Sales/Model/Order/ItemRepository.php index f1bbb7d39469b..7916eb9db2b80 100644 --- a/app/code/Magento/Sales/Model/Order/ItemRepository.php +++ b/app/code/Magento/Sales/Model/Order/ItemRepository.php @@ -117,6 +117,7 @@ public function get($id) } $this->addProductOption($orderItem); + $this->addParentItem($orderItem); $this->registry[$id] = $orderItem; } return $this->registry[$id]; @@ -216,6 +217,20 @@ protected function addProductOption(OrderItemInterface $orderItem) return $this; } + /** + * Set parent item. + * + * @param OrderItemInterface $orderItem + * @throws InputException + * @throws NoSuchEntityException + */ + private function addParentItem(OrderItemInterface $orderItem) + { + if ($parentId = $orderItem->getParentItemId()) { + $orderItem->setParentItem($this->get($parentId)); + } + } + /** * Set product options data * diff --git a/app/code/Magento/Sales/Model/Order/OrderCustomerDelegate.php b/app/code/Magento/Sales/Model/Order/OrderCustomerDelegate.php new file mode 100644 index 0000000000000..5d0cd4f37df5a --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/OrderCustomerDelegate.php @@ -0,0 +1,54 @@ +customerExtractor = $customerExtractor; + $this->delegateService = $delegateService; + } + + /** + * {@inheritdoc} + */ + public function delegateNew(int $orderId): Redirect + { + return $this->delegateService->createRedirectForNew( + $this->customerExtractor->extract($orderId), + ['__sales_assign_order_id' => $orderId] + ); + } +} diff --git a/app/code/Magento/Sales/Model/Order/OrderCustomerExtractor.php b/app/code/Magento/Sales/Model/Order/OrderCustomerExtractor.php new file mode 100644 index 0000000000000..2a93f389e569f --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/OrderCustomerExtractor.php @@ -0,0 +1,142 @@ +orderRepository = $orderRepository; + $this->customerRepository = $customerRepository; + $this->objectCopyService = $objectCopyService; + $this->addressFactory = $addressFactory; + $this->regionFactory = $regionFactory; + $this->customerFactory = $customerFactory; + $this->quoteAddressFactory = $quoteAddressFactory; + } + + /** + * @param int $orderId + * + * @return CustomerInterface + */ + public function extract(int $orderId): CustomerInterface + { + $order = $this->orderRepository->get($orderId); + + //Simply return customer from DB. + if ($order->getCustomerId()) { + return $this->customerRepository->getById($order->getCustomerId()); + } + + //Prepare customer data from order data if customer doesn't exist yet. + $customerData = $this->objectCopyService->copyFieldsetToTarget( + 'order_address', + 'to_customer', + $order->getBillingAddress(), + [] + ); + $addresses = $order->getAddresses(); + foreach ($addresses as $address) { + $addressData = $this->objectCopyService->copyFieldsetToTarget( + 'order_address', + 'to_customer_address', + $address, + [] + ); + /** @var AddressInterface $customerAddress */ + $customerAddress = $this->addressFactory->create(['data' => $addressData]); + switch ($address->getAddressType()) { + case QuoteAddress::ADDRESS_TYPE_BILLING: + $customerAddress->setIsDefaultBilling(true); + break; + case QuoteAddress::ADDRESS_TYPE_SHIPPING: + $customerAddress->setIsDefaultShipping(true); + break; + } + + if (is_string($address->getRegion())) { + /** @var RegionInterface $region */ + $region = $this->regionFactory->create(); + $region->setRegion($address->getRegion()); + $region->setRegionCode($address->getRegionCode()); + $region->setRegionId($address->getRegionId()); + $customerAddress->setRegion($region); + } + $customerData['addresses'][] = $customerAddress; + } + + return $this->customerFactory->create(['data' => $customerData]); + } +} diff --git a/app/code/Magento/Sales/Model/Order/Payment/State/RegisterCaptureNotificationCommand.php b/app/code/Magento/Sales/Model/Order/Payment/State/RegisterCaptureNotificationCommand.php index ee12b459118c1..d38e58d7341c1 100644 --- a/app/code/Magento/Sales/Model/Order/Payment/State/RegisterCaptureNotificationCommand.php +++ b/app/code/Magento/Sales/Model/Order/Payment/State/RegisterCaptureNotificationCommand.php @@ -35,7 +35,7 @@ public function __construct(StatusResolver $statusResolver = null) */ public function execute(OrderPaymentInterface $payment, $amount, OrderInterface $order) { - $state = Order::STATE_PROCESSING; + $state = $order->getState() ?: Order::STATE_PROCESSING; $status = null; $message = 'Registered notification about captured amount of %1.'; diff --git a/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php b/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php index 57cc7021b79bc..401fdcd2b04ac 100644 --- a/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php +++ b/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php @@ -861,7 +861,7 @@ protected function _drawItem( protected function _setFontRegular($object, $size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Re-4.4.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerif.ttf') ); $object->setFont($font, $size); return $font; @@ -877,7 +877,7 @@ protected function _setFontRegular($object, $size = 7) protected function _setFontBold($object, $size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Bd-2.8.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifBold.ttf') ); $object->setFont($font, $size); return $font; @@ -893,7 +893,7 @@ protected function _setFontBold($object, $size = 7) protected function _setFontItalic($object, $size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_It-2.8.2.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifItalic.ttf') ); $object->setFont($font, $size); return $font; diff --git a/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php b/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php index c0d7a43bed862..422ff1746c9a6 100644 --- a/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php +++ b/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php @@ -338,7 +338,7 @@ public function getItemOptions() protected function _setFontRegular($size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Re-4.4.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerif.ttf') ); $this->getPage()->setFont($font, $size); return $font; @@ -353,7 +353,7 @@ protected function _setFontRegular($size = 7) protected function _setFontBold($size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Bd-2.8.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifBold.ttf') ); $this->getPage()->setFont($font, $size); return $font; @@ -368,7 +368,7 @@ protected function _setFontBold($size = 7) protected function _setFontItalic($size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_It-2.8.2.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifItalic.ttf') ); $this->getPage()->setFont($font, $size); return $font; diff --git a/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php b/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php index b0d3b03aec4ed..8efc9f8461914 100644 --- a/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php +++ b/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php @@ -7,6 +7,7 @@ use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Sales\Api\Data\ShipmentCommentInterface; @@ -14,7 +15,13 @@ use Magento\Sales\Api\Data\ShipmentCommentSearchResultInterfaceFactory; use Magento\Sales\Api\ShipmentCommentRepositoryInterface; use Magento\Sales\Model\Spi\ShipmentCommentResourceInterface; +use Magento\Sales\Model\Order\Email\Sender\ShipmentCommentSender; +use Magento\Sales\Api\ShipmentRepositoryInterface; +use Psr\Log\LoggerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CommentRepository implements ShipmentCommentRepositoryInterface { /** @@ -37,22 +44,48 @@ class CommentRepository implements ShipmentCommentRepositoryInterface */ private $collectionProcessor; + /** + * @var ShipmentCommentSender + */ + private $shipmentCommentSender; + + /** + * @var ShipmentRepositoryInterface + */ + private $shipmentRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param ShipmentCommentResourceInterface $commentResource * @param ShipmentCommentInterfaceFactory $commentFactory * @param ShipmentCommentSearchResultInterfaceFactory $searchResultFactory * @param CollectionProcessorInterface $collectionProcessor + * @param ShipmentCommentSender|null $shipmentCommentSender + * @param ShipmentRepositoryInterface|null $shipmentRepository + * @param LoggerInterface|null $logger */ public function __construct( ShipmentCommentResourceInterface $commentResource, ShipmentCommentInterfaceFactory $commentFactory, ShipmentCommentSearchResultInterfaceFactory $searchResultFactory, - CollectionProcessorInterface $collectionProcessor + CollectionProcessorInterface $collectionProcessor, + ShipmentCommentSender $shipmentCommentSender = null, + ShipmentRepositoryInterface $shipmentRepository = null, + LoggerInterface $logger = null ) { $this->commentResource = $commentResource; $this->commentFactory = $commentFactory; $this->searchResultFactory = $searchResultFactory; $this->collectionProcessor = $collectionProcessor; + $this->shipmentCommentSender = $shipmentCommentSender + ?: ObjectManager::getInstance()->get(ShipmentCommentSender::class); + $this->shipmentRepository = $shipmentRepository + ?: ObjectManager::getInstance()->get(ShipmentRepositoryInterface::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -99,6 +132,14 @@ public function save(ShipmentCommentInterface $entity) } catch (\Exception $e) { throw new CouldNotSaveException(__('Could not save the shipment comment.'), $e); } + + try { + $shipment = $this->shipmentRepository->get($entity->getParentId()); + $this->shipmentCommentSender->send($shipment, $entity->getIsCustomerNotified(), $entity->getComment()); + } catch (\Exception $exception) { + $this->logger->critical($exception); + } + return $entity; } } diff --git a/app/code/Magento/Sales/Model/Order/ShipmentFactory.php b/app/code/Magento/Sales/Model/Order/ShipmentFactory.php index 642f8647ef56b..21b42abeb293d 100644 --- a/app/code/Magento/Sales/Model/Order/ShipmentFactory.php +++ b/app/code/Magento/Sales/Model/Order/ShipmentFactory.php @@ -5,13 +5,13 @@ */ namespace Magento\Sales\Model\Order; -use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; -use Magento\Sales\Model\Order\Shipment\ShipmentValidatorInterface; use Magento\Framework\Serialize\Serializer\Json; /** * Factory class for @see \Magento\Sales\Api\Data\ShipmentInterface + * + * @api */ class ShipmentFactory { diff --git a/app/code/Magento/Sales/Model/OrderIncrementIdChecker.php b/app/code/Magento/Sales/Model/OrderIncrementIdChecker.php new file mode 100644 index 0000000000000..0af61835a9c9b --- /dev/null +++ b/app/code/Magento/Sales/Model/OrderIncrementIdChecker.php @@ -0,0 +1,52 @@ +resourceModel = $resourceModel; + } + + /** + * Check if order increment ID is already used. + * + * Method can be used to avoid collisions of order IDs. + * + * @param string|int $orderIncrementId + * @return bool + */ + public function isIncrementIdUsed($orderIncrementId): bool + { + /** @var \Magento\Framework\DB\Adapter\AdapterInterface $adapter */ + $adapter = $this->resourceModel->getConnection(); + $bind = [':increment_id' => $orderIncrementId]; + /** @var \Magento\Framework\DB\Select $select */ + $select = $adapter->select(); + $select->from($this->resourceModel->getMainTable(), $this->resourceModel->getIdFieldName()) + ->where('increment_id = :increment_id'); + $entity_id = $adapter->fetchOne($select, $bind); + if ($entity_id > 0) { + return true; + } + + return false; + } +} diff --git a/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php b/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php index 1b781890e0f7f..80612277e68d5 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php +++ b/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php @@ -123,10 +123,15 @@ protected function _beforeSave(\Magento\Framework\Model\AbstractModel $object) { /** @var \Magento\Sales\Model\AbstractModel $object */ if ($object instanceof EntityInterface && $object->getIncrementId() == null) { + $store = $object->getStore(); + $storeId = $store->getId(); + if ($storeId === null) { + $storeId = $store->getGroup()->getDefaultStoreId(); + } $object->setIncrementId( $this->sequenceManager->getSequence( $object->getEntityType(), - $object->getStore()->getGroup()->getDefaultStoreId() + $storeId )->getNextValue() ); } diff --git a/app/code/Magento/Sales/Model/ResourceModel/Grid.php b/app/code/Magento/Sales/Model/ResourceModel/Grid.php index b3425baf1e727..42e5d92fb00cc 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Grid.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Grid.php @@ -45,6 +45,11 @@ class Grid extends AbstractGrid */ private $notSyncedDataProvider; + /** + * Order grid rows batch size + */ + const BATCH_SIZE = 100; + /** * @param Context $context * @param string $mainTableName @@ -104,25 +109,20 @@ public function refresh($value, $field = null) * * Only orders created/updated since the last method call will be added. * - * @return \Zend_Db_Statement_Interface + * @return void */ public function refreshBySchedule() { - $select = $this->getGridOriginSelect() - ->where( - $this->mainTableName . '.entity_id IN (?)', - $this->notSyncedDataProvider->getIds($this->mainTableName, $this->gridTableName) + $notSyncedIds = $this->notSyncedDataProvider->getIds($this->mainTableName, $this->gridTableName); + foreach (array_chunk($notSyncedIds, self::BATCH_SIZE) as $bunch) { + $select = $this->getGridOriginSelect()->where($this->mainTableName . '.entity_id IN (?)', $bunch); + $fetchResult = $this->getConnection()->fetchAll($select); + $this->getConnection()->insertOnDuplicate( + $this->getTable($this->gridTableName), + $fetchResult, + array_keys($this->columns) ); - - return $this->getConnection()->query( - $this->getConnection() - ->insertFromSelect( - $select, - $this->getTable($this->gridTableName), - array_keys($this->columns), - AdapterInterface::INSERT_ON_DUPLICATE - ) - ); + } } /** diff --git a/app/code/Magento/Sales/Model/ResourceModel/GridInterface.php b/app/code/Magento/Sales/Model/ResourceModel/GridInterface.php index 9b0e53347e21a..6c7d983f066e5 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/GridInterface.php +++ b/app/code/Magento/Sales/Model/ResourceModel/GridInterface.php @@ -29,7 +29,7 @@ public function refresh($value, $field = null); * * Only rows created/updated since the last method call should be added. * - * @return \Zend_Db_Statement_Interface + * @return void */ public function refreshBySchedule(); diff --git a/app/code/Magento/Sales/Model/Service/OrderService.php b/app/code/Magento/Sales/Model/Service/OrderService.php index 1eb3fad11278f..e4a71f028cc82 100644 --- a/app/code/Magento/Sales/Model/Service/OrderService.php +++ b/app/code/Magento/Sales/Model/Service/OrderService.php @@ -6,6 +6,7 @@ namespace Magento\Sales\Model\Service; use Magento\Sales\Api\OrderManagementInterface; +use Magento\Payment\Gateway\Command\CommandException; /** * Class OrderService @@ -49,6 +50,11 @@ class OrderService implements OrderManagementInterface */ protected $orderCommentSender; + /** + * @var \Magento\Sales\Api\PaymentFailuresInterface + */ + private $paymentFailures; + /** * Constructor * @@ -59,6 +65,7 @@ class OrderService implements OrderManagementInterface * @param \Magento\Sales\Model\OrderNotifier $notifier * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Sales\Model\Order\Email\Sender\OrderCommentSender $orderCommentSender + * @param \Magento\Sales\Api\PaymentFailuresInterface|null $paymentFailures */ public function __construct( \Magento\Sales\Api\OrderRepositoryInterface $orderRepository, @@ -67,7 +74,8 @@ public function __construct( \Magento\Framework\Api\FilterBuilder $filterBuilder, \Magento\Sales\Model\OrderNotifier $notifier, \Magento\Framework\Event\ManagerInterface $eventManager, - \Magento\Sales\Model\Order\Email\Sender\OrderCommentSender $orderCommentSender + \Magento\Sales\Model\Order\Email\Sender\OrderCommentSender $orderCommentSender, + \Magento\Sales\Api\PaymentFailuresInterface $paymentFailures = null ) { $this->orderRepository = $orderRepository; $this->historyRepository = $historyRepository; @@ -76,6 +84,8 @@ public function __construct( $this->notifier = $notifier; $this->eventManager = $eventManager; $this->orderCommentSender = $orderCommentSender; + $this->paymentFailures = $paymentFailures ? : \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Sales\Api\PaymentFailuresInterface::class); } /** @@ -192,6 +202,9 @@ public function place(\Magento\Sales\Api\Data\OrderInterface $order) return $this->orderRepository->save($order); //commit } catch (\Exception $e) { + if ($e instanceof CommandException) { + $this->paymentFailures->handle((int)$order->getQuoteId(), __($e->getMessage())); + } throw $e; //rollback; } diff --git a/app/code/Magento/Sales/Model/Service/PaymentFailuresService.php b/app/code/Magento/Sales/Model/Service/PaymentFailuresService.php new file mode 100644 index 0000000000000..3a49bbce256ef --- /dev/null +++ b/app/code/Magento/Sales/Model/Service/PaymentFailuresService.php @@ -0,0 +1,294 @@ + Configuration > Sales > Checkout > Payment Failed Emails configuration. + */ +class PaymentFailuresService implements PaymentFailuresInterface +{ + /** + * Store config + * + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var StateInterface + */ + private $inlineTranslation; + + /** + * @var TransportBuilder + */ + private $transportBuilder; + + /** + * @var TimezoneInterface + */ + private $localeDate; + + /** + * @var CartRepositoryInterface + */ + private $cartRepository; + + /** + * @param ScopeConfigInterface $scopeConfig + * @param StateInterface $inlineTranslation + * @param TransportBuilder $transportBuilder + * @param TimezoneInterface $localeDate + * @param CartRepositoryInterface $cartRepository + */ + public function __construct( + ScopeConfigInterface $scopeConfig, + StateInterface $inlineTranslation, + TransportBuilder $transportBuilder, + TimezoneInterface $localeDate, + CartRepositoryInterface $cartRepository + ) { + $this->scopeConfig = $scopeConfig; + $this->inlineTranslation = $inlineTranslation; + $this->transportBuilder = $transportBuilder; + $this->localeDate = $localeDate; + $this->cartRepository = $cartRepository; + } + + /** + * Sends an email about failed transaction. + * + * @param int $cartId + * @param string $message + * @param string $checkoutType + * @return PaymentFailuresInterface + */ + public function handle( + int $cartId, + string $message, + string $checkoutType = 'onepage' + ): PaymentFailuresInterface { + $this->inlineTranslation->suspend(); + $quote = $this->cartRepository->get($cartId); + + $template = $this->getConfigValue('checkout/payment_failed/template', $quote); + $receiver = $this->getConfigValue('checkout/payment_failed/receiver', $quote); + $sendTo = [ + [ + 'email' => $this->getConfigValue('trans_email/ident_' . $receiver . '/email', $quote), + 'name' => $this->getConfigValue('trans_email/ident_' . $receiver . '/name', $quote), + ], + ]; + + $copyMethod = $this->getConfigValue('checkout/payment_failed/copy_method', $quote); + $copyTo = $this->getConfigEmails($quote); + + $bcc = []; + if (!empty($copyTo)) { + switch ($copyMethod) { + case 'bcc': + $bcc = $copyTo; + break; + case 'copy': + foreach ($copyTo as $email) { + $sendTo[] = ['email' => $email, 'name' => null]; + } + break; + } + } + + foreach ($sendTo as $recipient) { + $transport = $this->transportBuilder + ->setTemplateIdentifier($template) + ->setTemplateOptions([ + 'area' => FrontNameResolver::AREA_CODE, + 'store' => Store::DEFAULT_STORE_ID, + ]) + ->setTemplateVars($this->getTemplateVars($quote, $message, $checkoutType)) + ->setFrom($this->getSendFrom($quote)) + ->addTo($recipient['email'], $recipient['name']) + ->addBcc($bcc) + ->getTransport(); + + $transport->sendMessage(); + } + + $this->inlineTranslation->resume(); + + return $this; + } + + /** + * Returns mail template variables. + * + * @param Quote $quote + * @param string $message + * @param string $checkoutType + * @return array + */ + private function getTemplateVars(Quote $quote, string $message, string $checkoutType): array + { + return [ + 'reason' => $message, + 'checkoutType' => $checkoutType, + 'dateAndTime' => $this->getLocaleDate(), + 'customer' => $this->getCustomerName($quote), + 'customerEmail' => $quote->getBillingAddress()->getEmail(), + 'billingAddress' => $quote->getBillingAddress(), + 'shippingAddress' => $quote->getShippingAddress(), + 'shippingMethod' => $this->getConfigValue( + 'carriers/' . $this->getShippingMethod($quote) . '/title', + $quote + ), + 'paymentMethod' => $this->getConfigValue( + 'payment/' . $this->getPaymentMethod($quote) . '/title', + $quote + ), + 'items' => implode('
    ', $this->getQuoteItems($quote)), + 'total' => $quote->getCurrency()->getStoreCurrencyCode() . ' ' . $quote->getGrandTotal(), + ]; + } + + /** + * Returns scope config value by config path. + * + * @param string $configPath + * @param Quote $quote + * @return mixed + */ + private function getConfigValue(string $configPath, Quote $quote) + { + return $this->scopeConfig->getValue( + $configPath, + ScopeInterface::SCOPE_STORE, + $quote->getStoreId() + ); + } + + /** + * Returns shipping method from quote. + * + * @param Quote $quote + * @return string + */ + private function getShippingMethod(Quote $quote): string + { + $shippingMethod = ''; + $shippingInfo = $quote->getShippingAddress()->getShippingMethod(); + + if ($shippingInfo) { + $data = explode('_', $shippingInfo); + $shippingMethod = $data[0]; + } + + return $shippingMethod; + } + + /** + * Returns payment method title from quote. + * + * @param Quote $quote + * @return string + */ + private function getPaymentMethod(Quote $quote): string + { + $paymentMethod = $quote->getPayment()->getMethod() ?? ''; + + return $paymentMethod; + } + + /** + * Returns quote visible items. + * + * @param Quote $quote + * @return array + */ + private function getQuoteItems(Quote $quote): array + { + $items = []; + foreach ($quote->getAllVisibleItems() as $item) { + $itemData = $item->getProduct()->getName() . ' x ' . $item->getQty() . ' ' . + $quote->getCurrency()->getStoreCurrencyCode() . ' ' . + $item->getProduct()->getFinalPrice($item->getQty()); + $items[] = $itemData; + } + + return $items; + } + + /** + * Gets email values by configuration path. + * + * @param Quote $quote + * @return array|false + */ + private function getConfigEmails(Quote $quote) + { + $configData = $this->getConfigValue('checkout/payment_failed/copy_to', $quote); + if (!empty($configData)) { + return explode(',', $configData); + } + + return false; + } + + /** + * Returns sender identity. + * + * @param Quote $quote + * @return string + */ + private function getSendFrom(Quote $quote): string + { + return $this->getConfigValue('checkout/payment_failed/identity', $quote); + } + + /** + * Returns current locale date and time + * + * @return string + */ + private function getLocaleDate(): string + { + return $this->localeDate->formatDateTime( + new \DateTime(), + \IntlDateFormatter::MEDIUM, + \IntlDateFormatter::MEDIUM + ); + } + + /** + * Returns customer name. + * + * @param Quote $quote + * @return string + */ + private function getCustomerName(Quote $quote): string + { + $customer = __('Guest')->render(); + if (!$quote->getCustomerIsGuest()) { + $customer = $quote->getCustomer()->getFirstname() . ' ' . + $quote->getCustomer()->getLastname(); + } + + return $customer; + } +} diff --git a/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php b/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php new file mode 100644 index 0000000000000..cade86d18e935 --- /dev/null +++ b/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php @@ -0,0 +1,54 @@ +orderRepository = $orderRepository; + } + + /** + * {@inheritdoc} + */ + public function execute(Observer $observer) + { + $event = $observer->getEvent(); + /** @var CustomerInterface $customer */ + $customer = $event->getData('customer_data_object'); + /** @var array $delegateData */ + $delegateData = $event->getData('delegate_data'); + if (array_key_exists('__sales_assign_order_id', $delegateData)) { + $orderId = $delegateData['__sales_assign_order_id']; + $order = $this->orderRepository->get($orderId); + if (!$order->getCustomerId()) { + //if customer ID wasn't already assigned then assigning. + $order->setCustomerId($customer->getId()); + $order->setCustomerIsGuest(0); + $this->orderRepository->save($order); + } + } + } +} diff --git a/app/code/Magento/Sales/Observer/Backend/SubtractQtyFromQuotesObserver.php b/app/code/Magento/Sales/Observer/Backend/SubtractQtyFromQuotesObserver.php index 775a7dab95cfe..cd8c705750d6c 100644 --- a/app/code/Magento/Sales/Observer/Backend/SubtractQtyFromQuotesObserver.php +++ b/app/code/Magento/Sales/Observer/Backend/SubtractQtyFromQuotesObserver.php @@ -31,6 +31,6 @@ public function __construct(\Magento\Quote\Model\ResourceModel\Quote $quote) public function execute(\Magento\Framework\Event\Observer $observer) { $product = $observer->getEvent()->getProduct(); - $this->_quote->substractProductFromQuotes($product); + $this->_quote->subtractProductFromQuotes($product); } } diff --git a/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php b/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php index 0ad2245a6287e..d3e13334bb3f9 100644 --- a/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php +++ b/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php @@ -93,27 +93,27 @@ public function apply() public function fillQuoteAddressIdInSalesOrderAddress() { $addressCollection = $this->addressCollectionFactory->create(); + $addressCollection->addFieldToFilter('quote_address_id', ['null' => true]); + /** @var \Magento\Sales\Model\Order\Address $orderAddress */ foreach ($addressCollection as $orderAddress) { - if (!$orderAddress->getData('quote_address_id')) { - $orderId = $orderAddress->getParentId(); - $addressType = $orderAddress->getAddressType(); - - /** @var \Magento\Sales\Model\Order $order */ - $order = $this->orderFactory->create()->load($orderId); - $quoteId = $order->getQuoteId(); - $quote = $this->quoteFactory->create()->load($quoteId); - - if ($addressType == \Magento\Sales\Model\Order\Address::TYPE_SHIPPING) { - $quoteAddressId = $quote->getShippingAddress()->getId(); - $orderAddress->setData('quote_address_id', $quoteAddressId); - } elseif ($addressType == \Magento\Sales\Model\Order\Address::TYPE_BILLING) { - $quoteAddressId = $quote->getBillingAddress()->getId(); - $orderAddress->setData('quote_address_id', $quoteAddressId); - } - - $orderAddress->save(); + $orderId = $orderAddress->getParentId(); + $addressType = $orderAddress->getAddressType(); + + /** @var \Magento\Sales\Model\Order $order */ + $order = $this->orderFactory->create()->load($orderId); + $quoteId = $order->getQuoteId(); + $quote = $this->quoteFactory->create()->load($quoteId); + + if ($addressType == \Magento\Sales\Model\Order\Address::TYPE_SHIPPING) { + $quoteAddressId = $quote->getShippingAddress()->getId(); + $orderAddress->setData('quote_address_id', $quoteAddressId); + } elseif ($addressType == \Magento\Sales\Model\Order\Address::TYPE_BILLING) { + $quoteAddressId = $quote->getBillingAddress()->getId(); + $orderAddress->setData('quote_address_id', $quoteAddressId); } + + $orderAddress->save(); } } diff --git a/app/code/Magento/Sales/Setup/SalesSetup.php b/app/code/Magento/Sales/Setup/SalesSetup.php index bfc05c549ddb3..4be2b38b074e7 100644 --- a/app/code/Magento/Sales/Setup/SalesSetup.php +++ b/app/code/Magento/Sales/Setup/SalesSetup.php @@ -303,6 +303,9 @@ public function getEncryptor() return $this->encryptor; } + /** + * @return \Magento\Framework\DB\Adapter\AdapterInterface + */ public function getConnection() { return $this->getSetup()->getConnection(self::$connectionName); diff --git a/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php b/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php index 99528983a13c9..96162aca42e12 100644 --- a/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php +++ b/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php @@ -5,6 +5,15 @@ */ namespace Magento\Sales\Test\Unit\Block\Order; +use Magento\Framework\View\Element\Template\Context; +use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; +use Magento\Customer\Model\Session; +use Magento\Sales\Model\Order\Config; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\View\Layout; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Sales\Model\ResourceModel\Order\Collection; + class RecentTest extends \PHPUnit\Framework\TestCase { /** @@ -32,26 +41,33 @@ class RecentTest extends \PHPUnit\Framework\TestCase */ protected $orderConfig; + /** + * @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + protected $storeManagerMock; + protected function setUp() { - $this->context = $this->createMock(\Magento\Framework\View\Element\Template\Context::class); + $this->context = $this->createMock(Context::class); $this->orderCollectionFactory = $this->createPartialMock( - \Magento\Sales\Model\ResourceModel\Order\CollectionFactory::class, + CollectionFactory::class, ['create'] ); - $this->customerSession = $this->createPartialMock(\Magento\Customer\Model\Session::class, ['getCustomerId']); + $this->customerSession = $this->createPartialMock(Session::class, ['getCustomerId']); $this->orderConfig = $this->createPartialMock( - \Magento\Sales\Model\Order\Config::class, + Config::class, ['getVisibleOnFrontStatuses'] ); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); } public function testConstructMethod() { - $data = []; - $attribute = ['customer_id', 'status']; + $attribute = ['customer_id', 'store_id', 'status']; $customerId = 25; - $layout = $this->createPartialMock(\Magento\Framework\View\Layout::class, ['getBlock']); + $storeId = 4; + $layout = $this->createPartialMock(Layout::class, ['getBlock']); $this->context->expects($this->once()) ->method('getLayout') ->will($this->returnValue($layout)); @@ -64,14 +80,20 @@ public function testConstructMethod() ->method('getVisibleOnFrontStatuses') ->will($this->returnValue($statuses)); - $orderCollection = $this->createPartialMock(\Magento\Sales\Model\ResourceModel\Order\Collection::class, [ - 'addAttributeToSelect', - 'addFieldToFilter', - 'addAttributeToFilter', - 'addAttributeToSort', - 'setPageSize', - 'load' - ]); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); + $storeMock = $this->getMockBuilder(StoreInterface::class)->getMockForAbstractClass(); + $this->storeManagerMock->expects($this->once())->method('getStore')->willReturn($storeMock); + $storeMock->expects($this->any())->method('getId')->willReturn($storeId); + + $orderCollection = $this->createPartialMock(Collection::class, [ + 'addAttributeToSelect', + 'addFieldToFilter', + 'addAttributeToFilter', + 'addAttributeToSort', + 'setPageSize', + 'load' + ]); $this->orderCollectionFactory->expects($this->once()) ->method('create') ->will($this->returnValue($orderCollection)); @@ -85,17 +107,21 @@ public function testConstructMethod() ->willReturnSelf(); $orderCollection->expects($this->at(2)) ->method('addAttributeToFilter') - ->with($attribute[1], $this->equalTo(['in' => $statuses])) - ->will($this->returnSelf()); + ->with($attribute[1], $this->equalTo($storeId)) + ->willReturnSelf(); $orderCollection->expects($this->at(3)) + ->method('addAttributeToFilter') + ->with($attribute[2], $this->equalTo(['in' => $statuses])) + ->will($this->returnSelf()); + $orderCollection->expects($this->at(4)) ->method('addAttributeToSort') ->with('created_at', 'desc') ->will($this->returnSelf()); - $orderCollection->expects($this->at(4)) + $orderCollection->expects($this->at(5)) ->method('setPageSize') ->with('5') ->will($this->returnSelf()); - $orderCollection->expects($this->at(5)) + $orderCollection->expects($this->at(6)) ->method('load') ->will($this->returnSelf()); $this->block = new \Magento\Sales\Block\Order\Recent( @@ -103,7 +129,8 @@ public function testConstructMethod() $this->orderCollectionFactory, $this->customerSession, $this->orderConfig, - $data + [], + $this->storeManagerMock ); $this->assertEquals($orderCollection, $this->block->getOrders()); } diff --git a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php index 569a210d993f0..756bade3c83c9 100644 --- a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php +++ b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php @@ -85,6 +85,11 @@ class MassCancelTest extends \PHPUnit\Framework\TestCase */ protected $filterMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $orderManagementMock; + protected function setUp() { $objectManagerHelper = new ObjectManagerHelper($this); @@ -145,12 +150,15 @@ protected function setUp() $this->orderCollectionFactoryMock->expects($this->once()) ->method('create') ->willReturn($this->orderCollectionMock); + $this->orderManagementMock = $this->createMock(\Magento\Sales\Api\OrderManagementInterface::class); + $this->massAction = $objectManagerHelper->getObject( \Magento\Sales\Controller\Adminhtml\Order\MassCancel::class, [ 'context' => $this->contextMock, 'filter' => $this->filterMock, - 'collectionFactory' => $this->orderCollectionFactoryMock + 'collectionFactory' => $this->orderCollectionFactoryMock, + 'orderManagement' => $this->orderManagementMock ] ); } @@ -161,6 +169,9 @@ protected function setUp() */ public function testExecuteCanCancelOneOrder() { + $order1id = 100; + $order2id = 200; + $order1 = $this->getMockBuilder(\Magento\Sales\Model\Order::class) ->disableOriginalConstructor() ->getMock(); @@ -175,20 +186,19 @@ public function testExecuteCanCancelOneOrder() ->willReturn($orders); $order1->expects($this->once()) - ->method('canCancel') - ->willReturn(true); - $order1->expects($this->once()) - ->method('cancel'); - $order1->expects($this->once()) - ->method('save'); + ->method('getEntityId') + ->willReturn($order1id); + + $order2->expects($this->once()) + ->method('getEntityId') + ->willReturn($order2id); $this->orderCollectionMock->expects($this->once()) ->method('count') ->willReturn($countOrders); - $order2->expects($this->once()) - ->method('canCancel') - ->willReturn(false); + $this->orderManagementMock->expects($this->at(0))->method('cancel')->with($order1id)->willReturn(true); + $this->orderManagementMock->expects($this->at(1))->method('cancel')->with($order2id)->willReturn(false); $this->messageManagerMock->expects($this->once()) ->method('addError') @@ -222,21 +232,23 @@ public function testExcludedCannotCancelOrders() $orders = [$order1, $order2]; $countOrders = count($orders); + $order1->expects($this->once()) + ->method('getEntityId') + ->willReturn(100); + + $order2->expects($this->once()) + ->method('getEntityId') + ->willReturn(200); + $this->orderCollectionMock->expects($this->any()) ->method('getItems') ->willReturn([$order1, $order2]); - $order1->expects($this->once()) - ->method('canCancel') - ->willReturn(false); - $this->orderCollectionMock->expects($this->once()) ->method('count') ->willReturn($countOrders); - $order2->expects($this->once()) - ->method('canCancel') - ->willReturn(false); + $this->orderManagementMock->expects($this->atLeastOnce())->method('cancel')->willReturn(false); $this->messageManagerMock->expects($this->once()) ->method('addError') @@ -265,11 +277,10 @@ public function testException() ->willReturn([$order1]); $order1->expects($this->once()) - ->method('canCancel') - ->willReturn(true); - $order1->expects($this->once()) - ->method('cancel') - ->willThrowException($exception); + ->method('getEntityId') + ->willReturn(100); + + $this->orderManagementMock->expects($this->atLeastOnce())->method('cancel')->willThrowException($exception); $this->messageManagerMock->expects($this->once()) ->method('addError') diff --git a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php index 7003d445e1b50..cddb503925987 100644 --- a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php +++ b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php @@ -85,6 +85,11 @@ class MassUnholdTest extends \PHPUnit\Framework\TestCase */ protected $filterMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $orderManagementMock; + protected function setUp() { $objectManagerHelper = new ObjectManagerHelper($this); @@ -146,12 +151,15 @@ protected function setUp() ->method('create') ->willReturn($this->orderCollectionMock); + $this->orderManagementMock = $this->createMock(\Magento\Sales\Api\OrderManagementInterface::class); + $this->massAction = $objectManagerHelper->getObject( \Magento\Sales\Controller\Adminhtml\Order\MassUnhold::class, [ 'context' => $this->contextMock, 'filter' => $this->filterMock, - 'collectionFactory' => $this->orderCollectionFactoryMock + 'collectionFactory' => $this->orderCollectionFactoryMock, + 'orderManagement' => $this->orderManagementMock ] ); } @@ -175,9 +183,7 @@ public function testExecuteOneOrdersReleasedFromHold() ->method('canUnhold') ->willReturn(true); $order1->expects($this->once()) - ->method('unhold'); - $order1->expects($this->once()) - ->method('save'); + ->method('getEntityId'); $this->orderCollectionMock->expects($this->once()) ->method('count') @@ -187,6 +193,8 @@ public function testExecuteOneOrdersReleasedFromHold() ->method('canUnhold') ->willReturn(false); + $this->orderManagementMock->expects($this->atLeastOnce())->method('unHold')->willReturn(true); + $this->messageManagerMock->expects($this->once()) ->method('addError') ->with('1 order(s) were not released from on hold status.'); diff --git a/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php b/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php index 0802bafd5ac34..035d6ff2727d8 100644 --- a/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php +++ b/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php @@ -48,11 +48,21 @@ class LastOrderedItemsTest extends \PHPUnit\Framework\TestCase */ private $orderMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $productRepositoryMock; + /** * @var \Magento\Sales\CustomerData\LastOrderedItems */ private $section; + /** + * @var \Psr\Log\LoggerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $loggerMock; + protected function setUp() { $this->objectManagerHelper = new ObjectManagerHelper($this); @@ -74,62 +84,97 @@ protected function setUp() $this->orderMock = $this->getMockBuilder(\Magento\Sales\Model\Order::class) ->disableOriginalConstructor() ->getMock(); + $this->productRepositoryMock = $this->getMockBuilder(\Magento\Catalog\Api\ProductRepositoryInterface::class) + ->getMockForAbstractClass(); + $this->loggerMock = $this->getMockBuilder(\Psr\Log\LoggerInterface::class) + ->getMockForAbstractClass(); + $this->section = new \Magento\Sales\CustomerData\LastOrderedItems( $this->orderCollectionFactoryMock, $this->orderConfigMock, $this->customerSessionMock, $this->stockRegistryMock, - $this->storeManagerMock + $this->storeManagerMock, + $this->productRepositoryMock, + $this->loggerMock ); } public function testGetSectionData() { + $storeId = 1; $websiteId = 4; - $expectedItem = [ + $expectedItem1 = [ 'id' => 1, - 'name' => 'Product Name', + 'name' => 'Product Name 1', 'url' => 'http://example.com', 'is_saleable' => true, ]; - $productId = 10; + $expectedItem2 = [ + 'id' => 2, + 'name' => 'Product Name 2', + 'url' => null, + 'is_saleable' => true, + ]; + $productIdVisible = 1; + $productIdNotVisible = 2; $stockItemMock = $this->getMockBuilder(\Magento\CatalogInventory\Api\Data\StockItemInterface::class) ->getMockForAbstractClass(); - $itemWithProductMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + $itemWithVisibleProduct = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + ->disableOriginalConstructor() + ->getMock(); + $itemWithNotVisibleProduct = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) ->disableOriginalConstructor() ->getMock(); - $itemWithoutProductMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + $productVisible = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) + $productNotVisible = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); - $items = [$itemWithoutProductMock, $itemWithProductMock]; + $items = [$itemWithVisibleProduct, $itemWithNotVisibleProduct]; $this->getLastOrderMock(); $storeMock = $this->getMockBuilder(\Magento\Store\Api\Data\StoreInterface::class)->getMockForAbstractClass(); - $this->storeManagerMock->expects($this->once())->method('getStore')->willReturn($storeMock); + $this->storeManagerMock->expects($this->any())->method('getStore')->willReturn($storeMock); $storeMock->expects($this->any())->method('getWebsiteId')->willReturn($websiteId); + $storeMock->expects($this->any())->method('getId')->willReturn($storeId); $this->orderMock->expects($this->once()) ->method('getParentItemsRandomCollection') ->with(\Magento\Sales\CustomerData\LastOrderedItems::SIDEBAR_ORDER_LIMIT) ->willReturn($items); - $itemWithProductMock->expects($this->once())->method('hasData')->with('product')->willReturn(true); - $itemWithProductMock->expects($this->any())->method('getProduct')->willReturn($productMock); - $productMock->expects($this->once())->method('getWebsiteIds')->willReturn([1, 4]); - $itemWithProductMock->expects($this->once())->method('getId')->willReturn($expectedItem['id']); - $itemWithProductMock->expects($this->once())->method('getName')->willReturn($expectedItem['name']); - $productMock->expects($this->once())->method('getProductUrl')->willReturn($expectedItem['url']); - $this->stockRegistryMock->expects($this->once())->method('getStockItem')->willReturn($stockItemMock); - $productMock->expects($this->once())->method('getId')->willReturn($productId); - $itemWithProductMock->expects($this->once())->method('getStore')->willReturn($storeMock); + $productVisible->expects($this->once())->method('isVisibleInSiteVisibility')->willReturn(true); + $productVisible->expects($this->once())->method('getProductUrl')->willReturn($expectedItem1['url']); + $productVisible->expects($this->once())->method('getWebsiteIds')->willReturn([1, 4]); + $productVisible->expects($this->once())->method('getId')->willReturn($productIdVisible); + $productNotVisible->expects($this->once())->method('isVisibleInSiteVisibility')->willReturn(false); + $productNotVisible->expects($this->never())->method('getProductUrl'); + $productNotVisible->expects($this->once())->method('getWebsiteIds')->willReturn([1, 4]); + $productNotVisible->expects($this->once())->method('getId')->willReturn($productIdNotVisible); + $itemWithVisibleProduct->expects($this->once())->method('getProductId')->willReturn($productIdVisible); + $itemWithVisibleProduct->expects($this->once())->method('getProduct')->willReturn($productVisible); + $itemWithVisibleProduct->expects($this->once())->method('getId')->willReturn($expectedItem1['id']); + $itemWithVisibleProduct->expects($this->once())->method('getName')->willReturn($expectedItem1['name']); + $itemWithVisibleProduct->expects($this->once())->method('getStore')->willReturn($storeMock); + $itemWithNotVisibleProduct->expects($this->once())->method('getProductId')->willReturn($productIdNotVisible); + $itemWithNotVisibleProduct->expects($this->once())->method('getProduct')->willReturn($productNotVisible); + $itemWithNotVisibleProduct->expects($this->once())->method('getId')->willReturn($expectedItem2['id']); + $itemWithNotVisibleProduct->expects($this->once())->method('getName')->willReturn($expectedItem2['name']); + $itemWithNotVisibleProduct->expects($this->once())->method('getStore')->willReturn($storeMock); + $this->productRepositoryMock->expects($this->any()) + ->method('getById') + ->willReturnMap([ + [$productIdVisible, false, $storeId, false, $productVisible], + [$productIdNotVisible, false, $storeId, false, $productNotVisible], + ]); $this->stockRegistryMock - ->expects($this->once()) + ->expects($this->any()) ->method('getStockItem') - ->with($productId, $websiteId) - ->willReturn($stockItemMock); - $stockItemMock->expects($this->once())->method('getIsInStock')->willReturn($expectedItem['is_saleable']); - $itemWithoutProductMock->expects($this->once())->method('hasData')->with('product')->willReturn(false); - $this->assertEquals(['items' => [$expectedItem]], $this->section->getSectionData()); + ->willReturnMap([ + [$productIdVisible, $websiteId, $stockItemMock], + [$productIdNotVisible, $websiteId, $stockItemMock], + ]); + $stockItemMock->expects($this->exactly(2))->method('getIsInStock')->willReturn($expectedItem1['is_saleable']); + $this->assertEquals(['items' => [$expectedItem1, $expectedItem2]], $this->section->getSectionData()); } private function getLastOrderMock() @@ -160,4 +205,34 @@ private function getLastOrderMock() ->willReturnSelf(); return $this->orderMock; } + + public function testGetSectionDataWithNotExistingProduct() + { + $storeId = 1; + $websiteId = 4; + $productId = 1; + $exception = new \Magento\Framework\Exception\NoSuchEntityException(__("Product doesn't exist")); + $orderItemMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + ->disableOriginalConstructor() + ->setMethods(['getProductId']) + ->getMock(); + $storeMock = $this->getMockBuilder(\Magento\Store\Api\Data\StoreInterface::class)->getMockForAbstractClass(); + + $this->getLastOrderMock(); + $this->storeManagerMock->expects($this->exactly(2))->method('getStore')->willReturn($storeMock); + $storeMock->expects($this->once())->method('getWebsiteId')->willReturn($websiteId); + $storeMock->expects($this->once())->method('getId')->willReturn($storeId); + $this->orderMock->expects($this->once()) + ->method('getParentItemsRandomCollection') + ->with(\Magento\Sales\CustomerData\LastOrderedItems::SIDEBAR_ORDER_LIMIT) + ->willReturn([$orderItemMock]); + $orderItemMock->expects($this->once())->method('getProductId')->willReturn($productId); + $this->productRepositoryMock->expects($this->once()) + ->method('getById') + ->with($productId, false, $storeId) + ->willThrowException($exception); + $this->loggerMock->expects($this->once())->method('critical')->with($exception); + + $this->assertEquals(['items' => []], $this->section->getSectionData()); + } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php b/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php index 3dab9178739b8..91da34c2b8b26 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php @@ -18,12 +18,14 @@ use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\App\RequestInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Model\Quote; use Magento\Quote\Model\Quote\Address; use Magento\Quote\Model\Quote\Item; use Magento\Quote\Model\Quote\Item\Updater; use Magento\Sales\Model\AdminOrder\Create; use Magento\Sales\Model\AdminOrder\Product; +use Magento\Quote\Model\QuoteFactory; use PHPUnit_Framework_MockObject_MockObject as MockObject; /** @@ -39,6 +41,16 @@ class CreateTest extends \PHPUnit\Framework\TestCase */ private $adminOrderCreate; + /** + * @var CartRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $quoteRepository; + + /** + * @var QuoteFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $quoteFactory; + /** * @var SessionQuote|MockObject */ @@ -76,12 +88,22 @@ class CreateTest extends \PHPUnit\Framework\TestCase protected function setUp() { - $this->sessionQuote = $this->createMock(SessionQuote::class); $this->formFactory = $this->createPartialMock(FormFactory::class, ['create']); + $this->quoteFactory = $this->createPartialMock(QuoteFactory::class, ['create']); $this->customerFactory = $this->createPartialMock(CustomerInterfaceFactory::class, ['create']); $this->itemUpdater = $this->createMock(Updater::class); + $this->quoteRepository = $this->getMockBuilder(CartRepositoryInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getForCustomer']) + ->getMockForAbstractClass(); + + $this->sessionQuote = $this->getMockBuilder(\Magento\Backend\Model\Session\Quote::class) + ->disableOriginalConstructor() + ->setMethods(['getQuote', 'getStoreId', 'getCustomerId']) + ->getMock(); + $this->customerMapper = $this->getMockBuilder(Mapper::class) ->setMethods(['toFlatArray']) ->disableOriginalConstructor() @@ -103,6 +125,8 @@ protected function setUp() 'quoteItemUpdater' => $this->itemUpdater, 'customerMapper' => $this->customerMapper, 'dataObjectHelper' => $this->dataObjectHelper, + 'quoteRepository' => $this->quoteRepository, + 'quoteFactory' => $this->quoteFactory, ] ); } @@ -264,4 +288,28 @@ public function testApplyCoupon() $object = $this->adminOrderCreate->applyCoupon($couponCode); self::assertEquals($this->adminOrderCreate, $object); } + + public function testGetCustomerCart() + { + $storeId = 2; + $customerId = 2; + $cartResult = [ + 'cart' => true, + ]; + + $this->quoteFactory->expects($this->once()) + ->method('create'); + $this->sessionQuote->expects($this->once()) + ->method('getStoreId') + ->willReturn($storeId); + $this->sessionQuote->expects($this->once()) + ->method('getCustomerId') + ->willReturn($customerId); + $this->quoteRepository->expects($this->once()) + ->method('getForCustomer') + ->with($customerId, [$storeId]) + ->willReturn($cartResult); + + $this->assertEquals($cartResult, $this->adminOrderCreate->getCustomerCart()); + } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/EmailSenderHandlerTest.php b/app/code/Magento/Sales/Test/Unit/Model/EmailSenderHandlerTest.php index 8d3aaa4dae616..56ec763a31cfe 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/EmailSenderHandlerTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/EmailSenderHandlerTest.php @@ -47,6 +47,16 @@ class EmailSenderHandlerTest extends \PHPUnit\Framework\TestCase */ protected $globalConfig; + /** + * @var \Magento\Sales\Model\Order\Email\Container\IdentityInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $identityContainerMock; + + /** + * @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $storeManagerMock; + protected function setUp() { $objectManager = new ObjectManager($this); @@ -70,18 +80,28 @@ protected function setUp() false, false, true, - ['addFieldToFilter', 'getItems'] + ['addFieldToFilter', 'getItems', 'addAttributeToSelect', 'getSelect'] ); $this->globalConfig = $this->createMock(\Magento\Framework\App\Config::class); + $this->identityContainerMock = $this->createMock( + \Magento\Sales\Model\Order\Email\Container\IdentityInterface::class + ); + + $this->storeManagerMock = $this->createMock( + \Magento\Store\Model\StoreManagerInterface::class + ); + $this->object = $objectManager->getObject( \Magento\Sales\Model\EmailSenderHandler::class, [ - 'emailSender' => $this->emailSender, - 'entityResource' => $this->entityResource, - 'entityCollection' => $this->entityCollection, - 'globalConfig' => $this->globalConfig + 'emailSender' => $this->emailSender, + 'entityResource' => $this->entityResource, + 'entityCollection' => $this->entityCollection, + 'globalConfig' => $this->globalConfig, + 'identityContainer' => $this->identityContainerMock, + 'storeManager' => $this->storeManagerMock, ] ); } @@ -114,12 +134,32 @@ public function testExecute($configValue, $collectionItems, $emailSendingResult) ->method('addFieldToFilter') ->with('email_sent', ['null' => true]); + $this->entityCollection + ->expects($this->any()) + ->method('addAttributeToSelect') + ->with('store_id') + ->willReturnSelf(); + + $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + + $selectMock + ->expects($this->atLeastOnce()) + ->method('group') + ->with('store_id') + ->willReturnSelf(); + + $this->entityCollection + ->expects($this->any()) + ->method('getSelect') + ->willReturn($selectMock); + $this->entityCollection ->expects($this->any()) ->method('getItems') ->willReturn($collectionItems); if ($collectionItems) { + /** @var \Magento\Sales\Model\AbstractModel|\PHPUnit_Framework_MockObject_MockObject $collectionItem */ $collectionItem = $collectionItems[0]; @@ -129,6 +169,23 @@ public function testExecute($configValue, $collectionItems, $emailSendingResult) ->with($collectionItem, true) ->willReturn($emailSendingResult); + $storeMock = $this->createMock(\Magento\Store\Model\Store::class); + + $this->storeManagerMock + ->expects($this->any()) + ->method('getStore') + ->willReturn($storeMock); + + $this->identityContainerMock + ->expects($this->any()) + ->method('setStore') + ->with($storeMock); + + $this->identityContainerMock + ->expects($this->any()) + ->method('isEnabled') + ->willReturn(true); + if ($emailSendingResult) { $collectionItem ->expects($this->once()) @@ -159,14 +216,30 @@ public function executeDataProvider() false, false, true, - ['setEmailSent'] + ['setEmailSent', 'getOrder'] ); return [ - [1, [$entityModel], true], - [1, [$entityModel], false], - [1, [], null], - [0, null, null] + [ + 'configValue' => 1, + 'collectionItems' => [clone $entityModel], + 'emailSendingResult' => true, + ], + [ + 'configValue' => 1, + 'collectionItems' => [clone $entityModel], + 'emailSendingResult' => false, + ], + [ + 'configValue' => 1, + 'collectionItems' => [], + 'emailSendingResult' => null, + ], + [ + 'configValue' => 0, + 'collectionItems' => null, + 'emailSendingResult' => null, + ] ]; } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/CommentRepositoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/CommentRepositoryTest.php new file mode 100644 index 0000000000000..649e8dc0600cf --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/CommentRepositoryTest.php @@ -0,0 +1,189 @@ +commentResource = $this->getMockBuilder(CreditmemoCommentResourceInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->commentFactory = $this->getMockBuilder(CreditmemoCommentInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->searchResultFactory = $this->getMockBuilder(CreditmemoCommentSearchResultInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->collectionProcessor = $this->getMockBuilder(CollectionProcessorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->creditmemoRepositoryMock = $this->getMockBuilder(CreditmemoRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->creditmemoCommentSender = $this->getMockBuilder(CreditmemoCommentSender::class) + ->disableOriginalConstructor() + ->getMock(); + $this->loggerMock = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); + + $this->creditmemoMock = $this->getMockBuilder(Creditmemo::class)->disableOriginalConstructor()->getMock(); + $this->commentMock = $this->getMockBuilder(Comment::class)->disableOriginalConstructor()->getMock(); + + $this->commentRepository = new CommentRepository( + $this->commentResource, + $this->commentFactory, + $this->searchResultFactory, + $this->collectionProcessor, + $this->creditmemoCommentSender, + $this->creditmemoRepositoryMock, + $this->loggerMock + ); + } + + public function testSave() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->creditmemoRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->creditmemoMock); + $this->creditmemoCommentSender->expects($this->once()) + ->method('send') + ->with($this->creditmemoMock, true, $comment) + ->willReturn(true); + $this->commentRepository->save($this->commentMock); + } + + /** + * @expectedException \Magento\Framework\Exception\CouldNotSaveException + * @expectedExceptionMessage Could not save the creditmemo comment. + */ + public function testSaveWithException() + { + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willThrowException( + new \Magento\Framework\Exception\CouldNotSaveException(__('Could not save the creditmemo comment.')) + ); + + $this->commentRepository->save($this->commentMock); + } + + public function testSaveSendCatchException() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->creditmemoRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->creditmemoMock); + $this->creditmemoCommentSender->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception()); + $this->loggerMock->expects($this->once()) + ->method('critical'); + + $this->commentRepository->save($this->commentMock); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/CustomerManagementTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/CustomerManagementTest.php deleted file mode 100644 index 2794860793ed6..0000000000000 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/CustomerManagementTest.php +++ /dev/null @@ -1,185 +0,0 @@ -objectCopyService = $this->createMock(\Magento\Framework\DataObject\Copy::class); - $this->accountManagement = $this->createMock(\Magento\Customer\Api\AccountManagementInterface::class); - $this->customerFactory = $this->createPartialMock( - \Magento\Customer\Api\Data\CustomerInterfaceFactory::class, - ['create'] - ); - $this->addressFactory = $this->createPartialMock( - \Magento\Customer\Api\Data\AddressInterfaceFactory::class, - ['create'] - ); - $this->regionFactory = $this->createPartialMock( - \Magento\Customer\Api\Data\RegionInterfaceFactory::class, - ['create'] - ); - $this->orderRepository = $this->createMock(\Magento\Sales\Api\OrderRepositoryInterface::class); - $this->quoteAddressFactory = $this->createMock(\Magento\Quote\Model\Quote\AddressFactory::class); - - $this->service = new \Magento\Sales\Model\Order\CustomerManagement( - $this->objectCopyService, - $this->accountManagement, - $this->customerFactory, - $this->addressFactory, - $this->regionFactory, - $this->orderRepository, - $this->quoteAddressFactory - ); - } - - /** - * @expectedException \Magento\Framework\Exception\AlreadyExistsException - */ - public function testCreateThrowsExceptionIfCustomerAlreadyExists() - { - $orderMock = $this->createMock(\Magento\Sales\Api\Data\OrderInterface::class); - $orderMock->expects($this->once())->method('getCustomerId')->will($this->returnValue('customer_id')); - $this->orderRepository->expects($this->once())->method('get')->with(1)->will($this->returnValue($orderMock)); - $this->service->create(1); - } - - public function testCreateCreatesCustomerBasedonGuestOrder() - { - $orderMock = $this->createMock(\Magento\Sales\Model\Order::class); - $orderMock->expects($this->once())->method('getCustomerId')->will($this->returnValue(null)); - $orderMock->expects($this->any())->method('getBillingAddress')->will($this->returnValue('billing_address')); - - $orderBillingAddress = $this->createPartialMockForAbstractClass(OrderAddressInterface::class, ['getData']); - $orderBillingAddress->expects($this->once()) - ->method('getAddressType') - ->willReturn(Address::ADDRESS_TYPE_BILLING); - - $orderShippingAddress = $this->createPartialMockForAbstractClass(OrderAddressInterface::class, ['getData']); - $orderShippingAddress->expects($this->once()) - ->method('getAddressType') - ->willReturn(Address::ADDRESS_TYPE_SHIPPING); - - $orderMock->expects($this->any()) - ->method('getAddresses') - ->will($this->returnValue([$orderBillingAddress, $orderShippingAddress])); - - $billingQuoteAddress = $this->createMock(\Magento\Quote\Model\Quote\Address::class); - $billingQuoteAddress->expects($this->once())->method('load')->willReturn($billingQuoteAddress); - $billingQuoteAddress->expects($this->once())->method('getId')->willReturn(4); - $billingQuoteAddress->expects($this->once())->method('getData')->with('save_in_address_book')->willReturn(1); - - $shippingQuoteAddress = $this->createMock(\Magento\Quote\Model\Quote\Address::class); - $shippingQuoteAddress->expects($this->once())->method('load')->willReturn($shippingQuoteAddress); - $shippingQuoteAddress->expects($this->once())->method('getId')->willReturn(5); - $shippingQuoteAddress->expects($this->once())->method('getData')->with('save_in_address_book')->willReturn(1); - $this->quoteAddressFactory->expects($this->exactly(2))->method('create') - ->willReturnOnConsecutiveCalls($billingQuoteAddress, $shippingQuoteAddress); - $this->orderRepository->expects($this->once())->method('get')->with(1)->will($this->returnValue($orderMock)); - $this->objectCopyService->expects($this->any())->method('copyFieldsetToTarget')->will($this->returnValueMap( - [ - ['order_address', 'to_customer', 'billing_address', [], 'global', ['customer_data' => []]], - ['order_address', 'to_customer_address', $orderBillingAddress, [], 'global', 'address_data'], - ['order_address', 'to_customer_address', $orderShippingAddress, [], 'global', 'address_data'], - ] - )); - - $addressMock = $this->createMock(\Magento\Customer\Api\Data\AddressInterface::class); - $addressMock->expects($this->any()) - ->method('setIsDefaultBilling') - ->with(true) - ->willReturnSelf(); - $addressMock->expects($this->any()) - ->method('setIsDefaultShipping') - ->with(true) - ->willReturnSelf(); - - $this->addressFactory->expects($this->any())->method('create')->with(['data' => 'address_data'])->will( - $this->returnValue($addressMock) - ); - $customerMock = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); - $customerMock->expects($this->any())->method('getId')->will($this->returnValue('customer_id')); - $this->customerFactory->expects($this->once())->method('create')->with( - ['data' => ['customer_data' => [], 'addresses' => [$addressMock, $addressMock]]] - )->will($this->returnValue($customerMock)); - $this->accountManagement->expects($this->once())->method('createAccount')->with($customerMock)->will( - $this->returnValue($customerMock) - ); - $orderMock->expects($this->once())->method('setCustomerId')->with('customer_id'); - $this->orderRepository->expects($this->once())->method('save')->with($orderMock); - $this->assertEquals($customerMock, $this->service->create(1)); - } - - /** - * Get mock for abstract class with methods. - * - * @param string $className - * @param array $methods - * - * @return \PHPUnit_Framework_MockObject_MockObject - */ - private function createPartialMockForAbstractClass($className, $methods = []) - { - return $this->getMockForAbstractClass( - $className, - [], - '', - true, - true, - true, - $methods - ); - } -} diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/CommentRepositoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/CommentRepositoryTest.php new file mode 100644 index 0000000000000..984554b289901 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/CommentRepositoryTest.php @@ -0,0 +1,189 @@ +commentResource = $this->getMockBuilder(InvoiceCommentResourceInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->commentFactory = $this->getMockBuilder(InvoiceCommentInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->searchResultFactory = $this->getMockBuilder(InvoiceCommentSearchResultInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->collectionProcessor = $this->getMockBuilder(CollectionProcessorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->invoiceRepositoryMock = $this->getMockBuilder(InvoiceRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->invoiceCommentSender = $this->getMockBuilder(InvoiceCommentSender::class) + ->disableOriginalConstructor() + ->getMock(); + $this->loggerMock = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); + + $this->invoiceMock = $this->getMockBuilder(Invoice::class)->disableOriginalConstructor()->getMock(); + $this->commentMock = $this->getMockBuilder(Comment::class)->disableOriginalConstructor()->getMock(); + + $this->commentRepository = new CommentRepository( + $this->commentResource, + $this->commentFactory, + $this->searchResultFactory, + $this->collectionProcessor, + $this->invoiceCommentSender, + $this->invoiceRepositoryMock, + $this->loggerMock + ); + } + + public function testSave() + { + $comment = "Comment text"; + $invoiceId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($invoiceId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->invoiceRepositoryMock->expects($this->once()) + ->method('get') + ->with($invoiceId) + ->willReturn($this->invoiceMock); + $this->invoiceCommentSender->expects($this->once()) + ->method('send') + ->with($this->invoiceMock, true, $comment) + ->willReturn(true); + $this->commentRepository->save($this->commentMock); + } + + /** + * @expectedException \Magento\Framework\Exception\CouldNotSaveException + * @expectedExceptionMessage Could not save the invoice comment. + */ + public function testSaveWithException() + { + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willThrowException( + new \Magento\Framework\Exception\CouldNotSaveException(__('Could not save the invoice comment.')) + ); + + $this->commentRepository->save($this->commentMock); + } + + public function testSaveSendCatchException() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->invoiceRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->invoiceMock); + $this->invoiceCommentSender->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception()); + $this->loggerMock->expects($this->once()) + ->method('critical'); + + $this->commentRepository->save($this->commentMock); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Payment/State/RegisterCaptureNotificationCommandTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Payment/State/RegisterCaptureNotificationCommandTest.php index 32ea9d8869344..1b762fafe0b71 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Payment/State/RegisterCaptureNotificationCommandTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Payment/State/RegisterCaptureNotificationCommandTest.php @@ -32,26 +32,29 @@ class RegisterCaptureNotificationCommandTest extends \PHPUnit\Framework\TestCase * * @param bool $isTransactionPending * @param bool $isFraudDetected + * @param string|null $currentState * @param string $expectedState * @param string $expectedStatus * @param string $expectedMessage - * + * @return void * @dataProvider commandResultDataProvider */ public function testExecute( - $isTransactionPending, - $isFraudDetected, - $expectedState, - $expectedStatus, - $expectedMessage - ) { + bool $isTransactionPending, + bool $isFraudDetected, + $currentState, + string $expectedState, + string $expectedStatus, + string $expectedMessage + ): void { + $order = $this->getOrder($currentState); $actualReturn = (new RegisterCaptureNotificationCommand($this->getStatusResolver()))->execute( $this->getPayment($isTransactionPending, $isFraudDetected), $this->amount, - $this->getOrder() + $order ); - $this->assertOrderStateAndStatus($this->getOrder(), $expectedState, $expectedStatus); + $this->assertOrderStateAndStatus($order, $expectedState, $expectedStatus); self::assertEquals(__($expectedMessage, $this->amount), $actualReturn); } @@ -64,30 +67,42 @@ public function commandResultDataProvider() [ false, false, + Order::STATE_COMPLETE, + Order::STATE_COMPLETE, + $this->newOrderStatus, + 'Registered notification about captured amount of %1.', + ], + [ + false, + false, + null, Order::STATE_PROCESSING, $this->newOrderStatus, - 'Registered notification about captured amount of %1.' + 'Registered notification about captured amount of %1.', ], [ true, false, + Order::STATE_PROCESSING, Order::STATE_PAYMENT_REVIEW, $this->newOrderStatus, - 'An amount of %1 will be captured after being approved at the payment gateway.' + 'An amount of %1 will be captured after being approved at the payment gateway.', ], [ false, true, + Order::STATE_PROCESSING, Order::STATE_PAYMENT_REVIEW, Order::STATUS_FRAUD, - 'Order is suspended as its capture amount %1 is suspected to be fraudulent.' + 'Order is suspended as its capture amount %1 is suspected to be fraudulent.', ], [ true, true, + Order::STATE_PROCESSING, Order::STATE_PAYMENT_REVIEW, Order::STATUS_FRAUD, - 'Order is suspended as its capture amount %1 is suspected to be fraudulent.' + 'Order is suspended as its capture amount %1 is suspected to be fraudulent.', ], ]; } @@ -107,15 +122,19 @@ private function getStatusResolver() } /** + * @param string|null $state * @return Order|MockObject */ - private function getOrder() + private function getOrder($state) { + /** @var Order|MockObject $order */ $order = $this->getMockBuilder(Order::class) ->disableOriginalConstructor() + ->setMethods(['getBaseCurrency', 'getOrderStatusByState']) ->getMock(); $order->method('getBaseCurrency') ->willReturn($this->getCurrency()); + $order->setState($state); return $order; } @@ -159,7 +178,7 @@ private function getCurrency() */ private function assertOrderStateAndStatus($order, $expectedState, $expectedStatus) { - $order->method('setState')->with($expectedState); - $order->method('setStatus')->with($expectedStatus); + self::assertEquals($expectedState, $order->getState(), 'The order {state} should match.'); + self::assertEquals($expectedStatus, $order->getStatus(), 'The order {status} should match.'); } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/CommentRepositoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/CommentRepositoryTest.php new file mode 100644 index 0000000000000..5152b359e4b6a --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/CommentRepositoryTest.php @@ -0,0 +1,188 @@ +commentResource = $this->getMockBuilder(ShipmentCommentResourceInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->commentFactory = $this->getMockBuilder(ShipmentCommentInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->searchResultFactory = $this->getMockBuilder(ShipmentCommentSearchResultInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->collectionProcessor = $this->getMockBuilder(CollectionProcessorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->shipmentRepositoryMock = $this->getMockBuilder(ShipmentRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->shipmentCommentSender = $this->getMockBuilder(ShipmentCommentSender::class) + ->disableOriginalConstructor() + ->getMock(); + $this->loggerMock = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); + + $this->shipmentMock = $this->getMockBuilder(Shipment::class)->disableOriginalConstructor()->getMock(); + $this->commentMock = $this->getMockBuilder(Comment::class)->disableOriginalConstructor()->getMock(); + + $this->commentRepository = new CommentRepository( + $this->commentResource, + $this->commentFactory, + $this->searchResultFactory, + $this->collectionProcessor, + $this->shipmentCommentSender, + $this->shipmentRepositoryMock, + $this->loggerMock + ); + } + + public function testSave() + { + $comment = "Comment text"; + $shipmentId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($shipmentId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->shipmentRepositoryMock->expects($this->once()) + ->method('get') + ->with($shipmentId) + ->willReturn($this->shipmentMock); + $this->shipmentCommentSender->expects($this->once()) + ->method('send') + ->with($this->shipmentMock, true, $comment); + $this->assertEquals($this->commentMock, $this->commentRepository->save($this->commentMock)); + } + + /** + * @expectedException \Magento\Framework\Exception\CouldNotSaveException + * @expectedExceptionMessage Could not save the shipment comment. + */ + public function testSaveWithException() + { + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willThrowException( + new \Magento\Framework\Exception\CouldNotSaveException(__('Could not save the shipment comment.')) + ); + + $this->commentRepository->save($this->commentMock); + } + + public function testSaveSendCatchException() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->shipmentRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->shipmentMock); + $this->shipmentCommentSender->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception()); + $this->loggerMock->expects($this->once()) + ->method('critical'); + + $this->commentRepository->save($this->commentMock); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/OrderIncrementIdCheckerTest.php b/app/code/Magento/Sales/Test/Unit/Model/OrderIncrementIdCheckerTest.php new file mode 100644 index 0000000000000..e53cb7bfdf8c6 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/OrderIncrementIdCheckerTest.php @@ -0,0 +1,81 @@ +selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $this->selectMock->expects($this->any())->method('from')->will($this->returnSelf()); + $this->selectMock->expects($this->any())->method('where'); + + $this->adapterMock = $this->createMock(\Magento\Framework\DB\Adapter\Pdo\Mysql::class); + $this->adapterMock->expects($this->any())->method('select')->will($this->returnValue($this->selectMock)); + + $this->resourceMock = $this->createMock(\Magento\Sales\Model\ResourceModel\Order::class); + $this->resourceMock->expects($this->any())->method('getConnection')->willReturn($this->adapterMock); + + $this->model = $objectManagerHelper->getObject( + \Magento\Sales\Model\OrderIncrementIdChecker::class, + [ + 'resourceModel' => $this->resourceMock, + ] + ); + } + + /** + * Unit test to verify if isOrderIncrementIdUsed method works with different types increment ids. + * + * @param string|int $value + * @return void + * @dataProvider isOrderIncrementIdUsedDataProvider + */ + public function testIsIncrementIdUsed($value): void + { + $expectedBind = [':increment_id' => $value]; + $this->adapterMock->expects($this->once())->method('fetchOne')->with($this->selectMock, $expectedBind); + $this->model->isIncrementIdUsed($value); + } + + /** + * @return array + */ + public function isOrderIncrementIdUsedDataProvider(): array + { + return [[100000001], ['10000000001'], ['M10000000001']]; + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/GridTest.php b/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/GridTest.php new file mode 100644 index 0000000000000..0c38ee9c509a5 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/GridTest.php @@ -0,0 +1,109 @@ + 'column_1_value', + 'column_2_key' => 'column_2_value' + ]; + + /** + * @inheritdoc + */ + protected function setUp() + { + $objectManager = new ObjectManager($this); + $this->notSyncedDataProvider = $this->getMockBuilder(NotSyncedDataProviderInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getIds']) + ->getMockForAbstractClass(); + $this->connection = $this->getMockBuilder(ConnectionAdapterInterface::class) + ->disableOriginalConstructor() + ->setMethods(['select', 'fetchAll', 'insertOnDuplicate']) + ->getMockForAbstractClass(); + + $this->grid = $objectManager->getObject( + \Magento\Sales\Model\ResourceModel\Grid::class, + [ + 'notSyncedDataProvider' => $this->notSyncedDataProvider, + 'mainTableName' => $this->mainTable, + 'gridTableName' => $this->gridTable, + 'connection' => $this->connection, + '_tables' => ['sales_order' => $this->mainTable, 'sales_order_grid' => $this->gridTable], + 'columns' => $this->columns + ] + ); + } + + /** + * Test for refreshBySchedule() method + */ + public function testRefreshBySchedule() + { + $notSyncedIds = ['1', '2', '3']; + $fetchResult = ['column_1' => '1', 'column_2' => '2']; + + $this->notSyncedDataProvider->expects($this->atLeastOnce())->method('getIds')->willReturn($notSyncedIds); + $select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor() + ->setMethods(['from', 'columns', 'where']) + ->getMock(); + $select->expects($this->atLeastOnce())->method('from')->with(['sales_order' => $this->mainTable], []) + ->willReturnSelf(); + $select->expects($this->atLeastOnce())->method('columns')->willReturnSelf(); + $select->expects($this->atLeastOnce())->method('where') + ->with($this->mainTable . '.entity_id IN (?)', $notSyncedIds) + ->willReturnSelf(); + + $this->connection->expects($this->atLeastOnce())->method('select')->willReturn($select); + $this->connection->expects($this->atLeastOnce())->method('fetchAll')->with($select)->willReturn($fetchResult); + $this->connection->expects($this->atLeastOnce())->method('insertOnDuplicate') + ->with($this->gridTable, $fetchResult, array_keys($this->columns)) + ->willReturn(array_count_values($notSyncedIds)); + + $this->grid->refreshBySchedule(); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Observer/Backend/SubtractQtyFromQuotesObserverTest.php b/app/code/Magento/Sales/Test/Unit/Observer/Backend/SubtractQtyFromQuotesObserverTest.php index a6a828c888fc0..949121eadee44 100644 --- a/app/code/Magento/Sales/Test/Unit/Observer/Backend/SubtractQtyFromQuotesObserverTest.php +++ b/app/code/Magento/Sales/Test/Unit/Observer/Backend/SubtractQtyFromQuotesObserverTest.php @@ -48,7 +48,7 @@ public function testSubtractQtyFromQuotes() ['getId', 'getStatus', '__wakeup'] ); $this->_eventMock->expects($this->once())->method('getProduct')->will($this->returnValue($productMock)); - $this->_quoteMock->expects($this->once())->method('substractProductFromQuotes')->with($productMock); + $this->_quoteMock->expects($this->once())->method('subtractProductFromQuotes')->with($productMock); $this->_model->execute($this->_observerMock); } } diff --git a/app/code/Magento/Sales/composer.json b/app/code/Magento/Sales/composer.json index 2eb05c8987720..4286fbb506a53 100644 --- a/app/code/Magento/Sales/composer.json +++ b/app/code/Magento/Sales/composer.json @@ -5,11 +5,12 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-authorization": "*", "magento/module-backend": "*", "magento/module-catalog": "*", + "magento/module-bundle": "*", "magento/module-catalog-inventory": "*", "magento/module-checkout": "*", "magento/module-config": "*", @@ -32,7 +33,7 @@ "magento/module-wishlist": "*" }, "suggest": { - "magento/module-sales-sample-data": "Sample Data version:100.3.*" + "magento/module-sales-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Sales/etc/adminhtml/system.xml b/app/code/Magento/Sales/etc/adminhtml/system.xml index 6261353332cef..9d6d11d56c81f 100644 --- a/app/code/Magento/Sales/etc/adminhtml/system.xml +++ b/app/code/Magento/Sales/etc/adminhtml/system.xml @@ -106,15 +106,15 @@ We'll use the default error above if you leave this empty. - + - + Magento\Config\Model\Config\Source\Yesno Improves dashboard performance but provides non-realtime data. - + diff --git a/app/code/Magento/Sales/etc/db_schema.xml b/app/code/Magento/Sales/etc/db_schema.xml index 8c91a8b5fbc13..4b716e761094c 100644 --- a/app/code/Magento/Sales/etc/db_schema.xml +++ b/app/code/Magento/Sales/etc/db_schema.xml @@ -216,7 +216,7 @@ - + diff --git a/app/code/Magento/Sales/etc/di.xml b/app/code/Magento/Sales/etc/di.xml index c1dc3af859d1a..89799347fdda0 100644 --- a/app/code/Magento/Sales/etc/di.xml +++ b/app/code/Magento/Sales/etc/di.xml @@ -65,6 +65,7 @@ + @@ -322,6 +323,7 @@ Magento\Sales\Model\Order\Email\Sender\OrderSender Magento\Sales\Model\ResourceModel\Order Magento\Sales\Model\ResourceModel\Order\Collection + Magento\Sales\Model\Order\Email\Container\OrderIdentity @@ -329,6 +331,7 @@ Magento\Sales\Model\Order\Email\Sender\InvoiceSender Magento\Sales\Model\ResourceModel\Order\Invoice Magento\Sales\Model\ResourceModel\Order\Invoice\Collection + Magento\Sales\Model\Order\Email\Container\InvoiceIdentity @@ -336,6 +339,7 @@ Magento\Sales\Model\Order\Email\Sender\ShipmentSender Magento\Sales\Model\ResourceModel\Order\Shipment Magento\Sales\Model\ResourceModel\Order\Shipment\Collection + Magento\Sales\Model\Order\Email\Container\ShipmentIdentity @@ -343,6 +347,7 @@ Magento\Sales\Model\Order\Email\Sender\CreditmemoSender Magento\Sales\Model\ResourceModel\Order\Creditmemo Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection + Magento\Sales\Model\Order\Email\Container\CreditmemoIdentity @@ -686,7 +691,7 @@ ShippingAddressAggregator sales_order.shipping_description sales_invoice.base_subtotal - sales_order.base_shipping_amount + sales_invoice.base_shipping_amount sales_invoice.base_grand_total sales_invoice.grand_total sales_invoice.created_at @@ -991,4 +996,7 @@
    + diff --git a/app/code/Magento/Sales/etc/events.xml b/app/code/Magento/Sales/etc/events.xml index 9ec983acab5bd..b3a7a4ab99577 100644 --- a/app/code/Magento/Sales/etc/events.xml +++ b/app/code/Magento/Sales/etc/events.xml @@ -51,4 +51,9 @@ + + + diff --git a/app/code/Magento/Sales/view/adminhtml/templates/order/create/billing/method/form.phtml b/app/code/Magento/Sales/view/adminhtml/templates/order/create/billing/method/form.phtml index a0851a864ff0b..da37433758aeb 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/order/create/billing/method/form.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/order/create/billing/method/form.phtml @@ -12,6 +12,7 @@ $_methods = $block->getMethods(); $_methodsCount = count($_methods); $_counter = 0; + $currentSelectedMethod = $block->getSelectedMethodCode(); ?> getCode(); @@ -24,7 +25,7 @@ type="radio" name="payment[method]" title="escapeHtml($_method->getTitle()); ?>" onclick="payment.switchMethod('escapeHtml($_code); ?>')" - getSelectedMethodCode() == $_code) : ?> + checked="checked" @@ -39,8 +40,8 @@ -
    diff --git a/app/code/Magento/SalesAnalytics/composer.json b/app/code/Magento/SalesAnalytics/composer.json index c9933d81225d8..64424c8f5bc61 100644 --- a/app/code/Magento/SalesAnalytics/composer.json +++ b/app/code/Magento/SalesAnalytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-sales-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-sales": "*" }, diff --git a/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php b/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php index ed95ecd7e4380..3f2ba38fa5a55 100644 --- a/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php +++ b/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php @@ -10,6 +10,8 @@ /** * Class ReturnProcessor + * + * @api */ class ReturnProcessor { diff --git a/app/code/Magento/SalesInventory/composer.json b/app/code/Magento/SalesInventory/composer.json index 0d97aedd39657..d8a48bed9169a 100644 --- a/app/code/Magento/SalesInventory/composer.json +++ b/app/code/Magento/SalesInventory/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-catalog-inventory": "*", diff --git a/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php new file mode 100644 index 0000000000000..f413a7d047d62 --- /dev/null +++ b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php @@ -0,0 +1,26 @@ +getActualLength(); $code = ''; for ($i = 0, $indexMax = strlen($alphabet) - 1; $i < $length; ++$i) { - $code .= substr($alphabet, mt_rand(0, $indexMax), 1); + $code .= substr($alphabet, random_int(0, $indexMax), 1); } return $code; @@ -54,7 +54,7 @@ protected function getActualLength() $lengthMin = $this->getLengthMin() ? $this->getLengthMin() : static::DEFAULT_LENGTH_MIN; $lengthMax = $this->getLengthMax() ? $this->getLengthMax() : static::DEFAULT_LENGTH_MAX; - return $this->getLength() ? $this->getLength() : mt_rand($lengthMin, $lengthMax); + return $this->getLength() ? $this->getLength() : random_int($lengthMin, $lengthMax); } /** diff --git a/app/code/Magento/SalesRule/Model/DeltaPriceRound.php b/app/code/Magento/SalesRule/Model/DeltaPriceRound.php new file mode 100644 index 0000000000000..b080a93ee4c9f --- /dev/null +++ b/app/code/Magento/SalesRule/Model/DeltaPriceRound.php @@ -0,0 +1,78 @@ +priceCurrency = $priceCurrency; + } + + /** + * Round price based on previous rounding operation delta. + * + * @param float $price + * @param string $type + * @return float + */ + public function round(float $price, string $type): float + { + if ($price) { + // initialize the delta to a small number to avoid non-deterministic behavior with rounding of 0.5 + $delta = isset($this->roundingDeltas[$type]) ? $this->roundingDeltas[$type] : 0.000001; + $price += $delta; + $roundPrice = $this->priceCurrency->round($price); + $this->roundingDeltas[$type] = $price - $roundPrice; + $price = $roundPrice; + } + + return $price; + } + + /** + * Reset all deltas. + * + * @return void + */ + public function resetAll(): void + { + $this->roundingDeltas = []; + } + + /** + * Reset deltas by type. + * + * @param string $type + * @return void + */ + public function reset(string $type): void + { + if (isset($this->roundingDeltas[$type])) { + unset($this->roundingDeltas[$type]); + } + } +} diff --git a/app/code/Magento/SalesRule/Model/Quote/ChildrenValidationLocator.php b/app/code/Magento/SalesRule/Model/Quote/ChildrenValidationLocator.php new file mode 100644 index 0000000000000..af1f61c187129 --- /dev/null +++ b/app/code/Magento/SalesRule/Model/Quote/ChildrenValidationLocator.php @@ -0,0 +1,53 @@ + + * [ + * 'ProductType1' => true, + * 'ProductType2' => false + * ] + * + */ + public function __construct( + array $productTypeChildrenValidationMap = [] + ) { + $this->productTypeChildrenValidationMap = $productTypeChildrenValidationMap; + } + + /** + * Checks necessity to validate rule on item's children. + * + * @param QuoteItem $item + * @return bool + */ + public function isChildrenValidationRequired(QuoteItem $item): bool + { + $type = $item->getProduct()->getTypeId(); + if (isset($this->productTypeChildrenValidationMap[$type])) { + return (bool)$this->productTypeChildrenValidationMap[$type]; + } + + return true; + } +} diff --git a/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php b/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php index 5068f53f23bc9..3907261cad1b9 100644 --- a/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php +++ b/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php @@ -106,7 +106,7 @@ public function exists($code) } /** - * Update auto generated Specific Coupon if it's rule changed + * Update auto generated Specific Coupon if its rule changed * * @param \Magento\SalesRule\Model\Rule $rule * @return $this diff --git a/app/code/Magento/SalesRule/Model/Rule.php b/app/code/Magento/SalesRule/Model/Rule.php index f4469213bd96e..59efdf5eb3f6d 100644 --- a/app/code/Magento/SalesRule/Model/Rule.php +++ b/app/code/Magento/SalesRule/Model/Rule.php @@ -521,7 +521,7 @@ public function acquireCoupon($saveNewlyCreated = true, $saveAttemptCount = 10) $coupon->setCode( $couponCode . self::getCouponCodeGenerator()->getDelimiter() . sprintf( '%04u', - rand(0, 9999) + random_int(0, 9999) ) ); continue; diff --git a/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php b/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php index caa938322617d..3cd776fe99f5d 100644 --- a/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php +++ b/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php @@ -5,6 +5,14 @@ */ namespace Magento\SalesRule\Model\Rule\Action\Discount; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\SalesRule\Model\DeltaPriceRound; +use Magento\SalesRule\Model\Validator; + +/** + * Calculates discount for cart item if fixed discount applied on whole cart. + */ class CartFixed extends AbstractDiscount { /** @@ -14,6 +22,33 @@ class CartFixed extends AbstractDiscount */ protected $_cartFixedRuleUsedForAddress = []; + /** + * @var DeltaPriceRound + */ + private $deltaPriceRound; + + /** + * @var string + */ + private static $discountType = 'CartFixed'; + + /** + * @param Validator $validator + * @param DataFactory $discountDataFactory + * @param PriceCurrencyInterface $priceCurrency + * @param DeltaPriceRound $deltaPriceRound + */ + public function __construct( + Validator $validator, + DataFactory $discountDataFactory, + PriceCurrencyInterface $priceCurrency, + DeltaPriceRound $deltaPriceRound + ) { + $this->deltaPriceRound = $deltaPriceRound; + + parent::__construct($validator, $discountDataFactory, $priceCurrency); + } + /** * @param \Magento\SalesRule\Model\Rule $rule * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item @@ -51,14 +86,22 @@ public function calculate($rule, $item, $qty) $cartRules[$rule->getId()] = $rule->getDiscountAmount(); } - if ($cartRules[$rule->getId()] > 0) { + $availableDiscountAmount = (float)$cartRules[$rule->getId()]; + $discountType = self::$discountType . $rule->getId(); + + if ($availableDiscountAmount > 0) { $store = $quote->getStore(); if ($ruleTotals['items_count'] <= 1) { - $quoteAmount = $this->priceCurrency->convert($cartRules[$rule->getId()], $store); - $baseDiscountAmount = min($baseItemPrice * $qty, $cartRules[$rule->getId()]); + $quoteAmount = $this->priceCurrency->convert($availableDiscountAmount, $store); + $baseDiscountAmount = min($baseItemPrice * $qty, $availableDiscountAmount); + $this->deltaPriceRound->reset($discountType); } else { - $discountRate = $baseItemPrice * $qty / $ruleTotals['base_items_price']; - $maximumItemDiscount = $rule->getDiscountAmount() * $discountRate; + $ratio = $baseItemPrice * $qty / $ruleTotals['base_items_price']; + $maximumItemDiscount = $this->deltaPriceRound->round( + $rule->getDiscountAmount() * $ratio, + $discountType + ); + $quoteAmount = $this->priceCurrency->convert($maximumItemDiscount, $store); $baseDiscountAmount = min($baseItemPrice * $qty, $maximumItemDiscount); @@ -67,7 +110,11 @@ public function calculate($rule, $item, $qty) $baseDiscountAmount = $this->priceCurrency->round($baseDiscountAmount); - $cartRules[$rule->getId()] -= $baseDiscountAmount; + $availableDiscountAmount -= $baseDiscountAmount; + $cartRules[$rule->getId()] = $availableDiscountAmount; + if ($availableDiscountAmount <= 0) { + $this->deltaPriceRound->reset($discountType); + } $discountData->setAmount($this->priceCurrency->round(min($itemPrice * $qty, $quoteAmount))); $discountData->setBaseAmount($baseDiscountAmount); diff --git a/app/code/Magento/SalesRule/Model/RulesApplier.php b/app/code/Magento/SalesRule/Model/RulesApplier.php index 06a4e252bf60e..f771a4f1e3892 100644 --- a/app/code/Magento/SalesRule/Model/RulesApplier.php +++ b/app/code/Magento/SalesRule/Model/RulesApplier.php @@ -6,6 +6,9 @@ namespace Magento\SalesRule\Model; use Magento\Quote\Model\Quote\Address; +use Magento\SalesRule\Model\Quote\ChildrenValidationLocator; +use Magento\Framework\App\ObjectManager; +use Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory; /** * Class RulesApplier @@ -25,19 +28,33 @@ class RulesApplier */ protected $validatorUtility; + /** + * @var ChildrenValidationLocator + */ + private $childrenValidationLocator; + + /** + * @var CalculatorFactory + */ + private $calculatorFactory; + /** * @param \Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory $calculatorFactory * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\SalesRule\Model\Utility $utility + * @param ChildrenValidationLocator|null $childrenValidationLocator */ public function __construct( \Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory $calculatorFactory, \Magento\Framework\Event\ManagerInterface $eventManager, - \Magento\SalesRule\Model\Utility $utility + \Magento\SalesRule\Model\Utility $utility, + ChildrenValidationLocator $childrenValidationLocator = null ) { $this->calculatorFactory = $calculatorFactory; $this->validatorUtility = $utility; $this->_eventManager = $eventManager; + $this->childrenValidationLocator = $childrenValidationLocator + ?: ObjectManager::getInstance()->get(ChildrenValidationLocator::class); } /** @@ -61,6 +78,9 @@ public function applyRules($item, $rules, $skipValidation, $couponCode) } if (!$skipValidation && !$rule->getActions()->validate($item)) { + if (!$this->childrenValidationLocator->isChildrenValidationRequired($item)) { + continue; + } $childItems = $item->getChildren(); $isContinue = true; if (!empty($childItems)) { diff --git a/app/code/Magento/SalesRule/Model/Validator.php b/app/code/Magento/SalesRule/Model/Validator.php index 5c76c534ed03b..5c0f97ae0b08b 100644 --- a/app/code/Magento/SalesRule/Model/Validator.php +++ b/app/code/Magento/SalesRule/Model/Validator.php @@ -506,7 +506,7 @@ public function sortItemsByPriority($items, Address $address = null) foreach ($items as $itemKey => $itemValue) { if ($rule->getActions()->validate($itemValue)) { unset($items[$itemKey]); - array_push($itemsSorted, $itemValue); + $itemsSorted[] = $itemValue; } } } diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php index ebdc10830f33f..31536e1be3d2e 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php @@ -150,7 +150,8 @@ public function testSaveWithExceptions($exceptionObject, $exceptionName, $except $this->resource->expects($this->once())->method('save')->with($coupon) ->willThrowException($exceptionObject); } - $this->expectException($exceptionName, $exceptionMessage); + $this->expectException($exceptionName); + $this->expectExceptionMessage($exceptionMessage); $this->model->save($coupon); } diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/DeltaPriceRoundTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/DeltaPriceRoundTest.php new file mode 100644 index 0000000000000..d67dab5baf63b --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Unit/Model/DeltaPriceRoundTest.php @@ -0,0 +1,102 @@ +priceCurrency = $this->getMockForAbstractClass(PriceCurrencyInterface::class); + $this->priceCurrency->method('round') + ->willReturnCallback( + function ($amount) { + return round($amount, 2); + } + ); + + $this->model = new DeltaPriceRound($this->priceCurrency); + } + + /** + * Tests rounded price based on previous rounding operation delta. + * + * @param array $prices + * @param array $roundedPrices + * @return void + * @dataProvider roundDataProvider + */ + public function testRound(array $prices, array $roundedPrices): void + { + foreach ($prices as $key => $price) { + $roundedPrice = $this->model->round($price, 'test'); + $this->assertEquals($roundedPrices[$key], $roundedPrice); + } + + $this->model->reset('test'); + } + + /** + * @return array + */ + public function roundDataProvider(): array + { + return [ + [ + 'prices' => [1.004, 1.004], + 'rounded prices' => [1.00, 1.01], + ], + [ + 'prices' => [1.005, 1.005], + 'rounded prices' => [1.01, 1.0], + ], + ]; + } + + /** + * @return void + */ + public function testReset(): void + { + $this->assertEquals(1.44, $this->model->round(1.444, 'test')); + $this->model->reset('test'); + $this->assertEquals(1.44, $this->model->round(1.444, 'test')); + } + + /** + * @return void + */ + public function testResetAll(): void + { + $this->assertEquals(1.44, $this->model->round(1.444, 'test1')); + $this->assertEquals(1.44, $this->model->round(1.444, 'test2')); + + $this->model->resetAll(); + + $this->assertEquals(1.44, $this->model->round(1.444, 'test1')); + $this->assertEquals(1.44, $this->model->round(1.444, 'test2')); + } +} diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/Quote/ChildrenValidationLocatorTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/Quote/ChildrenValidationLocatorTest.php new file mode 100644 index 0000000000000..abb8d791d74c4 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Unit/Model/Quote/ChildrenValidationLocatorTest.php @@ -0,0 +1,104 @@ +objectManager = new ObjectManager($this); + + $this->productTypeChildrenValidationMap = [ + 'type1' => true, + 'type2' => false, + ]; + + $this->quoteItemMock = $this->getMockBuilder(QuoteItem::class) + ->disableOriginalConstructor() + ->setMethods(['getProduct']) + ->getMockForAbstractClass(); + + $this->productMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->setMethods(['getTypeId']) + ->getMock(); + + $this->model = $this->objectManager->getObject( + ChildrenValidationLocator::class, + [ + 'productTypeChildrenValidationMap' => $this->productTypeChildrenValidationMap, + ] + ); + } + + /** + * @dataProvider productTypeDataProvider + * @param string $type + * @param bool $expected + * + * @return void + */ + public function testIsChildrenValidationRequired(string $type, bool $expected): void + { + $this->quoteItemMock->expects($this->once()) + ->method('getProduct') + ->willReturn($this->productMock); + + $this->productMock->expects($this->once()) + ->method('getTypeId') + ->willReturn($type); + + $this->assertEquals($this->model->isChildrenValidationRequired($this->quoteItemMock), $expected); + } + + /** + * @return array + */ + public function productTypeDataProvider(): array + { + return [ + ['type1', true], + ['type2', false], + ['type3', true], + ]; + } +} diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php index 2f1bee9fc686a..13f26124c464d 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php @@ -5,48 +5,56 @@ */ namespace Magento\SalesRule\Test\Unit\Model\Rule\Action\Discount; +use PHPUnit_Framework_MockObject_MockObject as MockObject; + +/** + * Tests for Magento\SalesRule\Model\Rule\Action\Discount\CartFixed. + */ class CartFixedTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\SalesRule\Model\Rule|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\SalesRule\Model\Rule|MockObject */ protected $rule; /** - * @var \Magento\Quote\Model\Quote\Item\AbstractItem|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Quote\Model\Quote\Item\AbstractItem|MockObject */ protected $item; /** - * @var \Magento\SalesRule\Model\Validator|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\SalesRule\Model\Validator|MockObject */ protected $validator; /** - * @var \Magento\SalesRule\Model\Rule\Action\Discount\Data|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\SalesRule\Model\Rule\Action\Discount\Data|MockObject */ protected $data; /** - * @var \Magento\Quote\Model\Quote|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Quote\Model\Quote|MockObject */ protected $quote; /** - * @var \Magento\Quote\Model\Quote\Address|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Quote\Model\Quote\Address|MockObject */ protected $address; /** - * @var CartFixed + * @var \Magento\SalesRule\Model\Rule\Action\Discount\CartFixed */ protected $model; /** - * @var \Magento\Framework\Pricing\PriceCurrencyInterface|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Pricing\PriceCurrencyInterface|MockObject */ protected $priceCurrency; + /** + * @inheritdoc + */ protected function setUp() { $this->rule = $this->getMockBuilder(\Magento\Framework\DataObject::class) @@ -66,18 +74,23 @@ protected function setUp() $this->item->expects($this->any())->method('getAddress')->will($this->returnValue($this->address)); $this->validator = $this->createMock(\Magento\SalesRule\Model\Validator::class); + /** @var \Magento\SalesRule\Model\Rule\Action\Discount\DataFactory|MockObject $dataFactory */ $dataFactory = $this->createPartialMock( \Magento\SalesRule\Model\Rule\Action\Discount\DataFactory::class, ['create'] ); $dataFactory->expects($this->any())->method('create')->will($this->returnValue($this->data)); - $this->priceCurrency = $this->getMockBuilder( - \Magento\Framework\Pricing\PriceCurrencyInterface::class - )->getMock(); + $this->priceCurrency = $this->getMockBuilder(\Magento\Framework\Pricing\PriceCurrencyInterface::class) + ->getMock(); + $deltaPriceRound = $this->getMockBuilder(\Magento\SalesRule\Model\DeltaPriceRound::class) + ->disableOriginalConstructor() + ->getMock(); + $this->model = new \Magento\SalesRule\Model\Rule\Action\Discount\CartFixed( $this->validator, $dataFactory, - $this->priceCurrency + $this->priceCurrency, + $deltaPriceRound ); } diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php index 814048c2ac1d0..37c839d413d4b 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php @@ -6,6 +6,9 @@ namespace Magento\SalesRule\Test\Unit\Model; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class RulesApplierTest extends \PHPUnit\Framework\TestCase { /** @@ -28,6 +31,11 @@ class RulesApplierTest extends \PHPUnit\Framework\TestCase */ protected $validatorUtility; + /** + * @var \Magento\SalesRule\Model\Quote\ChildrenValidationLocator|\PHPUnit_Framework_MockObject_MockObject + */ + protected $childrenValidationLocator; + protected function setUp() { $this->calculatorFactory = $this->createMock( @@ -38,11 +46,15 @@ protected function setUp() \Magento\SalesRule\Model\Utility::class, ['canProcessRule', 'minFix', 'deltaRoundingFix', 'getItemQty'] ); - + $this->childrenValidationLocator = $this->createPartialMock( + \Magento\SalesRule\Model\Quote\ChildrenValidationLocator::class, + ['isChildrenValidationRequired'] + ); $this->rulesApplier = new \Magento\SalesRule\Model\RulesApplier( $this->calculatorFactory, $this->eventManager, - $this->validatorUtility + $this->validatorUtility, + $this->childrenValidationLocator ); } @@ -84,6 +96,10 @@ public function testApplyRulesWhenRuleWithStopRulesProcessingIsUsed($isChildren, $item->setDiscountCalculationPrice($positivePrice); $item->setData('calculation_price', $positivePrice); + $this->childrenValidationLocator->expects($this->any()) + ->method('isChildrenValidationRequired') + ->willReturn(true); + $this->validatorUtility->expects($this->atLeastOnce()) ->method('canProcessRule') ->will($this->returnValue(true)); diff --git a/app/code/Magento/SalesRule/composer.json b/app/code/Magento/SalesRule/composer.json index 343b72e8ac879..a2e7dc8835ae7 100644 --- a/app/code/Magento/SalesRule/composer.json +++ b/app/code/Magento/SalesRule/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", @@ -25,7 +25,7 @@ "magento/module-widget": "*" }, "suggest": { - "magento/module-sales-rule-sample-data": "Sample Data version:100.3.*" + "magento/module-sales-rule-sample-data": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/SalesSequence/Setup/Patch/Schema/CreateSequence.php b/app/code/Magento/SalesSequence/Setup/Patch/Schema/CreateSequence.php deleted file mode 100644 index 875817a973a94..0000000000000 --- a/app/code/Magento/SalesSequence/Setup/Patch/Schema/CreateSequence.php +++ /dev/null @@ -1,68 +0,0 @@ -sequenceCreator = $sequenceCreator; - } - - /** - * {@inheritdoc} - */ - public function apply() - { - $this->sequenceCreator->create(); - } - - /** - * {@inheritdoc} - */ - public static function getDependencies() - { - return [ - \Magento\Store\Setup\Patch\Schema\InitializeStoresAndWebsites::class - ]; - } - - /** - * {@inheritdoc} - */ - public static function getVersion() - { - return '2.0.0'; - } - - /** - * {@inheritdoc} - */ - public function getAliases() - { - return []; - } -} diff --git a/app/code/Magento/SalesSequence/Setup/Recurring.php b/app/code/Magento/SalesSequence/Setup/Recurring.php new file mode 100644 index 0000000000000..2beff94bfab67 --- /dev/null +++ b/app/code/Magento/SalesSequence/Setup/Recurring.php @@ -0,0 +1,39 @@ +sequenceCreator = $sequenceCreator; + } + + /** + * {@inheritdoc} + */ + public function install(SchemaSetupInterface $setup, ModuleContextInterface $context) + { + $this->sequenceCreator->create(); + } +} diff --git a/app/code/Magento/SalesSequence/composer.json b/app/code/Magento/SalesSequence/composer.json index 64a6627cd99e2..3865d9569c529 100644 --- a/app/code/Magento/SalesSequence/composer.json +++ b/app/code/Magento/SalesSequence/composer.json @@ -5,9 +5,8 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "*", - "magento/module-store": "*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/SalesSequence/etc/db_schema.xml b/app/code/Magento/SalesSequence/etc/db_schema.xml index 0a2d1cb9e0cbb..530a9a33b0cfe 100644 --- a/app/code/Magento/SalesSequence/etc/db_schema.xml +++ b/app/code/Magento/SalesSequence/etc/db_schema.xml @@ -7,7 +7,7 @@ --> -
    +
    - +
    diff --git a/app/code/Magento/SalesSequence/etc/module.xml b/app/code/Magento/SalesSequence/etc/module.xml index e200d84a3145e..dc6918830dbfa 100644 --- a/app/code/Magento/SalesSequence/etc/module.xml +++ b/app/code/Magento/SalesSequence/etc/module.xml @@ -6,9 +6,5 @@ */ --> - - - - - + diff --git a/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php b/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php index 88df47283133a..57a61fecae5ca 100644 --- a/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php +++ b/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php @@ -68,7 +68,7 @@ public function __construct( protected function configure() { $this->setName('sampledata:deploy') - ->setDescription('Deploy sample data modules'); + ->setDescription('Deploy sample data modules for composer-based Magento installations'); $this->addOption( self::OPTION_NO_UPDATE, null, @@ -83,6 +83,12 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { + $rootJson = json_decode($this->filesystem->getDirectoryRead(DirectoryList::ROOT)->readFile("composer.json")); + if (!isset($rootJson->version)) { + // @codingStandardsIgnoreLine + $output->writeln('' . 'Git installations must deploy sample data from GitHub; see https://devdocs.magento.com/guides/v2.3/install-gde/install/sample-data-after-clone.html for more information.' . ''); + return; + } $this->updateMemoryLimit(); $this->createAuthFile(); $sampleDataPackages = $this->sampleDataDependency->getSampleDataPackages(); diff --git a/app/code/Magento/SampleData/Model/Dependency.php b/app/code/Magento/SampleData/Model/Dependency.php index a475560dd6089..d08f2d9833e86 100644 --- a/app/code/Magento/SampleData/Model/Dependency.php +++ b/app/code/Magento/SampleData/Model/Dependency.php @@ -81,7 +81,7 @@ public function getSampleDataPackages() $suggests = array_merge($suggests, $this->getSuggestsFromModules()); foreach ($suggests as $name => $version) { if (strpos($version, self::SAMPLE_DATA_SUGGEST) === 0) { - $installExtensions[$name] = substr($version, strlen(self::SAMPLE_DATA_SUGGEST)); + $installExtensions[$name] = trim(substr($version, strlen(self::SAMPLE_DATA_SUGGEST))); } } return $installExtensions; diff --git a/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php b/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php index 090bb4256f807..a5790d40f782c 100644 --- a/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php +++ b/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php @@ -83,6 +83,9 @@ protected function setupMocks( $additionalComposerArgs = [] ) { $this->directoryReadMock->expects($this->any())->method('getAbsolutePath')->willReturn($pathToComposerJson); + $this->directoryReadMock->expects($this->any())->method('readFile')->with('composer.json')->willReturn( + '{"version": "0.0.1"}' + ); $this->filesystemMock->expects($this->any())->method('getDirectoryRead')->with(DirectoryList::ROOT)->willReturn( $this->directoryReadMock ); diff --git a/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php b/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php index 450b2d8798f52..7ca27a7d746c7 100644 --- a/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php +++ b/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php @@ -115,6 +115,15 @@ public function processDataProvider() */ public function testExecuteWithException() { + $this->directoryReadMock->expects($this->once()) + ->method('readFile') + ->with('composer.json') + ->willReturn('{"version": "0.0.1"}'); + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryRead') + ->with(DirectoryList::ROOT) + ->willReturn($this->directoryReadMock); + $this->directoryWriteMock->expects($this->once()) ->method('isExist') ->with(PackagesAuth::PATH_TO_AUTH_FILE) diff --git a/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php b/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php index adf5a9eb6ebe4..5343b4f867adb 100644 --- a/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php +++ b/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php @@ -152,8 +152,8 @@ public static function dataPackagesFromComposerSuggest() ]; }, 'suggestions' => [ - 'magento/foo-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . '100.0.0', - 'thirdparty/bar-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . '1.2.3', + 'magento/foo-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . ' 100.0.0', + 'thirdparty/bar-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . ' 1.2.3', 'thirdparty/something-else' => 'Just a suggested package', ], 'expectedPackages' => [ diff --git a/app/code/Magento/SampleData/composer.json b/app/code/Magento/SampleData/composer.json index 1b310a7b49943..17bb4d03dd55e 100644 --- a/app/code/Magento/SampleData/composer.json +++ b/app/code/Magento/SampleData/composer.json @@ -5,11 +5,11 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*" }, "suggest": { - "magento/sample-data-media": "Sample Data version:100.3.*" + "magento/sample-data-media": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Search/Model/SynonymAnalyzer.php b/app/code/Magento/Search/Model/SynonymAnalyzer.php index a63c10da169d7..eea6a950d7ce5 100644 --- a/app/code/Magento/Search/Model/SynonymAnalyzer.php +++ b/app/code/Magento/Search/Model/SynonymAnalyzer.php @@ -83,7 +83,7 @@ public function getSynonymsForPhrase($phrase) /** * Helper method to find the matching of $pattern to $synonymGroupsToExamine. * If matches, the particular array index is returned. - * Otherwise false will be returned. + * Otherwise null will be returned. * * @param string $pattern * @param array $synonymGroupsToExamine @@ -99,6 +99,7 @@ private function findInArray(string $pattern, array $synonymGroupsToExamine) } $position++; } + return null; } diff --git a/app/code/Magento/Search/composer.json b/app/code/Magento/Search/composer.json index aa58098dc1fa0..067d1bf2f7dfd 100644 --- a/app/code/Magento/Search/composer.json +++ b/app/code/Magento/Search/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog-search": "*", diff --git a/app/code/Magento/Search/view/frontend/web/form-mini.js b/app/code/Magento/Search/view/frontend/web/form-mini.js index de16305bbbe8d..27a15017cb3fc 100644 --- a/app/code/Magento/Search/view/frontend/web/form-mini.js +++ b/app/code/Magento/Search/view/frontend/web/form-mini.js @@ -55,7 +55,7 @@ define([ this.autoComplete = $(this.options.destinationSelector); this.searchForm = $(this.options.formSelector); this.submitBtn = this.searchForm.find(this.options.submitBtn)[0]; - this.searchLabel = $(this.options.searchLabel); + this.searchLabel = this.searchForm.find(this.options.searchLabel); this.isExpandable = this.options.isExpandable; _.bindAll(this, '_onKeyDown', '_onPropertyChange', '_onSubmit'); @@ -226,6 +226,7 @@ define([ case $.ui.keyCode.ENTER: this.searchForm.trigger('submit'); + e.preventDefault(); break; case $.ui.keyCode.DOWN: diff --git a/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php b/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php index 8d98d8145f17e..c5326da45005e 100644 --- a/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php +++ b/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php @@ -126,9 +126,9 @@ public function testConstructorException() { $securityChecker = $this->createMock(\Magento\Framework\Message\ManagerInterface::class); - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - __('Incorrect Security Checker class. It has to implement SecurityCheckerInterface') + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage( + (string)__('Incorrect Security Checker class. It has to implement SecurityCheckerInterface') ); $this->model->__construct( diff --git a/app/code/Magento/Security/composer.json b/app/code/Magento/Security/composer.json index 6af83b0eb8396..405b5b518097d 100644 --- a/app/code/Magento/Security/composer.json +++ b/app/code/Magento/Security/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-store": "*", diff --git a/app/code/Magento/SendFriend/composer.json b/app/code/Magento/SendFriend/composer.json index a77d3893cb227..30aecf13c3588 100644 --- a/app/code/Magento/SendFriend/composer.json +++ b/app/code/Magento/SendFriend/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-customer": "*", diff --git a/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php b/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php index 500fe0dd3b289..be0555fbcda40 100644 --- a/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php +++ b/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php @@ -7,6 +7,7 @@ namespace Magento\Shipping\Controller\Adminhtml\Order\Shipment; use Magento\Backend\App\Action; +use Magento\Framework\App\ObjectManager; class NewAction extends \Magento\Backend\App\Action { @@ -22,15 +23,24 @@ class NewAction extends \Magento\Backend\App\Action */ protected $shipmentLoader; + /** + * @var \Magento\Shipping\Model\ShipmentProviderInterface + */ + private $shipmentProvider; + /** * @param Action\Context $context * @param \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader $shipmentLoader + * @param \Magento\Shipping\Model\ShipmentProviderInterface $shipmentProvider */ public function __construct( Action\Context $context, - \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader $shipmentLoader + \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader $shipmentLoader, + \Magento\Shipping\Model\ShipmentProviderInterface $shipmentProvider = null ) { $this->shipmentLoader = $shipmentLoader; + $this->shipmentProvider = $shipmentProvider ?: ObjectManager::getInstance() + ->get(\Magento\Shipping\Model\ShipmentProviderInterface::class); parent::__construct($context); } @@ -43,7 +53,7 @@ public function execute() { $this->shipmentLoader->setOrderId($this->getRequest()->getParam('order_id')); $this->shipmentLoader->setShipmentId($this->getRequest()->getParam('shipment_id')); - $this->shipmentLoader->setShipment($this->getRequest()->getParam('shipment')); + $this->shipmentLoader->setShipment($this->shipmentProvider->getShipmentData()); $this->shipmentLoader->setTracking($this->getRequest()->getParam('tracking')); $shipment = $this->shipmentLoader->load(); if ($shipment) { diff --git a/app/code/Magento/Shipping/Model/ShipmentProvider.php b/app/code/Magento/Shipping/Model/ShipmentProvider.php new file mode 100644 index 0000000000000..917203af72460 --- /dev/null +++ b/app/code/Magento/Shipping/Model/ShipmentProvider.php @@ -0,0 +1,37 @@ +request = $request; + } + + /** + * @inheritdoc + */ + public function getShipmentData(): array + { + return $this->request->getParam('shipment', []); + } +} diff --git a/app/code/Magento/Shipping/Model/ShipmentProviderInterface.php b/app/code/Magento/Shipping/Model/ShipmentProviderInterface.php new file mode 100644 index 0000000000000..4ff9ba0008340 --- /dev/null +++ b/app/code/Magento/Shipping/Model/ShipmentProviderInterface.php @@ -0,0 +1,23 @@ +shipmentProviderMock = $this->getMockBuilder(\Magento\Shipping\Model\ShipmentProviderInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getShipmentData']) + ->getMockForAbstractClass(); $this->actionFlag = $this->createPartialMock(\Magento\Framework\App\ActionFlag::class, ['get']); $this->helper = $this->createPartialMock(\Magento\Backend\Helper\Data::class, ['getUrl']); $this->view = $this->createMock(\Magento\Framework\App\ViewInterface::class); @@ -166,7 +175,7 @@ protected function setUp() \Magento\Shipping\Controller\Adminhtml\Order\Shipment\NewAction::class, [ 'context' => $this->context, 'shipmentLoader' => $this->shipmentLoader, 'request' => $this->request, - 'response' => $this->response, 'view' => $this->view + 'response' => $this->response, 'view' => $this->view, 'shipmentProvider' => $this->shipmentProviderMock ] ); } @@ -188,7 +197,6 @@ public function testExecute() [ ['order_id', null, $orderId], ['shipment_id', null, $shipmentId], - ['shipment', null, $shipmentData], ['tracking', null, $tracking], ] ) @@ -261,6 +269,9 @@ public function testExecute() ->method('getBlock') ->with('menu') ->will($this->returnValue($menuBlock)); + $this->shipmentProviderMock->expects($this->once()) + ->method('getShipmentData') + ->willReturn($shipmentData); $this->assertNull($this->newAction->execute()); } diff --git a/app/code/Magento/Shipping/composer.json b/app/code/Magento/Shipping/composer.json index 9923e17ee9a31..b29a2fd537e96 100644 --- a/app/code/Magento/Shipping/composer.json +++ b/app/code/Magento/Shipping/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "ext-gd": "*", "magento/framework": "*", "magento/module-backend": "*", diff --git a/app/code/Magento/Shipping/etc/adminhtml/di.xml b/app/code/Magento/Shipping/etc/adminhtml/di.xml index 36bd1ae9d3505..33a41318dd08b 100644 --- a/app/code/Magento/Shipping/etc/adminhtml/di.xml +++ b/app/code/Magento/Shipping/etc/adminhtml/di.xml @@ -7,6 +7,7 @@ --> + diff --git a/app/code/Magento/Shipping/etc/di.xml b/app/code/Magento/Shipping/etc/di.xml index 5834678157058..51c77c2cecd2d 100644 --- a/app/code/Magento/Shipping/etc/di.xml +++ b/app/code/Magento/Shipping/etc/di.xml @@ -9,4 +9,22 @@ + + + + /var/log/shipping.log + + + + + + Magento\Shipping\Model\Carrier\VirtualDebug + + + + + + Magento\Shipping\Model\Method\VirtualLogger + + diff --git a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml index 2894bf2596783..0853aead3b8bb 100644 --- a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml +++ b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml @@ -15,6 +15,7 @@ + diff --git a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml index 677102cc77d1d..e0ef730a6942b 100644 --- a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml +++ b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml @@ -15,6 +15,7 @@ + diff --git a/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml b/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml index 0b40bf40e97a3..be4069ccdc186 100644 --- a/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml +++ b/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml @@ -52,6 +52,7 @@ +
    getChildHtml('extra_shipment_info') ?>
    getItemsHtml() ?>
    diff --git a/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml b/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml index 142b5e6014d7c..32805ec0a3495 100644 --- a/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml +++ b/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml @@ -103,6 +103,7 @@ $order = $block->getShipment()->getOrder(); +
    getChildHtml('extra_shipment_info') ?>
    diff --git a/app/code/Magento/Signifyd/Block/Fingerprint.php b/app/code/Magento/Signifyd/Block/Fingerprint.php index 7afa092b3d0da..db76fc6c94468 100644 --- a/app/code/Magento/Signifyd/Block/Fingerprint.php +++ b/app/code/Magento/Signifyd/Block/Fingerprint.php @@ -85,6 +85,8 @@ public function getSignifydOrderSessionId() */ public function isModuleActive() { - return $this->config->isActive(); + $storeId = $this->quoteSession->getQuote()->getStoreId(); + + return $this->config->isActive($storeId); } } diff --git a/app/code/Magento/Signifyd/Model/Config.php b/app/code/Magento/Signifyd/Model/Config.php index b68380ee15bf3..15d3608bd38c4 100644 --- a/app/code/Magento/Signifyd/Model/Config.php +++ b/app/code/Magento/Signifyd/Model/Config.php @@ -34,13 +34,15 @@ public function __construct(ScopeConfigInterface $scopeConfig) * If this config option set to false no Signifyd integration should be available * (only possibility to configure Signifyd setting in admin) * + * @param int|null $storeId * @return bool */ - public function isActive() + public function isActive($storeId = null): bool { $enabled = $this->scopeConfig->isSetFlag( 'fraud_protection/signifyd/active', - ScopeInterface::SCOPE_STORE + ScopeInterface::SCOPE_STORE, + $storeId ); return $enabled; } @@ -51,13 +53,15 @@ public function isActive() * @see https://www.signifyd.com/docs/api/#/introduction/authentication * @see https://app.signifyd.com/settings * + * @param int|null $storeId * @return string */ - public function getApiKey() + public function getApiKey($storeId = null): string { $apiKey = $this->scopeConfig->getValue( 'fraud_protection/signifyd/api_key', - ScopeInterface::SCOPE_STORE + ScopeInterface::SCOPE_STORE, + $storeId ); return $apiKey; } @@ -66,13 +70,15 @@ public function getApiKey() * Base URL to Signifyd REST API. * Usually equals to https://api.signifyd.com/v2 and should not be changed * + * @param int|null $storeId * @return string */ - public function getApiUrl() + public function getApiUrl($storeId = null): string { $apiUrl = $this->scopeConfig->getValue( 'fraud_protection/signifyd/api_url', - ScopeInterface::SCOPE_STORE + ScopeInterface::SCOPE_STORE, + $storeId ); return $apiUrl; } @@ -80,13 +86,15 @@ public function getApiUrl() /** * If is "true" extra information about interaction with Signifyd API are written to debug.log file * + * @param int|null $storeId * @return bool */ - public function isDebugModeEnabled() + public function isDebugModeEnabled($storeId = null): bool { $debugModeEnabled = $this->scopeConfig->isSetFlag( 'fraud_protection/signifyd/debug', - ScopeInterface::SCOPE_STORE + ScopeInterface::SCOPE_STORE, + $storeId ); return $debugModeEnabled; } diff --git a/app/code/Magento/Signifyd/Model/PaymentVerificationFactory.php b/app/code/Magento/Signifyd/Model/PaymentVerificationFactory.php index a26beda520944..5be5ccbc5e55a 100644 --- a/app/code/Magento/Signifyd/Model/PaymentVerificationFactory.php +++ b/app/code/Magento/Signifyd/Model/PaymentVerificationFactory.php @@ -60,7 +60,7 @@ public function __construct( * * @param string $paymentCode * @return PaymentVerificationInterface - * @throws \Exception + * @throws ConfigurationMismatchException */ public function createPaymentCvv($paymentCode) { @@ -73,7 +73,7 @@ public function createPaymentCvv($paymentCode) * * @param string $paymentCode * @return PaymentVerificationInterface - * @throws \Exception + * @throws ConfigurationMismatchException */ public function createPaymentAvs($paymentCode) { diff --git a/app/code/Magento/Signifyd/Model/SignifydGateway/ApiClient.php b/app/code/Magento/Signifyd/Model/SignifydGateway/ApiClient.php index 0950ca1e22cfa..2d6d57a510ae3 100644 --- a/app/code/Magento/Signifyd/Model/SignifydGateway/ApiClient.php +++ b/app/code/Magento/Signifyd/Model/SignifydGateway/ApiClient.php @@ -36,12 +36,13 @@ public function __construct( * * @param string $url * @param string $method - * @param array $params + * @param array $params + * @param int|null $storeId * @return array */ - public function makeApiCall($url, $method, array $params = []) + public function makeApiCall($url, $method, array $params = [], $storeId = null): array { - $result = $this->requestBuilder->doRequest($url, $method, $params); + $result = $this->requestBuilder->doRequest($url, $method, $params, $storeId); return $result; } diff --git a/app/code/Magento/Signifyd/Model/SignifydGateway/Client/HttpClientFactory.php b/app/code/Magento/Signifyd/Model/SignifydGateway/Client/HttpClientFactory.php index 41006bd7d1e0e..2a9b933b98b5d 100644 --- a/app/code/Magento/Signifyd/Model/SignifydGateway/Client/HttpClientFactory.php +++ b/app/code/Magento/Signifyd/Model/SignifydGateway/Client/HttpClientFactory.php @@ -73,12 +73,13 @@ public function __construct( * @param string $url * @param string $method * @param array $params + * @param int|null $storeId * @return ZendClient */ - public function create($url, $method, array $params = []) + public function create($url, $method, array $params = [], $storeId = null): ZendClient { - $apiKey = $this->getApiKey(); - $apiUrl = $this->buildFullApiUrl($url); + $apiKey = $this->getApiKey($storeId); + $apiUrl = $this->buildFullApiUrl($url, $storeId); $client = $this->createNewClient(); $client->setHeaders( @@ -107,22 +108,24 @@ private function createNewClient() * Signifyd API key for merchant account. * * @see https://www.signifyd.com/docs/api/#/introduction/authentication + * @param int|null $storeId * @return string */ - private function getApiKey() + private function getApiKey($storeId): string { - return $this->config->getApiKey(); + return $this->config->getApiKey($storeId); } /** * Full URL for Singifyd API based on relative URL. * * @param string $url + * @param int|null $storeId * @return string */ - private function buildFullApiUrl($url) + private function buildFullApiUrl($url, $storeId): string { - $baseApiUrl = $this->getBaseApiUrl(); + $baseApiUrl = $this->getBaseApiUrl($storeId); $fullUrl = $baseApiUrl . self::$urlSeparator . ltrim($url, self::$urlSeparator); return $fullUrl; @@ -131,11 +134,12 @@ private function buildFullApiUrl($url) /** * Base Sigifyd API URL without trailing slash. * + * @param int|null $storeId * @return string */ - private function getBaseApiUrl() + private function getBaseApiUrl($storeId): string { - $baseApiUrl = $this->config->getApiUrl(); + $baseApiUrl = $this->config->getApiUrl($storeId); return rtrim($baseApiUrl, self::$urlSeparator); } diff --git a/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestBuilder.php b/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestBuilder.php index 2ab4395e1990d..ee079a74d345f 100644 --- a/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestBuilder.php +++ b/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestBuilder.php @@ -5,8 +5,6 @@ */ namespace Magento\Signifyd\Model\SignifydGateway\Client; -use Magento\Framework\HTTP\ZendClient; - /** * Class RequestBuilder * Creates HTTP client, sends request to Signifyd and handles response @@ -50,13 +48,14 @@ public function __construct( * * @param string $url * @param string $method - * @param array $params + * @param array $params + * @param int|null $storeId * @return array */ - public function doRequest($url, $method, array $params = []) + public function doRequest($url, $method, array $params = [], $storeId = null): array { - $client = $this->clientCreator->create($url, $method, $params); - $response = $this->requestSender->send($client); + $client = $this->clientCreator->create($url, $method, $params, $storeId); + $response = $this->requestSender->send($client, $storeId); $result = $this->responseHandler->handle($response); return $result; diff --git a/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestSender.php b/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestSender.php index 38128a799fd59..a63331e055c1c 100644 --- a/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestSender.php +++ b/app/code/Magento/Signifyd/Model/SignifydGateway/Client/RequestSender.php @@ -39,15 +39,16 @@ public function __construct( * debug information is recorded to debug.log. * * @param ZendClient $client + * @param int|null $storeId * @return \Zend_Http_Response * @throws ApiCallException */ - public function send(ZendClient $client) + public function send(ZendClient $client, $storeId = null): \Zend_Http_Response { try { $response = $client->request(); - $this->debuggerFactory->create()->success( + $this->debuggerFactory->create($storeId)->success( $client->getUri(true), $client->getLastRequest(), $response->getStatus() . ' ' . $response->getMessage(), @@ -56,7 +57,7 @@ public function send(ZendClient $client) return $response; } catch (\Exception $e) { - $this->debuggerFactory->create()->failure( + $this->debuggerFactory->create($storeId)->failure( $client->getUri(true), $client->getLastRequest(), $e diff --git a/app/code/Magento/Signifyd/Model/SignifydGateway/Debugger/DebuggerFactory.php b/app/code/Magento/Signifyd/Model/SignifydGateway/Debugger/DebuggerFactory.php index 02031e6f5b9b5..1e61a313899cc 100644 --- a/app/code/Magento/Signifyd/Model/SignifydGateway/Debugger/DebuggerFactory.php +++ b/app/code/Magento/Signifyd/Model/SignifydGateway/Debugger/DebuggerFactory.php @@ -30,7 +30,7 @@ class DebuggerFactory /** * DebuggerFactory constructor. * - * @param bjectManagerInterface $objectManager + * @param ObjectManagerInterface $objectManager * @param Config $config */ public function __construct( @@ -44,11 +44,12 @@ public function __construct( /** * Create debugger instance * + * @param int|null $storeId * @return DebuggerInterface */ - public function create() + public function create($storeId = null): DebuggerInterface { - if (!$this->config->isDebugModeEnabled()) { + if (!$this->config->isDebugModeEnabled($storeId)) { return $this->objectManager->get(BlackHole::class); } diff --git a/app/code/Magento/Signifyd/Model/SignifydGateway/Gateway.php b/app/code/Magento/Signifyd/Model/SignifydGateway/Gateway.php index ddcaa6cd696f2..9f7a053c58724 100644 --- a/app/code/Magento/Signifyd/Model/SignifydGateway/Gateway.php +++ b/app/code/Magento/Signifyd/Model/SignifydGateway/Gateway.php @@ -5,8 +5,9 @@ */ namespace Magento\Signifyd\Model\SignifydGateway; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Signifyd\Api\CaseRepositoryInterface; use Magento\Signifyd\Model\SignifydGateway\Request\CreateCaseBuilderInterface; -use Magento\Signifyd\Model\SignifydGateway\ApiClient; /** * Signifyd Gateway. @@ -53,18 +54,34 @@ class Gateway */ private $apiClient; + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @var CaseRepositoryInterface + */ + private $caseRepository; + /** * Gateway constructor. * * @param CreateCaseBuilderInterface $createCaseBuilder * @param ApiClient $apiClient + * @param OrderRepositoryInterface $orderRepository + * @param CaseRepositoryInterface $caseRepository */ public function __construct( CreateCaseBuilderInterface $createCaseBuilder, - ApiClient $apiClient + ApiClient $apiClient, + OrderRepositoryInterface $orderRepository, + CaseRepositoryInterface $caseRepository ) { $this->createCaseBuilder = $createCaseBuilder; $this->apiClient = $apiClient; + $this->orderRepository = $orderRepository; + $this->caseRepository = $caseRepository; } /** @@ -78,11 +95,13 @@ public function __construct( public function createCase($orderId) { $caseParams = $this->createCaseBuilder->build($orderId); + $storeId = $this->getStoreIdFromOrder($orderId); $caseCreationResult = $this->apiClient->makeApiCall( '/cases', 'POST', - $caseParams + $caseParams, + $storeId ); if (!isset($caseCreationResult['investigationId'])) { @@ -102,12 +121,14 @@ public function createCase($orderId) */ public function submitCaseForGuarantee($signifydCaseId) { + $storeId = $this->getStoreIdFromCase($signifydCaseId); $guaranteeCreationResult = $this->apiClient->makeApiCall( '/guarantees', 'POST', [ 'caseId' => $signifydCaseId, - ] + ], + $storeId ); $disposition = $this->processDispositionResult($guaranteeCreationResult); @@ -124,12 +145,14 @@ public function submitCaseForGuarantee($signifydCaseId) */ public function cancelGuarantee($caseId) { + $storeId = $this->getStoreIdFromCase($caseId); $result = $this->apiClient->makeApiCall( '/cases/' . $caseId . '/guarantee', 'PUT', [ 'guaranteeDisposition' => self::GUARANTEE_CANCELED - ] + ], + $storeId ); $disposition = $this->processDispositionResult($result); @@ -172,4 +195,31 @@ private function processDispositionResult(array $result) return $disposition; } + + /** + * Returns store id by case. + * + * @param int $caseId + * @return int|null + */ + private function getStoreIdFromCase(int $caseId) + { + $case = $this->caseRepository->getByCaseId($caseId); + $orderId = $case->getOrderId(); + + return $this->getStoreIdFromOrder($orderId); + } + + /** + * Returns store id from order. + * + * @param int $orderId + * @return int|null + */ + private function getStoreIdFromOrder(int $orderId) + { + $order = $this->orderRepository->get($orderId); + + return $order->getStoreId(); + } } diff --git a/app/code/Magento/Signifyd/Model/SignifydGateway/Request/PurchaseBuilder.php b/app/code/Magento/Signifyd/Model/SignifydGateway/Request/PurchaseBuilder.php index 858ce0f0f3287..5e544e4b4048e 100644 --- a/app/code/Magento/Signifyd/Model/SignifydGateway/Request/PurchaseBuilder.php +++ b/app/code/Magento/Signifyd/Model/SignifydGateway/Request/PurchaseBuilder.php @@ -7,12 +7,13 @@ use Magento\Framework\App\Area; use Magento\Framework\Config\ScopeInterface; +use Magento\Framework\Exception\ConfigurationMismatchException; use Magento\Framework\Intl\DateTimeFactory; use Magento\Sales\Api\Data\OrderPaymentInterface; use Magento\Sales\Model\Order; +use Magento\Signifyd\Model\PaymentMethodMapper\PaymentMethodMapper; use Magento\Signifyd\Model\PaymentVerificationFactory; use Magento\Signifyd\Model\SignifydOrderSessionId; -use Magento\Signifyd\Model\PaymentMethodMapper\PaymentMethodMapper; /** * Prepare data related to purchase event represented in case creation request. @@ -72,6 +73,7 @@ public function __construct( * * @param Order $order * @return array + * @throws ConfigurationMismatchException */ public function build(Order $order) { @@ -202,6 +204,7 @@ private function getOrderChannel() * * @param OrderPaymentInterface $orderPayment * @return string + * @throws ConfigurationMismatchException */ private function getAvsCode(OrderPaymentInterface $orderPayment) { @@ -214,6 +217,7 @@ private function getAvsCode(OrderPaymentInterface $orderPayment) * * @param OrderPaymentInterface $orderPayment * @return string + * @throws ConfigurationMismatchException */ private function getCvvCode(OrderPaymentInterface $orderPayment) { diff --git a/app/code/Magento/Signifyd/Observer/PlaceOrder.php b/app/code/Magento/Signifyd/Observer/PlaceOrder.php index 3798522dbe506..8415bc006b8aa 100644 --- a/app/code/Magento/Signifyd/Observer/PlaceOrder.php +++ b/app/code/Magento/Signifyd/Observer/PlaceOrder.php @@ -55,10 +55,6 @@ public function __construct( */ public function execute(Observer $observer) { - if (!$this->signifydIntegrationConfig->isActive()) { - return; - } - $orders = $this->extractOrders( $observer->getEvent() ); @@ -68,7 +64,10 @@ public function execute(Observer $observer) } foreach ($orders as $order) { - $this->createCaseForOrder($order); + $storeId = $order->getStoreId(); + if ($this->signifydIntegrationConfig->isActive($storeId)) { + $this->createCaseForOrder($order); + } } } diff --git a/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/Client/HttpClientFactoryTest.php b/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/Client/HttpClientFactoryTest.php index 776e8a75b9646..4aefd63355773 100644 --- a/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/Client/HttpClientFactoryTest.php +++ b/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/Client/HttpClientFactoryTest.php @@ -101,14 +101,17 @@ public function testCreateHttpClient() public function testCreateWithParams() { $param = ['id' => 1]; + $storeId = 1; $json = '{"id":1}'; $this->config->expects($this->once()) ->method('getApiKey') + ->with($storeId) ->willReturn('testKey'); $this->config->expects($this->once()) ->method('getApiUrl') + ->with($storeId) ->willReturn(self::$dummy); $this->dataEncoder->expects($this->once()) @@ -121,7 +124,7 @@ public function testCreateWithParams() ->with($this->equalTo($json), 'application/json') ->willReturnSelf(); - $client = $this->httpClient->create('url', 'method', $param); + $client = $this->httpClient->create('url', 'method', $param, $storeId); $this->assertInstanceOf(ZendClient::class, $client); } diff --git a/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/GatewayTest.php b/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/GatewayTest.php index f7aa65f842b91..2a05189e0e393 100644 --- a/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/GatewayTest.php +++ b/app/code/Magento/Signifyd/Test/Unit/Model/SignifydGateway/GatewayTest.php @@ -5,7 +5,10 @@ */ namespace Magento\Signifyd\Test\Unit\Model\SignifydGateway; -use \PHPUnit\Framework\TestCase as TestCase; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Signifyd\Api\CaseRepositoryInterface; +use Magento\Signifyd\Api\Data\CaseInterface; use PHPUnit_Framework_MockObject_MockObject as MockObject; use Magento\Signifyd\Model\SignifydGateway\Gateway; use Magento\Signifyd\Model\SignifydGateway\GatewayException; @@ -30,6 +33,16 @@ class GatewayTest extends \PHPUnit\Framework\TestCase */ private $gateway; + /** + * @var OrderRepositoryInterface|MockObject + */ + private $orderRepository; + + /** + * @var CaseRepositoryInterface|MockObject + */ + private $caseRepository; + public function setUp() { $this->createCaseBuilder = $this->getMockBuilder(CreateCaseBuilderInterface::class) @@ -39,16 +52,27 @@ public function setUp() ->disableOriginalConstructor() ->getMock(); + $this->orderRepository = $this->getMockBuilder(OrderRepositoryInterface::class) + ->getMockForAbstractClass(); + + $this->caseRepository= $this->getMockBuilder(CaseRepositoryInterface::class) + ->getMockForAbstractClass(); + $this->gateway = new Gateway( $this->createCaseBuilder, - $this->apiClient + $this->apiClient, + $this->orderRepository, + $this->caseRepository ); } public function testCreateCaseForSpecifiedOrder() { $dummyOrderId = 1; + $dummyStoreId = 2; $dummySignifydInvestigationId = 42; + + $this->withOrderEntity($dummyOrderId, $dummyStoreId); $this->apiClient ->method('makeApiCall') ->willReturn([ @@ -68,7 +92,10 @@ public function testCreateCaseForSpecifiedOrder() public function testCreateCaseCallsValidApiMethod() { $dummyOrderId = 1; + $dummyStoreId = 2; $dummySignifydInvestigationId = 42; + + $this->withOrderEntity($dummyOrderId, $dummyStoreId); $this->createCaseBuilder ->method('build') ->willReturn([]); @@ -79,7 +106,8 @@ public function testCreateCaseCallsValidApiMethod() ->with( $this->equalTo('/cases'), $this->equalTo('POST'), - $this->isType('array') + $this->isType('array'), + $this->equalTo($dummyStoreId) ) ->willReturn([ 'investigationId' => $dummySignifydInvestigationId @@ -92,7 +120,10 @@ public function testCreateCaseCallsValidApiMethod() public function testCreateCaseNormalFlow() { $dummyOrderId = 1; + $dummyStoreId = 2; $dummySignifydInvestigationId = 42; + + $this->withOrderEntity($dummyOrderId, $dummyStoreId); $this->createCaseBuilder ->method('build') ->willReturn([]); @@ -113,7 +144,10 @@ public function testCreateCaseNormalFlow() public function testCreateCaseWithFailedApiCall() { $dummyOrderId = 1; + $dummyStoreId = 2; $apiCallFailureMessage = 'Api call failed'; + + $this->withOrderEntity($dummyOrderId, $dummyStoreId); $this->createCaseBuilder ->method('build') ->willReturn([]); @@ -121,16 +155,17 @@ public function testCreateCaseWithFailedApiCall() ->method('makeApiCall') ->willThrowException(new ApiCallException($apiCallFailureMessage)); - $this->expectException( - GatewayException::class, - $apiCallFailureMessage - ); + $this->expectException(GatewayException::class); + $this->expectExceptionMessage($apiCallFailureMessage); $this->gateway->createCase($dummyOrderId); } public function testCreateCaseWithMissedResponseRequiredData() { $dummyOrderId = 1; + $dummyStoreId = 2; + + $this->withOrderEntity($dummyOrderId, $dummyStoreId); $this->createCaseBuilder ->method('build') ->willReturn([]); @@ -147,7 +182,10 @@ public function testCreateCaseWithMissedResponseRequiredData() public function testCreateCaseWithAdditionalResponseData() { $dummyOrderId = 1; + $dummyStoreId = 2; $dummySignifydInvestigationId = 42; + + $this->withOrderEntity($dummyOrderId, $dummyStoreId); $this->createCaseBuilder ->method('build') ->willReturn([]); @@ -169,8 +207,10 @@ public function testCreateCaseWithAdditionalResponseData() public function testSubmitCaseForGuaranteeCallsValidApiMethod() { $dummySygnifydCaseId = 42; + $dummyStoreId = 1; $dummyDisposition = 'APPROVED'; + $this->withCaseEntity($dummySygnifydCaseId, $dummyStoreId); $this->apiClient ->expects($this->atLeastOnce()) ->method('makeApiCall') @@ -179,7 +219,8 @@ public function testSubmitCaseForGuaranteeCallsValidApiMethod() $this->equalTo('POST'), $this->equalTo([ 'caseId' => $dummySygnifydCaseId - ]) + ]), + $this->equalTo($dummyStoreId) )->willReturn([ 'disposition' => $dummyDisposition ]); @@ -191,16 +232,16 @@ public function testSubmitCaseForGuaranteeCallsValidApiMethod() public function testSubmitCaseForGuaranteeWithFailedApiCall() { $dummySygnifydCaseId = 42; + $dummyStoreId = 1; $apiCallFailureMessage = 'Api call failed'; + $this->withCaseEntity($dummySygnifydCaseId, $dummyStoreId); $this->apiClient ->method('makeApiCall') ->willThrowException(new ApiCallException($apiCallFailureMessage)); - $this->expectException( - GatewayException::class, - $apiCallFailureMessage - ); + $this->expectException(GatewayException::class); + $this->expectExceptionMessage($apiCallFailureMessage); $result = $this->gateway->submitCaseForGuarantee($dummySygnifydCaseId); $this->assertEquals('Api call failed', $result); } @@ -208,10 +249,12 @@ public function testSubmitCaseForGuaranteeWithFailedApiCall() public function testSubmitCaseForGuaranteeReturnsDisposition() { $dummySygnifydCaseId = 42; + $dummyStoreId = 1; $dummyDisposition = 'APPROVED'; $dummyGuaranteeId = 123; $dummyRereviewCount = 0; + $this->withCaseEntity($dummySygnifydCaseId, $dummyStoreId); $this->apiClient ->method('makeApiCall') ->willReturn([ @@ -231,9 +274,11 @@ public function testSubmitCaseForGuaranteeReturnsDisposition() public function testSubmitCaseForGuaranteeWithMissedDisposition() { $dummySygnifydCaseId = 42; + $dummyStoreId = 1; $dummyGuaranteeId = 123; $dummyRereviewCount = 0; + $this->withCaseEntity($dummySygnifydCaseId, $dummyStoreId); $this->apiClient ->method('makeApiCall') ->willReturn([ @@ -248,8 +293,10 @@ public function testSubmitCaseForGuaranteeWithMissedDisposition() public function testSubmitCaseForGuaranteeWithUnexpectedDisposition() { $dummySygnifydCaseId = 42; + $dummyStoreId = 1; $dummyUnexpectedDisposition = 'UNEXPECTED'; + $this->withCaseEntity($dummySygnifydCaseId, $dummyStoreId); $this->apiClient ->method('makeApiCall') ->willReturn([ @@ -267,7 +314,9 @@ public function testSubmitCaseForGuaranteeWithUnexpectedDisposition() public function testSubmitCaseForGuaranteeWithExpectedDisposition($dummyExpectedDisposition) { $dummySygnifydCaseId = 42; + $dummyStoreId = 1; + $this->withCaseEntity($dummySygnifydCaseId, $dummyStoreId); $this->apiClient ->method('makeApiCall') ->willReturn([ @@ -294,11 +343,20 @@ public function testSubmitCaseForGuaranteeWithExpectedDisposition($dummyExpected public function testCancelGuarantee() { $caseId = 123; + $dummyStoreId = 1; + $this->withCaseEntity($caseId, $dummyStoreId); $this->apiClient->expects(self::once()) ->method('makeApiCall') - ->with('/cases/' . $caseId . '/guarantee', 'PUT', ['guaranteeDisposition' => Gateway::GUARANTEE_CANCELED]) - ->willReturn(['disposition' => Gateway::GUARANTEE_CANCELED]); + ->with( + '/cases/' . $caseId . '/guarantee', + 'PUT', + ['guaranteeDisposition' => Gateway::GUARANTEE_CANCELED], + $dummyStoreId + ) + ->willReturn( + ['disposition' => Gateway::GUARANTEE_CANCELED] + ); $result = $this->gateway->cancelGuarantee($caseId); self::assertEquals(Gateway::GUARANTEE_CANCELED, $result); @@ -314,10 +372,17 @@ public function testCancelGuarantee() public function testCancelGuaranteeWithUnexpectedDisposition() { $caseId = 123; + $dummyStoreId = 1; + $this->withCaseEntity($caseId, $dummyStoreId); $this->apiClient->expects(self::once()) ->method('makeApiCall') - ->with('/cases/' . $caseId . '/guarantee', 'PUT', ['guaranteeDisposition' => Gateway::GUARANTEE_CANCELED]) + ->with( + '/cases/' . $caseId . '/guarantee', + 'PUT', + ['guaranteeDisposition' => Gateway::GUARANTEE_CANCELED], + $dummyStoreId + ) ->willReturn(['disposition' => Gateway::GUARANTEE_DECLINED]); $result = $this->gateway->cancelGuarantee($caseId); @@ -335,4 +400,46 @@ public function supportedGuaranteeDispositionsProvider() 'UNREQUESTED' => ['UNREQUESTED'], ]; } + + /** + * Specifies order entity mock execution. + * + * @param int $orderId + * @param int $storeId + * @return void + */ + private function withOrderEntity(int $orderId, int $storeId): void + { + $orderEntity = $this->getMockBuilder(OrderInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $orderEntity->method('getStoreId') + ->willReturn($storeId); + $this->orderRepository->method('get') + ->with($orderId) + ->willReturn($orderEntity); + } + + /** + * Specifies case entity mock execution. + * + * @param int $caseId + * @param int $storeId + * @return void + */ + private function withCaseEntity(int $caseId, int $storeId): void + { + $orderId = 1; + + $caseEntity = $this->getMockBuilder(CaseInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $caseEntity->method('getOrderId') + ->willReturn($orderId); + $this->caseRepository->method('getByCaseId') + ->with($caseId) + ->willReturn($caseEntity); + + $this->withOrderEntity($orderId, $storeId); + } } diff --git a/app/code/Magento/Signifyd/Test/Unit/Observer/PlaceOrderTest.php b/app/code/Magento/Signifyd/Test/Unit/Observer/PlaceOrderTest.php index e2870953ec280..4e7edddf7b948 100644 --- a/app/code/Magento/Signifyd/Test/Unit/Observer/PlaceOrderTest.php +++ b/app/code/Magento/Signifyd/Test/Unit/Observer/PlaceOrderTest.php @@ -97,7 +97,10 @@ protected function setUp() */ public function testExecuteWithDisabledModule() { - $this->withActiveSignifydIntegration(false); + $orderId = 1; + $storeId = 2; + $this->withActiveSignifydIntegration(false, $storeId); + $this->withOrderEntity($orderId, $storeId); $this->creationService->expects(self::never()) ->method('createForOrder'); @@ -113,7 +116,7 @@ public function testExecuteWithDisabledModule() public function testExecuteWithoutOrder() { $this->withActiveSignifydIntegration(true); - $this->withOrderEntity(null); + $this->withOrderEntity(null, null); $this->creationService->expects(self::never()) ->method('createForOrder'); @@ -129,8 +132,9 @@ public function testExecuteWithoutOrder() public function testExecuteWithOfflinePayment() { $orderId = 1; - $this->withActiveSignifydIntegration(true); - $this->withOrderEntity($orderId); + $storeId = 2; + $this->withActiveSignifydIntegration(true, $storeId); + $this->withOrderEntity($orderId, $storeId); $this->withAvailablePaymentMethod(false); $this->creationService->expects(self::never()) @@ -147,10 +151,11 @@ public function testExecuteWithOfflinePayment() public function testExecuteWithFailedCaseCreation() { $orderId = 1; + $storeId = 2; $exceptionMessage = __('Case with the same order id already exists.'); - $this->withActiveSignifydIntegration(true); - $this->withOrderEntity($orderId); + $this->withActiveSignifydIntegration(true, $storeId); + $this->withOrderEntity($orderId, $storeId); $this->withAvailablePaymentMethod(true); $this->creationService->method('createForOrder') @@ -172,9 +177,10 @@ public function testExecuteWithFailedCaseCreation() public function testExecute() { $orderId = 1; + $storeId = 2; - $this->withActiveSignifydIntegration(true); - $this->withOrderEntity($orderId); + $this->withActiveSignifydIntegration(true, $storeId); + $this->withOrderEntity($orderId, $storeId); $this->withAvailablePaymentMethod(true); $this->creationService @@ -190,10 +196,11 @@ public function testExecute() /** * Specifies order entity mock execution. * - * @param int $orderId + * @param int|null $orderId + * @param int|null $storeId * @return void */ - private function withOrderEntity($orderId) + private function withOrderEntity($orderId, $storeId): void { $this->orderEntity = $this->getMockBuilder(OrderInterface::class) ->disableOriginalConstructor() @@ -201,6 +208,8 @@ private function withOrderEntity($orderId) $this->orderEntity->method('getEntityId') ->willReturn($orderId); + $this->orderEntity->method('getStoreId') + ->willReturn($storeId); $this->observer->method('getEvent') ->willReturn($this->event); @@ -214,11 +223,13 @@ private function withOrderEntity($orderId) * Specifies config mock execution. * * @param bool $isActive + * @param int|null $storeId * @return void */ - private function withActiveSignifydIntegration($isActive) + private function withActiveSignifydIntegration(bool $isActive, $storeId = null): void { $this->config->method('isActive') + ->with($storeId) ->willReturn($isActive); } diff --git a/app/code/Magento/Signifyd/composer.json b/app/code/Magento/Signifyd/composer.json index 0a7ff0cc1a569..c766ccd848ca4 100644 --- a/app/code/Magento/Signifyd/composer.json +++ b/app/code/Magento/Signifyd/composer.json @@ -14,7 +14,7 @@ "magento/module-payment": "*", "magento/module-sales": "*", "magento/module-store": "*", - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0" + "php": "~7.1.3||~7.2.0" }, "suggest": { "magento/module-config": "*" diff --git a/app/code/Magento/Signifyd/etc/adminhtml/system.xml b/app/code/Magento/Signifyd/etc/adminhtml/system.xml index d9ba2f7ffdff2..71f5916ca5325 100644 --- a/app/code/Magento/Signifyd/etc/adminhtml/system.xml +++ b/app/code/Magento/Signifyd/etc/adminhtml/system.xml @@ -13,7 +13,7 @@ Magento_Sales::fraud_protection signifyd-logo-header - + Magento\Signifyd\Block\Adminhtml\System\Config\Fieldset\Info signifyd-about-header @@ -26,12 +26,12 @@ https://www.signifyd.com/magento-guaranteed-fraud-protection - + signifyd-about-header View our setup guide for step-by-step instructions on how to integrate Signifyd with Magento.
    For support contact support@signifyd.com.]]>
    - + Magento\Config\Model\Config\Source\Yesno fraud_protection/signifyd/active diff --git a/app/code/Magento/Signifyd/etc/db_schema.xml b/app/code/Magento/Signifyd/etc/db_schema.xml index d416112c20d70..6a31eacbb45f5 100644 --- a/app/code/Magento/Signifyd/etc/db_schema.xml +++ b/app/code/Magento/Signifyd/etc/db_schema.xml @@ -7,7 +7,7 @@ --> -
    +
    - +
    diff --git a/app/code/Magento/Signifyd/etc/di.xml b/app/code/Magento/Signifyd/etc/di.xml index 92ad8a0bfd87a..fd78fff27f619 100644 --- a/app/code/Magento/Signifyd/etc/di.xml +++ b/app/code/Magento/Signifyd/etc/di.xml @@ -15,11 +15,7 @@ - - - U - - + diff --git a/app/code/Magento/Sitemap/Model/Observer.php b/app/code/Magento/Sitemap/Model/Observer.php index 840a6a1858fae..a536ec998b827 100644 --- a/app/code/Magento/Sitemap/Model/Observer.php +++ b/app/code/Magento/Sitemap/Model/Observer.php @@ -19,6 +19,8 @@ class Observer /** * Cronjob expression configuration + * + * @deprecated Use \Magento\Cron\Model\Config\Backend\Sitemap::CRON_STRING_PATH instead. */ const XML_PATH_CRON_EXPR = 'crontab/default/jobs/generate_sitemaps/schedule/cron_expr'; diff --git a/app/code/Magento/Sitemap/Model/ResourceModel/Catalog/Product.php b/app/code/Magento/Sitemap/Model/ResourceModel/Catalog/Product.php index 0a3e36570ca2f..11a59cfa59f17 100644 --- a/app/code/Magento/Sitemap/Model/ResourceModel/Catalog/Product.php +++ b/app/code/Magento/Sitemap/Model/ResourceModel/Catalog/Product.php @@ -5,9 +5,12 @@ */ namespace Magento\Sitemap\Model\ResourceModel\Catalog; +use Magento\Catalog\Model\Product\Image\UrlBuilder; use Magento\CatalogUrlRewrite\Model\ProductUrlRewriteGenerator; use Magento\Store\Model\Store; use Magento\Framework\App\ObjectManager; +use Magento\Store\Model\ScopeInterface; +use Magento\Catalog\Helper\Product as HelperProduct; /** * Sitemap resource product collection model @@ -89,6 +92,18 @@ class Product extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb */ private $catalogImageHelper; + /** + * @var UrlBuilder + */ + private $imageUrlBuilder; + + /** + * Scope Config + * + * @var \Magento\Framework\App\Config\ScopeConfigInterface + */ + private $scopeConfig; + /** * @param \Magento\Framework\Model\ResourceModel\Db\Context $context * @param \Magento\Sitemap\Helper\Data $sitemapData @@ -102,6 +117,8 @@ class Product extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb * @param string $connectionName * @param \Magento\Catalog\Model\Product $productModel * @param \Magento\Catalog\Helper\Image $catalogImageHelper + * @param \Magento\Framework\App\Config\ScopeConfigInterface|null $scopeConfig + * @param UrlBuilder $urlBuilder * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -116,7 +133,9 @@ public function __construct( \Magento\Catalog\Model\Product\Media\Config $mediaConfig, $connectionName = null, \Magento\Catalog\Model\Product $productModel = null, - \Magento\Catalog\Helper\Image $catalogImageHelper = null + \Magento\Catalog\Helper\Image $catalogImageHelper = null, + \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig = null, + UrlBuilder $urlBuilder = null ) { $this->_productResource = $productResource; $this->_storeManager = $storeManager; @@ -127,8 +146,13 @@ public function __construct( $this->_mediaConfig = $mediaConfig; $this->_sitemapData = $sitemapData; $this->productModel = $productModel ?: ObjectManager::getInstance()->get(\Magento\Catalog\Model\Product::class); + $this->catalogImageHelper = $catalogImageHelper; + $this->imageUrlBuilder = $urlBuilder ?? ObjectManager::getInstance()->get(UrlBuilder::class); $this->catalogImageHelper = $catalogImageHelper ?: ObjectManager::getInstance() ->get(\Magento\Catalog\Helper\Image::class); + $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance() + ->get(\Magento\Framework\App\Config\ScopeConfigInterface::class); + parent::__construct($context, $connectionName); } @@ -272,6 +296,10 @@ public function getCollection($storeId) } $connection = $this->getConnection(); + $urlsConfigCondition = ''; + if ($this->isCategoryProductURLsConfig($storeId)) { + $urlsConfigCondition = 'NOT '; + } $this->_select = $connection->select()->from( ['e' => $this->getMainTable()], @@ -282,7 +310,8 @@ public function getCollection($storeId) [] )->joinLeft( ['url_rewrite' => $this->getTable('url_rewrite')], - 'e.entity_id = url_rewrite.entity_id AND url_rewrite.is_autogenerated = 1 AND url_rewrite.metadata IS NULL' + 'e.entity_id = url_rewrite.entity_id AND url_rewrite.is_autogenerated = 1 AND url_rewrite.metadata IS ' + . $urlsConfigCondition . 'NULL' . $connection->quoteInto(' AND url_rewrite.store_id = ?', $store->getId()) . $connection->quoteInto(' AND url_rewrite.entity_type = ?', ProductUrlRewriteGenerator::ENTITY_TYPE), ['url' => 'request_path'] @@ -434,19 +463,29 @@ public function prepareSelectStatement(\Magento\Framework\DB\Select $select) } /** - * Get product image URL from image filename and path + * Get product image URL from image filename * * @param string $image * @return string */ private function getProductImageUrl($image) { - $productObject = $this->productModel; - $imgUrl = $this->catalogImageHelper - ->init($productObject, 'product_page_image_large') - ->setImageFile($image) - ->getUrl(); + return $this->imageUrlBuilder->getUrl($image, 'product_page_image_large'); + } - return $imgUrl; + /** + * Return Use Categories Path for Product URLs config value + * + * @param $storeId + * + * @return bool + */ + private function isCategoryProductURLsConfig($storeId) + { + return (bool)$this->scopeConfig->getValue( + HelperProduct::XML_PATH_PRODUCT_URL_USE_CATEGORY, + ScopeInterface::SCOPE_STORE, + $storeId + ); } } diff --git a/app/code/Magento/Sitemap/Model/Sitemap.php b/app/code/Magento/Sitemap/Model/Sitemap.php index f78c3d656ed58..d58ff732c81d7 100644 --- a/app/code/Magento/Sitemap/Model/Sitemap.php +++ b/app/code/Magento/Sitemap/Model/Sitemap.php @@ -646,7 +646,7 @@ protected function _finalizeSitemap($type = self::TYPE_URL) */ protected function _getCurrentSitemapFilename($index) { - return self::INDEX_FILE_PREFIX . '-' . $this->getStoreId() . '-' . $index . '.xml'; + return str_replace('.xml', '', $this->getSitemapFilename()) . '-' . $this->getStoreId() . '-' . $index . '.xml'; } /** diff --git a/app/code/Magento/Sitemap/composer.json b/app/code/Magento/Sitemap/composer.json index ed64088883028..f537832bdc3de 100644 --- a/app/code/Magento/Sitemap/composer.json +++ b/app/code/Magento/Sitemap/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-backend": "*", "magento/module-catalog": "*", diff --git a/app/code/Magento/Sitemap/etc/config.xml b/app/code/Magento/Sitemap/etc/config.xml index 73468baadcb90..6f14ff728ac4f 100644 --- a/app/code/Magento/Sitemap/etc/config.xml +++ b/app/code/Magento/Sitemap/etc/config.xml @@ -42,5 +42,16 @@ + + + + + + 0 0 * * * + + + + + diff --git a/app/code/Magento/Store/App/Action/Plugin/Context.php b/app/code/Magento/Store/App/Action/Plugin/Context.php index 66fec992b38d7..6ec6cf01bc71c 100644 --- a/app/code/Magento/Store/App/Action/Plugin/Context.php +++ b/app/code/Magento/Store/App/Action/Plugin/Context.php @@ -7,7 +7,10 @@ namespace Magento\Store\App\Action\Plugin; use Magento\Framework\App\Http\Context as HttpContext; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Exception\NotFoundException; use Magento\Framework\Phrase; +use Magento\Store\Api\Data\StoreInterface; use Magento\Store\Api\StoreCookieManagerInterface; use Magento\Store\Api\StoreResolverInterface; use Magento\Store\Model\StoreManagerInterface; @@ -58,42 +61,102 @@ public function __construct( } /** - * Set store and currency to http context + * Set store and currency to http context. * * @param AbstractAction $subject * @param RequestInterface $request * @return void * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function beforeDispatch(AbstractAction $subject, RequestInterface $request) - { - /** @var \Magento\Store\Model\Store $defaultStore */ - $defaultStore = $this->storeManager->getWebsite()->getDefaultStore(); + public function beforeDispatch( + AbstractAction $subject, + RequestInterface $request + ) { + if ($this->isAlreadySet()) { + //If required store related value were already set for + //HTTP processors then just continuing as we were. + return; + } + /** @var string|array|null $storeCode */ $storeCode = $request->getParam( StoreResolverInterface::PARAM_NAME, $this->storeCookieManager->getStoreCodeFromCookie() ); - if (is_array($storeCode)) { if (!isset($storeCode['_data']['code'])) { - throw new \InvalidArgumentException(new Phrase('Invalid store parameter.')); + $this->processInvalidStoreRequested(); } $storeCode = $storeCode['_data']['code']; } - /** @var \Magento\Store\Model\Store $currentStore */ - $currentStore = $storeCode ? $this->storeManager->getStore($storeCode) : $defaultStore; + if ($storeCode === '') { + //Empty code - is an invalid code and it was given explicitly + //(the value would be null if the code wasn't found). + $this->processInvalidStoreRequested(); + } + try { + $currentStore = $this->storeManager->getStore($storeCode); + } catch (NoSuchEntityException $exception) { + $this->processInvalidStoreRequested($exception); + } + + $this->updateContext($currentStore); + } + + /** + * Take action in case of invalid store requested. + * + * @param \Throwable|null $previousException + * @return void + * @throws NotFoundException + */ + private function processInvalidStoreRequested( + \Throwable $previousException = null + ) { + $store = $this->storeManager->getStore(); + $this->updateContext($store); + throw new NotFoundException( + $previousException + ? __($previousException->getMessage()) + : __('Invalid store requested.'), + $previousException + ); + } + + /** + * Update context accordingly to the store found. + * + * @param StoreInterface $store + * @return void + */ + private function updateContext(StoreInterface $store) + { $this->httpContext->setValue( StoreManagerInterface::CONTEXT_STORE, - $currentStore->getCode(), + $store->getCode(), $this->storeManager->getDefaultStoreView()->getCode() ); + /** @var StoreInterface $defaultStore */ + $defaultStore = $this->storeManager->getWebsite()->getDefaultStore(); $this->httpContext->setValue( HttpContext::CONTEXT_CURRENCY, - $this->session->getCurrencyCode() ?: $currentStore->getDefaultCurrencyCode(), + $this->session->getCurrencyCode() + ?: $store->getDefaultCurrencyCode(), $defaultStore->getDefaultCurrencyCode() ); } + + /** + * Check if there is a need to find the current store. + * + * @return bool + */ + private function isAlreadySet(): bool + { + $storeKey = StoreManagerInterface::CONTEXT_STORE; + + return $this->httpContext->getValue($storeKey) !== null; + } } diff --git a/app/code/Magento/Store/Console/Command/StoreListCommand.php b/app/code/Magento/Store/Console/Command/StoreListCommand.php index aaaa8afb76fd2..fcd9600aac684 100644 --- a/app/code/Magento/Store/Console/Command/StoreListCommand.php +++ b/app/code/Magento/Store/Console/Command/StoreListCommand.php @@ -6,6 +6,7 @@ */ namespace Magento\Store\Console\Command; +use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Command\Command; @@ -48,7 +49,7 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { try { - $table = $this->getHelperSet()->get('table'); + $table = new Table($output); $table->setHeaders(['ID', 'Website ID', 'Group ID', 'Name', 'Code', 'Sort Order', 'Is Active']); foreach ($this->storeManager->getStores(true, true) as $store) { @@ -63,7 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ]); } - $table->render($output); + $table->render(); return \Magento\Framework\Console\Cli::RETURN_SUCCESS; } catch (\Exception $e) { diff --git a/app/code/Magento/Store/Console/Command/WebsiteListCommand.php b/app/code/Magento/Store/Console/Command/WebsiteListCommand.php index ce0359d1bb799..985a6402e4e2f 100644 --- a/app/code/Magento/Store/Console/Command/WebsiteListCommand.php +++ b/app/code/Magento/Store/Console/Command/WebsiteListCommand.php @@ -6,6 +6,7 @@ */ namespace Magento\Store\Console\Command; +use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Command\Command; @@ -48,7 +49,7 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { try { - $table = $this->getHelperSet()->get('table'); + $table = new Table($output); $table->setHeaders(['ID', 'Default Group Id', 'Name', 'Code', 'Sort Order', 'Is Default']); foreach ($this->manager->getList() as $website) { @@ -62,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ]); } - $table->render($output); + $table->render(); return \Magento\Framework\Console\Cli::RETURN_SUCCESS; } catch (\Exception $e) { diff --git a/app/code/Magento/Store/Model/Config/Importer/Processor/Create.php b/app/code/Magento/Store/Model/Config/Importer/Processor/Create.php index edae49b77ff27..513ba04802985 100644 --- a/app/code/Magento/Store/Model/Config/Importer/Processor/Create.php +++ b/app/code/Magento/Store/Model/Config/Importer/Processor/Create.php @@ -53,6 +53,10 @@ class Create implements ProcessorInterface /** * The event manager. * + * @deprecated logic moved inside of "afterSave" method + * \Magento\Store\Model\Website::afterSave + * \Magento\Store\Model\Group::afterSave + * \Magento\Store\Model\Store::afterSave * @var ManagerInterface */ private $eventManager; @@ -182,8 +186,6 @@ private function createGroups(array $items, array $data) $group->setDefaultStoreId($store->getStoreId()); $group->setWebsite($website); $group->getResource()->save($group); - - $this->eventManager->dispatch('store_group_save', ['group' => $group]); }); } } diff --git a/app/code/Magento/Store/Model/Config/Importer/Processor/Delete.php b/app/code/Magento/Store/Model/Config/Importer/Processor/Delete.php index 8660a0ba7152d..475c15122773e 100644 --- a/app/code/Magento/Store/Model/Config/Importer/Processor/Delete.php +++ b/app/code/Magento/Store/Model/Config/Importer/Processor/Delete.php @@ -168,10 +168,7 @@ private function deleteStores(array $items) foreach ($items as $storeCode) { $store = $this->storeRepository->get($storeCode); - $store->getResource()->delete($store); - $store->getResource()->addCommitCallback(function () use ($store) { - $this->eventManager->dispatch('store_delete', ['store' => $store]); - }); + $store->delete(); } } diff --git a/app/code/Magento/Store/Model/Config/Importer/Processor/Update.php b/app/code/Magento/Store/Model/Config/Importer/Processor/Update.php index 35f3957b168d7..155506291e59d 100644 --- a/app/code/Magento/Store/Model/Config/Importer/Processor/Update.php +++ b/app/code/Magento/Store/Model/Config/Importer/Processor/Update.php @@ -176,10 +176,7 @@ private function updateStores(array $items, array $data) $store->setGroup($group); } - $store->getResource()->save($store); - $store->getResource()->addCommitCallback(function () use ($store) { - $this->eventManager->dispatch('store_edit', ['store' => $store]); - }); + $store->save(); } } @@ -214,11 +211,7 @@ private function updateGroups(array $items, array $data) if ($website && $website->getId() != $group->getWebsiteId()) { $group->setWebsite($website); } - - $group->getResource()->save($group); - $group->getResource()->addCommitCallback(function () use ($group) { - $this->eventManager->dispatch('store_group_save', ['group' => $group]); - }); + $group->save(); } } diff --git a/app/code/Magento/Store/Model/Group.php b/app/code/Magento/Store/Model/Group.php index 01f56fb9e0566..ccc3c65491422 100644 --- a/app/code/Magento/Store/Model/Group.php +++ b/app/code/Magento/Store/Model/Group.php @@ -95,6 +95,11 @@ class Group extends \Magento\Framework\Model\AbstractExtensibleModel implements */ protected $_storeManager; + /** + * @var \Magento\Framework\Event\ManagerInterface + */ + private $eventManager; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -106,6 +111,7 @@ class Group extends \Magento\Framework\Model\AbstractExtensibleModel implements * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data + * @param \Magento\Framework\Event\ManagerInterface|null $eventManager * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -118,11 +124,14 @@ public function __construct( \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + \Magento\Framework\Event\ManagerInterface $eventManager = null ) { $this->_configDataResource = $configDataResource; $this->_storeListFactory = $storeListFactory; $this->_storeManager = $storeManager; + $this->eventManager = $eventManager ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Framework\Event\ManagerInterface::class); parent::__construct( $context, $registry, @@ -405,6 +414,11 @@ public function beforeDelete() */ public function afterDelete() { + $group = $this; + $this->getResource()->addCommitCallback(function () use ($group) { + $this->_storeManager->reinitStores(); + $this->eventManager->dispatch($this->_eventPrefix . '_delete', ['group' => $group]); + }); $result = parent::afterDelete(); if ($this->getId() === $this->getWebsite()->getDefaultGroupId()) { @@ -421,6 +435,19 @@ public function afterDelete() return $result; } + /** + * @inheritdoc + */ + public function afterSave() + { + $group = $this; + $this->getResource()->addCommitCallback(function () use ($group) { + $this->_storeManager->reinitStores(); + $this->eventManager->dispatch($this->_eventPrefix . '_save', ['group' => $group]); + }); + return parent::afterSave(); + } + /** * Get/Set isReadOnly flag * diff --git a/app/code/Magento/Store/Model/PathConfig.php b/app/code/Magento/Store/Model/PathConfig.php index dfe4eee31f9a2..6eeb93d7475fa 100644 --- a/app/code/Magento/Store/Model/PathConfig.php +++ b/app/code/Magento/Store/Model/PathConfig.php @@ -83,6 +83,8 @@ public function shouldBeSecure($path) */ public function getDefaultPath() { - return $this->scopeConfig->getValue('web/default/front', ScopeInterface::SCOPE_STORE); + $store = $this->storeManager->getStore(); + $value = $this->scopeConfig->getValue('web/default/front', ScopeInterface::SCOPE_STORE, $store); + return $value; } } diff --git a/app/code/Magento/Store/Model/Store.php b/app/code/Magento/Store/Model/Store.php index 242ca3a2891c4..6e749df6a768d 100644 --- a/app/code/Magento/Store/Model/Store.php +++ b/app/code/Magento/Store/Model/Store.php @@ -18,6 +18,7 @@ use Magento\Framework\Url\ScopeInterface as UrlScopeInterface; use Magento\Framework\UrlInterface; use Magento\Store\Api\Data\StoreInterface; +use Zend\Uri\UriFactory; /** * Store model @@ -319,6 +320,11 @@ class Store extends AbstractExtensibleModel implements */ private $urlModifier; + /** + * @var \Magento\Framework\Event\ManagerInterface + */ + private $eventManager; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -344,6 +350,7 @@ class Store extends AbstractExtensibleModel implements * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param bool $isCustomEntryPoint * @param array $data optional generic object data + * @param \Magento\Framework\Event\ManagerInterface|null $eventManager * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -371,7 +378,8 @@ public function __construct( \Magento\Store\Api\WebsiteRepositoryInterface $websiteRepository, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, $isCustomEntryPoint = false, - array $data = [] + array $data = [], + \Magento\Framework\Event\ManagerInterface $eventManager = null ) { $this->_coreFileStorageDatabase = $coreFileStorageDatabase; $this->_config = $config; @@ -390,6 +398,8 @@ public function __construct( $this->_currencyInstalled = $currencyInstalled; $this->groupRepository = $groupRepository; $this->websiteRepository = $websiteRepository; + $this->eventManager = $eventManager ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Framework\Event\ManagerInterface::class); parent::__construct( $context, $registry, @@ -801,7 +811,7 @@ public function isCurrentlySecure() return false; } - $uri = \Zend_Uri::factory($secureBaseUrl); + $uri = UriFactory::factory($secureBaseUrl); $port = $uri->getPort(); $serverPort = $this->_request->getServer('SERVER_PORT'); $isSecure = $uri->getScheme() == 'https' && isset($serverPort) && $port == $serverPort; @@ -1048,6 +1058,15 @@ public function getWebsiteId() public function afterSave() { $this->_storeManager->reinitStores(); + if ($this->isObjectNew()) { + $event = $this->_eventPrefix . '_add'; + } else { + $event = $this->_eventPrefix . '_edit'; + } + $store = $this; + $this->getResource()->addCommitCallback(function () use ($event, $store) { + $this->eventManager->dispatch($event, ['store' => $store]); + }); return parent::afterSave(); } @@ -1237,6 +1256,11 @@ public function beforeDelete() */ public function afterDelete() { + $store = $this; + $this->getResource()->addCommitCallback(function () use ($store) { + $this->_storeManager->reinitStores(); + $this->eventManager->dispatch($this->_eventPrefix . '_delete', ['store' => $store]); + }); parent::afterDelete(); $this->_configCacheType->clean(); @@ -1251,6 +1275,7 @@ public function afterDelete() $this->getGroup()->setDefaultStoreId($defaultId); $this->getGroup()->save(); } + return $this; } diff --git a/app/code/Magento/Store/Model/StoreManagerInterface.php b/app/code/Magento/Store/Model/StoreManagerInterface.php index 84e498851ec32..220155c47f6df 100644 --- a/app/code/Magento/Store/Model/StoreManagerInterface.php +++ b/app/code/Magento/Store/Model/StoreManagerInterface.php @@ -6,6 +6,8 @@ namespace Magento\Store\Model; +use Magento\Framework\Exception\NoSuchEntityException; + /** * Store manager interface * @@ -46,6 +48,7 @@ public function isSingleStoreMode(); * * @param null|string|bool|int|\Magento\Store\Api\Data\StoreInterface $storeId * @return \Magento\Store\Api\Data\StoreInterface + * @throws NoSuchEntityException If given store doesn't exist. */ public function getStore($storeId = null); diff --git a/app/code/Magento/Store/Test/Unit/App/Action/Plugin/ContextTest.php b/app/code/Magento/Store/Test/Unit/App/Action/Plugin/ContextTest.php index d44724fe302d0..616851465b49c 100644 --- a/app/code/Magento/Store/Test/Unit/App/Action/Plugin/ContextTest.php +++ b/app/code/Magento/Store/Test/Unit/App/Action/Plugin/ContextTest.php @@ -8,8 +8,10 @@ use Magento\Framework\App\Action\AbstractAction; use Magento\Framework\App\Http\Context; use Magento\Framework\App\RequestInterface; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\App\Http\Context as HttpContext; /** * Class ContextPluginTest @@ -33,7 +35,7 @@ class ContextTest extends \PHPUnit\Framework\TestCase protected $sessionMock; /** - * @var \Magento\Framework\App\Http\Context|\PHPUnit_Framework_MockObject_MockObject + * @var HttpContext|\PHPUnit_Framework_MockObject_MockObject */ protected $httpContextMock; @@ -78,7 +80,11 @@ class ContextTest extends \PHPUnit\Framework\TestCase protected function setUp() { $this->sessionMock = $this->createPartialMock(\Magento\Framework\Session\Generic::class, ['getCurrencyCode']); - $this->httpContextMock = $this->createMock(\Magento\Framework\App\Http\Context::class); + $this->httpContextMock = $this->createMock(HttpContext::class); + $this->httpContextMock->expects($this->once()) + ->method('getValue') + ->with(StoreManagerInterface::CONTEXT_STORE) + ->willReturn(null); $this->storeManager = $this->createMock(\Magento\Store\Model\StoreManagerInterface::class); $this->storeCookieManager = $this->createMock(\Magento\Store\Api\StoreCookieManagerInterface::class); $this->storeMock = $this->createMock(\Magento\Store\Model\Store::class); @@ -101,29 +107,29 @@ protected function setUp() 'storeCookieManager' => $this->storeCookieManager, ] ); - $this->storeManager->expects($this->once()) - ->method('getWebsite') - ->will($this->returnValue($this->websiteMock)); + $this->storeManager->method('getDefaultStoreView') ->willReturn($this->storeMock); - - $this->websiteMock->expects($this->once()) - ->method('getDefaultStore') - ->will($this->returnValue($this->storeMock)); - $this->storeCookieManager->expects($this->once()) ->method('getStoreCodeFromCookie') - ->will($this->returnValue('storeCookie')); + ->willReturn('storeCookie'); $this->currentStoreMock->expects($this->any()) ->method('getDefaultCurrencyCode') - ->will($this->returnValue(self::CURRENCY_CURRENT_STORE)); + ->willReturn(self::CURRENCY_CURRENT_STORE); } public function testBeforeDispatchCurrencyFromSession() { + $this->storeManager->expects($this->once()) + ->method('getWebsite') + ->willReturn($this->websiteMock); + $this->websiteMock->expects($this->once()) + ->method('getDefaultStore') + ->willReturn($this->storeMock); + $this->storeMock->expects($this->once()) ->method('getDefaultCurrencyCode') - ->will($this->returnValue(self::CURRENCY_DEFAULT)); + ->willReturn(self::CURRENCY_DEFAULT); $this->storeMock->expects($this->once()) ->method('getCode') @@ -135,7 +141,7 @@ public function testBeforeDispatchCurrencyFromSession() $this->requestMock->expects($this->once()) ->method('getParam') ->with($this->equalTo('___store')) - ->will($this->returnValue('default')); + ->willReturn('default'); $this->storeManager->method('getStore') ->with('default') @@ -143,24 +149,42 @@ public function testBeforeDispatchCurrencyFromSession() $this->sessionMock->expects($this->any()) ->method('getCurrencyCode') - ->will($this->returnValue(self::CURRENCY_SESSION)); + ->willReturn(self::CURRENCY_SESSION); - $this->httpContextMock->expects($this->at(0)) - ->method('setValue') - ->with(StoreManagerInterface::CONTEXT_STORE, 'custom_store', 'default'); - /** Make sure that current currency is taken from session if available */ $this->httpContextMock->expects($this->at(1)) ->method('setValue') - ->with(Context::CONTEXT_CURRENCY, self::CURRENCY_SESSION, self::CURRENCY_DEFAULT); - - $this->plugin->beforeDispatch($this->subjectMock, $this->requestMock); + ->with( + StoreManagerInterface::CONTEXT_STORE, + 'custom_store', + 'default' + ); + // Make sure that current currency is taken from session if available. + $this->httpContextMock->expects($this->at(2)) + ->method('setValue') + ->with( + Context::CONTEXT_CURRENCY, + self::CURRENCY_SESSION, + self::CURRENCY_DEFAULT + ); + + $this->plugin->beforeDispatch( + $this->subjectMock, + $this->requestMock + ); } public function testDispatchCurrentStoreCurrency() { + $this->storeManager->expects($this->once()) + ->method('getWebsite') + ->willReturn($this->websiteMock); + $this->websiteMock->expects($this->once()) + ->method('getDefaultStore') + ->willReturn($this->storeMock); + $this->storeMock->expects($this->once()) ->method('getDefaultCurrencyCode') - ->will($this->returnValue(self::CURRENCY_DEFAULT)); + ->willReturn(self::CURRENCY_DEFAULT); $this->storeMock->expects($this->once()) ->method('getCode') @@ -172,28 +196,47 @@ public function testDispatchCurrentStoreCurrency() $this->requestMock->expects($this->once()) ->method('getParam') ->with($this->equalTo('___store')) - ->will($this->returnValue('default')); + ->willReturn('default'); $this->storeManager->method('getStore') ->with('default') ->willReturn($this->currentStoreMock); - $this->httpContextMock->expects($this->at(0)) - ->method('setValue') - ->with(StoreManagerInterface::CONTEXT_STORE, 'custom_store', 'default'); - /** Make sure that current currency is taken from current store if no value is provided in session */ $this->httpContextMock->expects($this->at(1)) ->method('setValue') - ->with(Context::CONTEXT_CURRENCY, self::CURRENCY_CURRENT_STORE, self::CURRENCY_DEFAULT); - - $this->plugin->beforeDispatch($this->subjectMock, $this->requestMock); + ->with( + StoreManagerInterface::CONTEXT_STORE, + 'custom_store', + 'default' + ); + // Make sure that current currency is taken from current store + //if no value is provided in session. + $this->httpContextMock->expects($this->at(2)) + ->method('setValue') + ->with( + Context::CONTEXT_CURRENCY, + self::CURRENCY_CURRENT_STORE, + self::CURRENCY_DEFAULT + ); + + $this->plugin->beforeDispatch( + $this->subjectMock, + $this->requestMock + ); } public function testDispatchStoreParameterIsArray() { + $this->storeManager->expects($this->once()) + ->method('getWebsite') + ->willReturn($this->websiteMock); + $this->websiteMock->expects($this->once()) + ->method('getDefaultStore') + ->willReturn($this->storeMock); + $this->storeMock->expects($this->once()) ->method('getDefaultCurrencyCode') - ->will($this->returnValue(self::CURRENCY_DEFAULT)); + ->willReturn(self::CURRENCY_DEFAULT); $this->storeMock->expects($this->once()) ->method('getCode') @@ -211,35 +254,52 @@ public function testDispatchStoreParameterIsArray() $this->requestMock->expects($this->once()) ->method('getParam') ->with($this->equalTo('___store')) - ->will($this->returnValue($store)); + ->willReturn($store); $this->storeManager->expects($this->once()) ->method('getStore') ->with('500') ->willReturn($this->currentStoreMock); - $this->httpContextMock->expects($this->at(0)) - ->method('setValue') - ->with(StoreManagerInterface::CONTEXT_STORE, 'custom_store', 'default'); - /** Make sure that current currency is taken from current store if no value is provided in session */ $this->httpContextMock->expects($this->at(1)) ->method('setValue') - ->with(Context::CONTEXT_CURRENCY, self::CURRENCY_CURRENT_STORE, self::CURRENCY_DEFAULT); - - $this->plugin->beforeDispatch($this->subjectMock, $this->requestMock); + ->with( + StoreManagerInterface::CONTEXT_STORE, + 'custom_store', + 'default' + ); + //Make sure that current currency is taken from current store + //if no value is provided in session. + $this->httpContextMock->expects($this->at(2)) + ->method('setValue') + ->with( + Context::CONTEXT_CURRENCY, + self::CURRENCY_CURRENT_STORE, + self::CURRENCY_DEFAULT + ); + + $this->plugin->beforeDispatch( + $this->subjectMock, + $this->requestMock + ); } /** - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage Invalid store parameter. + * @expectedException \Magento\Framework\Exception\NotFoundException */ public function testDispatchStoreParameterIsInvalidArray() { - $this->storeMock->expects($this->never()) + $this->storeManager->expects($this->once()) + ->method('getWebsite') + ->willReturn($this->websiteMock); + $this->websiteMock->expects($this->once()) + ->method('getDefaultStore') + ->willReturn($this->storeMock); + $this->storeMock->expects($this->exactly(2)) ->method('getDefaultCurrencyCode') - ->will($this->returnValue(self::CURRENCY_DEFAULT)); + ->willReturn(self::CURRENCY_DEFAULT); - $this->storeMock->expects($this->never()) + $this->storeMock->expects($this->exactly(2)) ->method('getCode') ->willReturn('default'); $this->currentStoreMock->expects($this->never()) @@ -255,7 +315,53 @@ public function testDispatchStoreParameterIsInvalidArray() $this->requestMock->expects($this->once()) ->method('getParam') ->with($this->equalTo('___store')) - ->will($this->returnValue($store)); + ->willReturn($store); + $this->storeManager->expects($this->once()) + ->method('getStore') + ->with() + ->willReturn($this->storeMock); + $this->plugin->beforeDispatch( + $this->subjectMock, + $this->requestMock + ); + } + + /** + * @return void + * @expectedException \Magento\Framework\Exception\NotFoundException + */ + public function testDispatchNonExistingStore() + { + $storeId = 'NonExisting'; + $this->requestMock->expects($this->once()) + ->method('getParam') + ->with('___store') + ->willReturn($storeId); + $this->storeManager->expects($this->at(0)) + ->method('getStore') + ->with($storeId) + ->willThrowException(new NoSuchEntityException()); + $this->storeManager->expects($this->at(1)) + ->method('getStore') + ->with() + ->willReturn($this->storeMock); + $this->storeManager->expects($this->once()) + ->method('getWebsite') + ->willReturn($this->websiteMock); + $this->websiteMock->expects($this->once()) + ->method('getDefaultStore') + ->willReturn($this->storeMock); + $this->storeMock->expects($this->exactly(2)) + ->method('getDefaultCurrencyCode') + ->willReturn(self::CURRENCY_DEFAULT); + + $this->storeMock->expects($this->exactly(2)) + ->method('getCode') + ->willReturn('default'); + $this->currentStoreMock->expects($this->never()) + ->method('getCode') + ->willReturn('custom_store'); + $this->plugin->beforeDispatch($this->subjectMock, $this->requestMock); } } diff --git a/app/code/Magento/Store/Test/Unit/Console/Command/StoreListCommandTest.php b/app/code/Magento/Store/Test/Unit/Console/Command/StoreListCommandTest.php index 4f848def8c353..50ea2947c1bd2 100644 --- a/app/code/Magento/Store/Test/Unit/Console/Command/StoreListCommandTest.php +++ b/app/code/Magento/Store/Test/Unit/Console/Command/StoreListCommandTest.php @@ -8,7 +8,6 @@ use Magento\Store\Console\Command\StoreListCommand; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Helper\HelperSet; -use Symfony\Component\Console\Helper\TableHelper; use Magento\Store\Model\Store; use Magento\Framework\Console\Cli; @@ -42,15 +41,6 @@ protected function setUp() StoreListCommand::class, ['storeManager' => $this->storeManagerMock] ); - - /** @var HelperSet $helperSet */ - $helperSet = $this->objectManager->getObject( - HelperSet::class, - ['helpers' => [$this->objectManager->getObject(TableHelper::class)]] - ); - - //Inject table helper for output - $this->command->setHelperSet($helperSet); } public function testExecuteExceptionNoVerbosity() diff --git a/app/code/Magento/Store/Test/Unit/Console/Command/WebsiteListCommandTest.php b/app/code/Magento/Store/Test/Unit/Console/Command/WebsiteListCommandTest.php index 3978f49522224..0312c735c6772 100644 --- a/app/code/Magento/Store/Test/Unit/Console/Command/WebsiteListCommandTest.php +++ b/app/code/Magento/Store/Test/Unit/Console/Command/WebsiteListCommandTest.php @@ -8,7 +8,6 @@ use Magento\Store\Console\Command\WebsiteListCommand; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Helper\HelperSet; -use Symfony\Component\Console\Helper\TableHelper; use Magento\Store\Model\Website; use Magento\Framework\Console\Cli; use Magento\Store\Api\WebsiteRepositoryInterface; @@ -43,15 +42,6 @@ protected function setUp() WebsiteListCommand::class, ['websiteManagement' => $this->websiteRepositoryMock] ); - - /** @var HelperSet $helperSet */ - $helperSet = $this->objectManager->getObject( - HelperSet::class, - ['helpers' => [$this->objectManager->getObject(TableHelper::class)]] - ); - - //Inject table helper for output - $this->command->setHelperSet($helperSet); } public function testExecuteExceptionNoVerbosity() diff --git a/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/CreateTest.php b/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/CreateTest.php index 9c7cc648cf8af..2c2b0b00aec43 100644 --- a/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/CreateTest.php +++ b/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/CreateTest.php @@ -325,10 +325,6 @@ public function testRunGroup() return $function(); }); - $this->eventManagerMock->expects($this->once()) - ->method('dispatch') - ->with('store_group_save', ['group' => $this->groupMock]); - $this->processor->run($this->data); } @@ -382,10 +378,6 @@ public function testRunStore() return $function(); }); - $this->eventManagerMock->expects($this->once()) - ->method('dispatch') - ->with('store_add', ['store' => $this->storeMock]); - $this->processor->run($this->data); } diff --git a/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/DeleteTest.php b/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/DeleteTest.php index d16a4a70b00aa..c373643fa03ac 100644 --- a/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/DeleteTest.php +++ b/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/DeleteTest.php @@ -244,8 +244,6 @@ public function testRun() ->method('get') ->with('test') ->willReturn($this->storeMock); - $this->storeResourceMock->expects($this->once()) - ->method('addCommitCallback'); $this->registryMock->expects($this->once()) ->method('unregister') diff --git a/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/UpdateTest.php b/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/UpdateTest.php index 3b0b932e31d46..99722ab7f855c 100644 --- a/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/UpdateTest.php +++ b/app/code/Magento/Store/Test/Unit/Model/Config/Importer/Processor/UpdateTest.php @@ -175,7 +175,7 @@ public function testRun() $updateData[ScopeInterface::SCOPE_STORES], ], ]); - $this->websiteMock->expects($this->exactly(4)) + $this->websiteMock->expects($this->atLeastOnce()) ->method('getResource') ->willReturn($this->websiteResourceMock); $this->websiteMock->expects($this->once()) @@ -203,7 +203,7 @@ public function testRun() $this->groupFactoryMock->expects($this->exactly(3)) ->method('create') ->willReturn($this->groupMock); - $this->groupMock->expects($this->exactly(5)) + $this->groupMock->expects($this->atLeastOnce()) ->method('getResource') ->willReturn($this->groupResourceMock); $this->groupMock->expects($this->once()) @@ -227,7 +227,7 @@ public function testRun() $this->storeFactoryMock->expects($this->exactly(2)) ->method('create') ->willReturn($this->storeMock); - $this->storeMock->expects($this->exactly(4)) + $this->storeMock->expects($this->atLeastOnce()) ->method('getResource') ->willReturn($this->storeResourceMock); $this->storeMock->expects($this->once()) @@ -244,11 +244,9 @@ public function testRun() $this->storeMock->expects($this->once()) ->method('setData') ->with($updateData[ScopeInterface::SCOPE_STORES]['test']); - $this->storeResourceMock->expects($this->once()) + $this->storeMock->expects($this->once()) ->method('save') - ->with($this->storeMock); - $this->storeResourceMock->expects($this->once()) - ->method('addCommitCallback'); + ->willReturnSelf(); $this->model->run($data); } diff --git a/app/code/Magento/Store/Test/Unit/Model/WebsiteRepositoryTest.php b/app/code/Magento/Store/Test/Unit/Model/WebsiteRepositoryTest.php index bd8ac4b6048cf..0396092176673 100644 --- a/app/code/Magento/Store/Test/Unit/Model/WebsiteRepositoryTest.php +++ b/app/code/Magento/Store/Test/Unit/Model/WebsiteRepositoryTest.php @@ -34,7 +34,7 @@ protected function setUp() { $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->websiteFactoryMock = - $this->getMockBuilder('Magento\Store\Model\WebsiteFactory') + $this->getMockBuilder(\Magento\Store\Model\WebsiteFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); diff --git a/app/code/Magento/Store/composer.json b/app/code/Magento/Store/composer.json index 53075717f7a55..ebaa32b95f48b 100644 --- a/app/code/Magento/Store/composer.json +++ b/app/code/Magento/Store/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*", "magento/module-catalog": "*", "magento/module-config": "*", diff --git a/app/code/Magento/StoreGraphQl/composer.json b/app/code/Magento/StoreGraphQl/composer.json index 54bba7f585c9a..91f79b39c023a 100644 --- a/app/code/Magento/StoreGraphQl/composer.json +++ b/app/code/Magento/StoreGraphQl/composer.json @@ -3,7 +3,7 @@ "description": "N/A", "type": "magento2-module", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*" }, "suggest": { diff --git a/app/code/Magento/Swagger/composer.json b/app/code/Magento/Swagger/composer.json index 7fa6c98eb0545..6a45076d3f32d 100644 --- a/app/code/Magento/Swagger/composer.json +++ b/app/code/Magento/Swagger/composer.json @@ -5,7 +5,7 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "magento/framework": "*" }, "type": "magento2-module", diff --git a/app/code/Magento/Swagger/view/frontend/layout/swagger_index_index.xml b/app/code/Magento/Swagger/view/frontend/layout/swagger_index_index.xml index 345f063a7aaa3..f14df1c70a790 100644 --- a/app/code/Magento/Swagger/view/frontend/layout/swagger_index_index.xml +++ b/app/code/Magento/Swagger/view/frontend/layout/swagger_index_index.xml @@ -10,32 +10,18 @@ Swagger UI - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + @@ -43,6 +29,7 @@ + diff --git a/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml b/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml index 27b3767f274bc..b20da68734579 100644 --- a/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml +++ b/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml @@ -12,11 +12,48 @@ * Modified by Magento, Modifications Copyright © Magento, Inc. All rights reserved. */ -/** @var \Magento\Swagger\Block\Index $block */ +/** @var \Magento\Swagger\Block\Index $block + * + * @codingStandardsIgnoreFile + */ $schemaUrl = $block->getSchemaUrl(); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +