diff --git a/README.md b/README.md index ec8bcdb292ea7..7662290cc09c2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Welcome to Magento 2 installation! We're glad you chose to install Magento 2, a * [Installation Guide](https://devdocs.magento.com/guides/v2.3/install-gde/bk-install-guide.html). +## Learn More About GraphQL in Magento 2 + +* [GraphQL Developer Guide](https://devdocs.magento.com/guides/v2.3/graphql/index.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. diff --git a/app/code/Magento/AdminNotification/etc/db_schema.xml b/app/code/Magento/AdminNotification/etc/db_schema.xml index 29d928ced2084..8849687611193 100644 --- a/app/code/Magento/AdminNotification/etc/db_schema.xml +++ b/app/code/Magento/AdminNotification/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> + comment="Notification ID"/>
- + isSuitable()) { $this->_productTypeModels[$productTypeName] = $model; + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $this->_disabledAttrs = array_merge($this->_disabledAttrs, $model->getDisabledAttrs()); + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $this->_indexValueAttributes = array_merge( $this->_indexValueAttributes, $model->getIndexValueAttributes() @@ -197,6 +201,7 @@ protected function initTypeModels() public function export() { //Execution time may be very long + // phpcs:ignore Magento2.Functions.DiscouragedFunction set_time_limit(0); $writer = $this->getWriter(); @@ -234,16 +239,6 @@ public function filterAttributeCollection(\Magento\Eav\Model\ResourceModel\Entit foreach ($collection as $attribute) { if (in_array($attribute->getAttributeCode(), $this->_disabledAttrs)) { - if (isset($this->_parameters[\Magento\ImportExport\Model\Export::FILTER_ELEMENT_SKIP])) { - if ($attribute->getAttributeCode() == ImportAdvancedPricing::COL_TIER_PRICE - && in_array( - $attribute->getId(), - $this->_parameters[\Magento\ImportExport\Model\Export::FILTER_ELEMENT_SKIP] - ) - ) { - $this->_passTierPrice = 1; - } - } $collection->removeItemByKey($attribute->getId()); } } @@ -363,6 +358,7 @@ private function prepareExportData( $linkedTierPricesData = []; foreach ($tierPricesData as $tierPriceData) { $sku = $productLinkIdToSkuMap[$tierPriceData['product_link_id']]; + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $linkedTierPricesData[] = array_merge( $tierPriceData, [ImportAdvancedPricing::COL_SKU => $sku] @@ -471,7 +467,7 @@ private function fetchTierPrices(array $productIds): array ImportAdvancedPricing::COL_TIER_PRICE_QTY => 'ap.qty', ImportAdvancedPricing::COL_TIER_PRICE => 'ap.value', ImportAdvancedPricing::COL_TIER_PRICE_PERCENTAGE_VALUE => 'ap.percentage_value', - 'product_link_id' => 'ap.' .$productEntityLinkField, + 'product_link_id' => 'ap.' . $productEntityLinkField, ]; if ($exportFilter && array_key_exists('tier_price', $exportFilter)) { if (!empty($exportFilter['tier_price'][0])) { @@ -488,7 +484,7 @@ private function fetchTierPrices(array $productIds): array $selectFields ) ->where( - 'ap.'.$productEntityLinkField.' IN (?)', + 'ap.' . $productEntityLinkField . ' IN (?)', $productIds ); @@ -602,7 +598,7 @@ protected function _getWebsiteCode(int $websiteId): string } if ($storeName && $currencyCode) { - $code = $storeName.' ['.$currencyCode.']'; + $code = $storeName . ' [' . $currencyCode . ']'; } else { $code = $storeName; } diff --git a/app/code/Magento/AdvancedSearch/etc/db_schema.xml b/app/code/Magento/AdvancedSearch/etc/db_schema.xml index 2dd8c68e2d5fd..bf85a23782095 100644 --- a/app/code/Magento/AdvancedSearch/etc/db_schema.xml +++ b/app/code/Magento/AdvancedSearch/etc/db_schema.xml @@ -9,11 +9,11 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
- + + default="0" comment="Query ID"/> + default="0" comment="Relation ID"/> diff --git a/app/code/Magento/Analytics/README.md b/app/code/Magento/Analytics/README.md index aa424182e2ebd..7ec30c6dd484b 100644 --- a/app/code/Magento/Analytics/README.md +++ b/app/code/Magento/Analytics/README.md @@ -1,18 +1,27 @@ -# Magento_Analytics Module +# Magento_Analytics module The Magento_Analytics module integrates your Magento instance with the [Magento Business Intelligence (MBI)](https://magento.com/products/business-intelligence) to use [Advanced Reporting](https://devdocs.magento.com/guides/v2.3/advanced-reporting/modules.html) functionality. The module implements the following functionality: -* enabling subscription to the MBI and automatic re-subscription -* changing the base URL with the same MBI account remained -* declaring the configuration schemas for report data collection -* collecting the Magento instance data as reports for the MBI -* introducing API that provides the collected data -* extending Magento configuration with the module parameters: - * subscription status (enabled/disabled) - * industry (a business area in which the instance website works) - * time of data collection (time of the day when the module collects data) +- Enabling subscription to Magento Business Intelligence (MBI) and automatic re-subscription +- Declaring the configuration schemas for report data collection +- Collecting the Magento instance data as reports for MBI +- Introducing API that provides the collected data +- Extending Magento configuration with the module parameters: + - Subscription status (enabled/disabled) + - Industry (a business area in which the instance website works) + - Time of data collection (time of the day when the module collects data) + +## Installation details + +Before disabling or uninstalling this module, note that the following modules depends on this module: +- Magento_CatalogAnalytics +- Magento_CustomerAnalytics +- Magento_QuoteAnalytics +- Magento_ReviewAnalytics +- Magento_SalesAnalytics +- Magento_WishlistAnalytics ## Structure @@ -29,12 +38,12 @@ The subscription to the MBI service is enabled during the installation process o Configuration settings for the Analytics module can be modified in the Admin Panel on the Stores > Configuration page under the General > Advanced Reporting tab. The following options can be adjusted: -* Advanced Reporting Service (Enabled/Disabled) - * Alters the status of the Advanced Reporting subscription -* Time of day to send data (Hour/Minute/Second in the store's time zone) - * Defines when the data collection process for the Advanced Reporting service occurs -* Industry - * Defines the industry of the store in order to create a personalized Advanced Reporting experience +- Advanced Reporting Service (Enabled/Disabled) + - Alters the status of the Advanced Reporting subscription +- Time of day to send data (Hour/Minute/Second in the store's time zone) + - Defines when the data collection process for the Advanced Reporting service occurs +- Industry + - Defines the industry of the store in order to create a personalized Advanced Reporting experience ## Extensibility diff --git a/app/code/Magento/Analytics/Test/Mftf/Test/AdminConfigurationTimeToSendDataTest.xml b/app/code/Magento/Analytics/Test/Mftf/Test/AdminConfigurationTimeToSendDataTest.xml index 58e62500b8203..8ebd8cb594bee 100644 --- a/app/code/Magento/Analytics/Test/Mftf/Test/AdminConfigurationTimeToSendDataTest.xml +++ b/app/code/Magento/Analytics/Test/Mftf/Test/AdminConfigurationTimeToSendDataTest.xml @@ -25,9 +25,9 @@ - - - + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/adminhtml/system.xml b/app/code/Magento/AsynchronousOperations/etc/adminhtml/system.xml index 7190b80750357..e373a4fc78b13 100644 --- a/app/code/Magento/AsynchronousOperations/etc/adminhtml/system.xml +++ b/app/code/Magento/AsynchronousOperations/etc/adminhtml/system.xml @@ -13,6 +13,7 @@ + validate-zero-or-greater validate-digits diff --git a/app/code/Magento/Authorization/Model/Role.php b/app/code/Magento/Authorization/Model/Role.php index fc32fbcaa2e98..042e95806ae18 100644 --- a/app/code/Magento/Authorization/Model/Role.php +++ b/app/code/Magento/Authorization/Model/Role.php @@ -52,6 +52,9 @@ public function __construct( //phpcs:ignore Generic.CodeAnalysis.UselessOverridi /** * @inheritDoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -61,6 +64,9 @@ public function __sleep() /** * @inheritDoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Authorizenet/README.md b/app/code/Magento/Authorizenet/README.md index 380161d8b264e..62598837bee6d 100644 --- a/app/code/Magento/Authorizenet/README.md +++ b/app/code/Magento/Authorizenet/README.md @@ -1 +1,42 @@ +# Magento_Authorizenet module + The Magento_Authorizenet module implements the integration with the Authorize.Net payment gateway and makes the latter available as a payment method in Magento. + +## Extensibility + +Extension developers can interact with the Magento_Authorizenet module. For more information about the Magento extension mechanism, see [Magento plug-ins](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/plugins.html). + +[The Magento dependency injection mechanism](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/depend-inj.html) enables you to override the functionality of the Magento_Authorizenet module. + +### Events + +This module dispatches the following events: + + - `checkout_directpost_placeOrder` event in the `\Magento\Authorizenet\Controller\Directpost\Payment\Place::placeCheckoutOrder()` method. Parameters: + - `result` is a data object (`\Magento\Framework\DataObject` class). + - `action` is a controller object (`\Magento\Authorizenet\Controller\Directpost\Payment\Place`). + + - `order_cancel_after` event in the `\Magento\Authorizenet\Model\Directpost::declineOrder()` method. Parameters: + - `order` is an order object (`\Magento\Sales\Model\Order` class). + + +This module observes the following events: + + - `checkout_submit_all_after` event in the `Magento\Authorizenet\Observer\SaveOrderAfterSubmitObserver` file. + - `checkout_directpost_placeOrder` event in the `Magento\Authorizenet\Observer\AddFieldsToResponseObserver` file. + +For information about events in Magento 2, see [Events and observers](http://devdocs.magento.com/guides/v2.3/extension-dev-guide/events-and-observers.html#events). + +### Layouts + +This module introduces the following layouts and layout handles in the `view/adminhtml/layout` directory: + +- `adminhtml_authorizenet_directpost_payment_redirect` + +This module introduces the following layouts and layout handles in the `view/frontend/layout` directory: + +- `authorizenet_directpost_payment_backendresponse` +- `authorizenet_directpost_payment_redirect` +- `authorizenet_directpost_payment_response` + +For more information about layouts in Magento 2, see the [Layout documentation](https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/layouts/layout-overview.html). diff --git a/app/code/Magento/Backend/Model/Auth/Session.php b/app/code/Magento/Backend/Model/Auth/Session.php index 809b78b7b98bc..6d2f8f6a21d4a 100644 --- a/app/code/Magento/Backend/Model/Auth/Session.php +++ b/app/code/Magento/Backend/Model/Auth/Session.php @@ -5,17 +5,20 @@ */ namespace Magento\Backend\Model\Auth; +use Magento\Framework\Acl; +use Magento\Framework\AclFactory; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Stdlib\Cookie\CookieMetadataFactory; use Magento\Framework\Stdlib\CookieManagerInterface; +use Magento\Backend\Spi\SessionUserHydratorInterface; +use Magento\Backend\Spi\SessionAclHydratorInterface; +use Magento\User\Model\User; +use Magento\User\Model\UserFactory; /** * Backend Auth session model * * @api - * @method \Magento\User\Model\User|null getUser() - * @method \Magento\Backend\Model\Auth\Session setUser(\Magento\User\Model\User $value) - * @method \Magento\Framework\Acl|null getAcl() - * @method \Magento\Backend\Model\Auth\Session setAcl(\Magento\Framework\Acl $value) * @method int getUpdatedAt() * @method \Magento\Backend\Model\Auth\Session setUpdatedAt(int $value) * @@ -56,6 +59,36 @@ class Session extends \Magento\Framework\Session\SessionManager implements \Mage */ protected $_config; + /** + * @var SessionUserHydratorInterface + */ + private $userHydrator; + + /** + * @var SessionAclHydratorInterface + */ + private $aclHydrator; + + /** + * @var UserFactory + */ + private $userFactory; + + /** + * @var AclFactory + */ + private $aclFactory; + + /** + * @var User|null + */ + private $user; + + /** + * @var Acl|null + */ + private $acl; + /** * @param \Magento\Framework\App\Request\Http $request * @param \Magento\Framework\Session\SidResolverInterface $sidResolver @@ -70,6 +103,10 @@ class Session extends \Magento\Framework\Session\SessionManager implements \Mage * @param \Magento\Backend\Model\UrlInterface $backendUrl * @param \Magento\Backend\App\ConfigInterface $config * @throws \Magento\Framework\Exception\SessionException + * @param SessionUserHydratorInterface|null $userHydrator + * @param SessionAclHydratorInterface|null $aclHydrator + * @param UserFactory|null $userFactory + * @param AclFactory|null $aclFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -84,11 +121,19 @@ public function __construct( \Magento\Framework\App\State $appState, \Magento\Framework\Acl\Builder $aclBuilder, \Magento\Backend\Model\UrlInterface $backendUrl, - \Magento\Backend\App\ConfigInterface $config + \Magento\Backend\App\ConfigInterface $config, + ?SessionUserHydratorInterface $userHydrator = null, + ?SessionAclHydratorInterface $aclHydrator = null, + ?UserFactory $userFactory = null, + ?AclFactory $aclFactory = null ) { $this->_config = $config; $this->_aclBuilder = $aclBuilder; $this->_backendUrl = $backendUrl; + $this->userHydrator = $userHydrator ?? ObjectManager::getInstance()->get(SessionUserHydratorInterface::class); + $this->aclHydrator = $aclHydrator ?? ObjectManager::getInstance()->get(SessionAclHydratorInterface::class); + $this->userFactory = $userFactory ?? ObjectManager::getInstance()->get(UserFactory::class); + $this->aclFactory = $aclFactory ?? ObjectManager::getInstance()->get(AclFactory::class); parent::__construct( $request, $sidResolver, @@ -232,6 +277,16 @@ public function processLogin() return $this; } + /** + * @inheritDoc + */ + public function destroy(array $options = null) + { + $this->user = null; + $this->acl = null; + parent::destroy($options); + } + /** * Process of configuring of current auth storage when logout was performed * @@ -255,4 +310,142 @@ public function isValidForPath($path) { return true; } + + /** + * Logged-in user. + * + * @return User|null + */ + public function getUser() + { + if (!$this->user) { + $userData = $this->getUserData(); + if ($userData) { + /** @var User $user */ + $user = $this->userFactory->create(); + $this->userHydrator->hydrate($user, $userData); + $this->user = $user; + } elseif ($user = parent::getUser()) { + $this->setUser($user); + } + } + + return $this->user; + } + + /** + * Set logged-in user instance. + * + * @param User|null $user + * @return Session + */ + public function setUser($user) + { + $this->setUserData(null); + if ($user) { + $this->setUserData($this->userHydrator->extract($user)); + } + $this->user = $user; + + return $this; + } + + /** + * Is user logged in? + * + * @return bool + */ + public function hasUser() + { + return (bool)$this->getUser(); + } + + /** + * Remove logged-in user. + * + * @return Session + */ + public function unsUser() + { + $this->user = null; + parent::unsUser(); + return $this->unsUserData(); + } + + /** + * Logged-in user's ACL data. + * + * @return Acl|null + */ + public function getAcl() + { + if (!$this->acl) { + $aclData = $this->getUserAclData(); + if ($aclData) { + /** @var Acl $acl */ + $acl = $this->aclFactory->create(); + $this->aclHydrator->hydrate($acl, $aclData); + $this->acl = $acl; + } elseif ($acl = parent::getAcl()) { + $this->setAcl($acl); + } + } + + return $this->acl; + } + + /** + * Set logged-in user's ACL data instance. + * + * @param Acl|null $acl + * @return Session + */ + public function setAcl($acl) + { + $this->setUserAclData(null); + if ($acl) { + $this->setUserAclData($this->aclHydrator->extract($acl)); + } + $this->acl = $acl; + + return $this; + } + + /** + * Whether ACL data is present. + * + * @return bool + */ + public function hasAcl() + { + return (bool)$this->getAcl(); + } + + /** + * Remove ACL data. + * + * @return Session + */ + public function unsAcl() + { + $this->acl = null; + parent::unsAcl(); + return $this->unsUserAclData(); + } + + /** + * @inheritDoc + */ + public function writeClose() + { + //Updating data in session in case these objects has been changed. + if ($this->user) { + $this->setUser($this->user); + } + if ($this->acl) { + $this->setAcl($this->acl); + } + + parent::writeClose(); + } } diff --git a/app/code/Magento/Backend/Model/Auth/SessionAclHydrator.php b/app/code/Magento/Backend/Model/Auth/SessionAclHydrator.php new file mode 100644 index 0000000000000..34e01be696672 --- /dev/null +++ b/app/code/Magento/Backend/Model/Auth/SessionAclHydrator.php @@ -0,0 +1,36 @@ + $acl->_rules, 'resources' => $acl->_resources, 'roles' => $acl->_roleRegistry]; + } + + /** + * @inheritDoc + */ + public function hydrate(Acl $target, array $data): void + { + $target->_rules = $data['rules']; + $target->_resources = $data['resources']; + $target->_roleRegistry = $data['roles']; + } +} diff --git a/app/code/Magento/Backend/Model/Auth/SessionUserHydrator.php b/app/code/Magento/Backend/Model/Auth/SessionUserHydrator.php new file mode 100644 index 0000000000000..6dee8b7b302c8 --- /dev/null +++ b/app/code/Magento/Backend/Model/Auth/SessionUserHydrator.php @@ -0,0 +1,54 @@ +roleFactory = $roleFactory; + } + + /** + * @inheritDoc + */ + public function extract(User $user): array + { + return ['data' => $user->getData(), 'role_data' => $user->getRole()->getData()]; + } + + /** + * @inheritDoc + */ + public function hydrate(User $target, array $data): void + { + $target->setData($data['data']); + /** @var Role $role */ + $role = $this->roleFactory->create(); + $role->setData($data['role_data']); + $target->setData('extracted_role', $role); + $target->getRole(); + } +} diff --git a/app/code/Magento/Backend/README.md b/app/code/Magento/Backend/README.md index 03c7d86516b92..205051809328a 100644 --- a/app/code/Magento/Backend/README.md +++ b/app/code/Magento/Backend/README.md @@ -1,3 +1,112 @@ -The Backend module contains common infrastructure and assets for other modules to be defined and used in their -administration user interface (UI). It does not contain anything specific to other modules. Among many things it -handles the logic of authenticating and authorizing users. +# Magento_Backend module + +The Magento_Backend module contains common infrastructure and assets for other modules to be defined and used in their +administration user interface (UI). + +The Magento_Backend module does not contain anything specific to other modules. Among many things it handles the logic of authenticating and authorizing users. + +## Installation details + +Before disabling or uninstalling this module, note that the following modules depends on this module: + +- Magento_Analytics +- Magento_Authorization +- Magento_NewRelicReporting +- Magento_ProductVideo +- Magento_ReleaseNotification +- Magento_Search +- Magento_Security +- Magento_Signifyd +- Magento_Swatches +- Magento_Ui +- Magento_User +- Magento_Webapi + +For information about module installation in Magento 2, see [Enable or disable modules](https://devdocs.magento.com/guides/v2.3/install-gde/install/cli/install-cli-subcommands-enable.html). + +## Structure + +Beyond the [usual module file structure](https://devdocs.magento.com/guides/v2.3/architecture/archi_perspectives/components/modules/mod_intro.html) the module contains a directory `Service/V1`. + +`Service/V1` - contains logic to provide a list of modules installed in Magento. + +For information about typical file structure of a module in Magento 2, see [Module file structure](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/build/module-file-structure.html#module-file-structure). + +## Extensibility + +Extension developers can interact with the Magento_Backend module. For more information about the Magento extension mechanism, see [Magento plug-ins](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/plugins.html). + +[The Magento dependency injection mechanism](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/depend-inj.html) enables you to override the functionality of the Magento_Backend module. + +### Events + +The module dispatches the following events: + + - `adminhtml_block_html_before` event in the `\Magento\Backend\Block\Template::_toHtml()` method. Parameters: + - `block` is the backend block template (this) (`\Magento\Backend\Block\Template` class). + - `adminhtml_store_edit_form_prepare_form` event in the `\Magento\Backend\Block\System\Store\Edit\AbstractForm::_prepareForm()` method. Parameters: + - `block` is the AbstractForm block (this) (`\Magento\Backend\Block\System\Store\Edit\AbstractForm` class). + - `backend_block_widget_grid_prepare_grid_before` event in the `\Magento\Backend\Block\Widget\Grid::_prepareGrid()` method. Parameters: + - `grid` is the widget grid block (this) (`\Magento\Backend\Block\Widget\Grid` class) + - `collection` is the grid collection (`\Magento\Framework\Data\Collection` class). + - `adminhtml_cache_flush_system` event in the `\Magento\Backend\Console\Command\CacheCleanCommand::performAction()` method. + - `adminhtml_cache_flush_all` event in the `\Magento\Backend\Console\Command\CacheFlushCommand::performAction()` method. + - `clean_catalog_images_cache_after` event in the `\Magento\Backend\Controller\Adminhtml\Cache\CleanImages::execute()` method. + - `clean_media_cache_after` event in the `\Magento\Backend\Controller\Adminhtml\Cache\CleanMedia::execute()` method. + - `clean_static_files_cache_after` event in the `\Magento\Backend\Controller\Adminhtml\Cache\CleanStaticFiles::execute()` method. + - `adminhtml_cache_flush_all` event in the `\Magento\Backend\Controller\Adminhtml\Cache\FlushAll::execute()` method. + - `adminhtml_cache_flush_system` event in the `\Magento\Backend\Controller\Adminhtml\Cache\FlushSystem::execute()` method. + - `theme_save_after` event in the `\Magento\Backend\Controller\Adminhtml\System\Design\Save::execute()` method. + - `backend_auth_user_login_success` event in the `\Magento\Backend\Model\Auth::login()` method. Parameters: + - `user` is the credential storage object (`null | \Magento\Backend\Model\Auth\Credential\StorageInterface`) + - `backend_auth_user_login_failed` event in the `\Magento\Backend\Model\Auth::login()` method. Parameters: + - `user_name` is username extracted from the credential storage object (`null | \Magento\Backend\Model\Auth\Credential\StorageInterface`) + - `exception` any exception generated (`\Magento\Framework\Exception\LocalizedException | \Magento\Framework\Exception\Plugin\AuthenticationException`) + +For information about an event in Magento 2, see [Events and observers](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/events-and-observers.html#events). + +### Layouts + +This module introduces the following layouts and layout handles in the `view/adminhtml/layout` directory: + +- `admin_login` +- `adminhtml_auth_login` +- `adminhtml_cache_block` +- `adminhtml_cache_index` +- `adminhtml_dashboard_customersmost` +- `adminhtml_dashboard_customersnewest` +- `adminhtml_dashboard_index` +- `adminhtml_dashboard_productsviewed` +- `adminhtml_denied` +- `adminhtml_noroute` +- `adminhtml_system_account_index` +- `adminhtml_system_design_edit` +- `adminhtml_system_design_grid` +- `adminhtml_system_design_grid_block` +- `adminhtml_system_design_index` +- `adminhtml_system_store_deletestore` +- `adminhtml_system_store_editstore` +- `adminhtml_system_store_grid_block` +- `adminhtml_system_store_index` +- `default` +- `editor` +- `empty` +- `formkey` +- `overlay_popup` +- `popup` + + +For more information about layouts in Magento 2, see the [Layout documentation](https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/layouts/layout-overview.html). + +### UI components + +You can extend Magento_Backend module using the following configuration files: + +- `view/adminhtml/ui_component/design_config_form.xml` +- `view/adminhtml/ui_component/design_config_listing.xml` + +For information about UI components in Magento 2, see [Overview of UI components](https://devdocs.magento.com/guides/v2.3/ui_comp_guide/bk-ui_comps.html). + +## Additional information + +For information about significant changes in patch releases, see [2.3.x Release information](https://devdocs.magento.com/guides/v2.3/release-notes/bk-release-notes.html). diff --git a/app/code/Magento/Backend/Spi/SessionAclHydratorInterface.php b/app/code/Magento/Backend/Spi/SessionAclHydratorInterface.php new file mode 100644 index 0000000000000..7227cc92fcc8e --- /dev/null +++ b/app/code/Magento/Backend/Spi/SessionAclHydratorInterface.php @@ -0,0 +1,34 @@ + + + + diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminCheckLocaleAndDeveloperConfigInDeveloperModeTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminCheckLocaleAndDeveloperConfigInDeveloperModeTest.xml new file mode 100644 index 0000000000000..59a6a7e261b87 --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminCheckLocaleAndDeveloperConfigInDeveloperModeTest.xml @@ -0,0 +1,37 @@ + + + + + + + + + <description value="Check locale dropdown and developer configuration page are available in developer mode"/> + <group value="backend"/> + <severity value="MAJOR"/> + <testCaseId value="MC-20374"/> + <group value="developer_mode_only"/> + <group value="mtf_migrated"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + + <!-- Go to the general configuration and make sure the locale dropdown is available and enabled --> + <actionGroup ref="AdminOpenStoreConfigPageActionGroup" stepKey="openStoreConfigPage" /> + <scrollTo selector="{{LocaleOptionsSection.sectionHeader}}" stepKey="scrollToLocaleSection" x="0" y="-80" /> + <conditionalClick selector="{{LocaleOptionsSection.sectionHeader}}" dependentSelector="{{LocaleOptionsSection.timezone}}" visible="false" stepKey="openLocaleSection"/> + <seeElement selector="{{LocaleOptionsSection.localeEnabled}}" stepKey="seeEnabledLocaleDropdown"/> + + <!-- Go to the developer configuration and make sure the page is available --> + <actionGroup ref="AdminOpenStoreConfigDeveloperPageActionGroup" stepKey="goToDeveloperConfigPage"/> + <seeInCurrentUrl url="{{AdminConfigDeveloperPage.url}}" stepKey="seeDeveloperConfigUrl"/> + <seeElement selector="{{AdminConfigSection.navItemByTitle('Developer')}}" stepKey="assertDeveloperNavItemPresent" /> + </test> +</tests> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminCheckLocaleAndDeveloperConfigInProductionModeTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminCheckLocaleAndDeveloperConfigInProductionModeTest.xml new file mode 100644 index 0000000000000..2dade727ca411 --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminCheckLocaleAndDeveloperConfigInProductionModeTest.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCheckLocaleAndDeveloperConfigInProductionModeTest"> + <annotations> + <features value="Backend"/> + <title value="Check locale dropdown and developer configuration page are not available in production mode"/> + <description value="Check locale dropdown and developer configuration page are not available in production mode"/> + <testCaseId value="MC-14106" /> + <severity value="MAJOR"/> + <group value="backend"/> + <group value="production_mode_only"/> + <group value="mtf_migrated"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + + <!-- Go to the general configuration and make sure the locale dropdown is disabled --> + <actionGroup ref="AdminOpenStoreConfigPageActionGroup" stepKey="openStoreConfigPage" /> + <scrollTo selector="{{LocaleOptionsSection.sectionHeader}}" stepKey="scrollToLocaleSection" x="0" y="-80" /> + <conditionalClick selector="{{LocaleOptionsSection.sectionHeader}}" dependentSelector="{{LocaleOptionsSection.timezone}}" visible="false" stepKey="openLocaleSection"/> + <assertElementContainsAttribute selector="{{LocaleOptionsSection.locale}}" attribute="disabled" stepKey="seeDisabledLocaleDropdown" /> + + <!-- Go to the developer configuration and make sure the redirect to the configuration page takes place --> + <actionGroup ref="AdminOpenStoreConfigDeveloperPageActionGroup" stepKey="goToDeveloperConfigPage"/> + <seeInCurrentUrl url="{{AdminConfigPage.url}}index/" stepKey="seeConfigurationIndexUrl"/> + + <actionGroup ref="AdminExpandConfigTabActionGroup" stepKey="expandAdvancedTab"> + <argument name="tabName" value="Advanced" /> + </actionGroup> + <dontSeeElement selector="{{AdminConfigSection.navItemByTitle('Developer')}}" stepKey="assertDeveloperNavItemAbsent" /> + </test> +</tests> diff --git a/app/code/Magento/Backend/Test/Unit/Model/Auth/SessionTest.php b/app/code/Magento/Backend/Test/Unit/Model/Auth/SessionTest.php deleted file mode 100644 index f1a4bc355b08e..0000000000000 --- a/app/code/Magento/Backend/Test/Unit/Model/Auth/SessionTest.php +++ /dev/null @@ -1,273 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -namespace Magento\Backend\Test\Unit\Model\Auth; - -use Magento\Backend\Model\Auth\Session; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; - -/** - * Class SessionTest tests Magento\Backend\Model\Auth\Session - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class SessionTest extends \PHPUnit\Framework\TestCase -{ - /** - * @var \Magento\Backend\App\Config | \PHPUnit_Framework_MockObject_MockObject - */ - protected $config; - - /** - * @var \Magento\Framework\Session\Config | \PHPUnit_Framework_MockObject_MockObject - */ - protected $sessionConfig; - - /** - * @var \Magento\Framework\Stdlib\CookieManagerInterface | \PHPUnit_Framework_MockObject_MockObject - */ - protected $cookieManager; - - /** - * @var \Magento\Framework\Stdlib\Cookie\CookieMetadataFactory | \PHPUnit_Framework_MockObject_MockObject - */ - protected $cookieMetadataFactory; - - /** - * @var \Magento\Framework\Session\Storage | \PHPUnit_Framework_MockObject_MockObject - */ - protected $storage; - - /** - * @var \Magento\Framework\Acl\Builder | \PHPUnit_Framework_MockObject_MockObject - */ - protected $aclBuilder; - - /** - * @var Session - */ - protected $session; - - protected function setUp() - { - $this->cookieMetadataFactory = $this->createPartialMock( - \Magento\Framework\Stdlib\Cookie\CookieMetadataFactory::class, - ['createPublicCookieMetadata'] - ); - - $this->config = $this->createPartialMock(\Magento\Backend\App\Config::class, ['getValue']); - $this->cookieManager = $this->createPartialMock( - \Magento\Framework\Stdlib\Cookie\PhpCookieManager::class, - ['getCookie', 'setPublicCookie'] - ); - $this->storage = $this->createPartialMock( - \Magento\Framework\Session\Storage::class, - ['getUser', 'getAcl', 'setAcl'] - ); - $this->sessionConfig = $this->createPartialMock( - \Magento\Framework\Session\Config::class, - ['getCookiePath', 'getCookieDomain', 'getCookieSecure', 'getCookieHttpOnly'] - ); - $this->aclBuilder = $this->getMockBuilder(\Magento\Framework\Acl\Builder::class) - ->disableOriginalConstructor() - ->getMock(); - $objectManager = new ObjectManager($this); - $this->session = $objectManager->getObject( - \Magento\Backend\Model\Auth\Session::class, - [ - 'config' => $this->config, - 'sessionConfig' => $this->sessionConfig, - 'cookieManager' => $this->cookieManager, - 'cookieMetadataFactory' => $this->cookieMetadataFactory, - 'storage' => $this->storage, - 'aclBuilder' => $this->aclBuilder - ] - ); - } - - protected function tearDown() - { - $this->config = null; - $this->sessionConfig = null; - $this->session = null; - } - - /** - * @dataProvider refreshAclDataProvider - * @param $isUserPassedViaParams - */ - public function testRefreshAcl($isUserPassedViaParams) - { - $aclMock = $this->getMockBuilder(\Magento\Framework\Acl::class)->disableOriginalConstructor()->getMock(); - $this->aclBuilder->expects($this->any())->method('getAcl')->willReturn($aclMock); - $userMock = $this->getMockBuilder(\Magento\User\Model\User::class) - ->setMethods(['getReloadAclFlag', 'setReloadAclFlag', 'unsetData', 'save']) - ->disableOriginalConstructor() - ->getMock(); - $userMock->expects($this->any())->method('getReloadAclFlag')->willReturn(true); - $userMock->expects($this->once())->method('setReloadAclFlag')->with('0')->willReturnSelf(); - $userMock->expects($this->once())->method('save'); - $this->storage->expects($this->once())->method('setAcl')->with($aclMock); - $this->storage->expects($this->any())->method('getAcl')->willReturn($aclMock); - if ($isUserPassedViaParams) { - $this->session->refreshAcl($userMock); - } else { - $this->storage->expects($this->once())->method('getUser')->willReturn($userMock); - $this->session->refreshAcl(); - } - $this->assertSame($aclMock, $this->session->getAcl()); - } - - /** - * @return array - */ - public function refreshAclDataProvider() - { - return [ - 'User set via params' => [true], - 'User set to session object' => [false] - ]; - } - - public function testIsLoggedInPositive() - { - $user = $this->createPartialMock(\Magento\User\Model\User::class, ['getId', '__wakeup']); - $user->expects($this->once()) - ->method('getId') - ->will($this->returnValue(1)); - - $this->storage->expects($this->any()) - ->method('getUser') - ->will($this->returnValue($user)); - - $this->assertTrue($this->session->isLoggedIn()); - } - - public function testProlong() - { - $name = session_name(); - $cookie = 'cookie'; - $lifetime = 900; - $path = '/'; - $domain = 'magento2'; - $secure = true; - $httpOnly = true; - - $this->config->expects($this->once()) - ->method('getValue') - ->with(\Magento\Backend\Model\Auth\Session::XML_PATH_SESSION_LIFETIME) - ->willReturn($lifetime); - $cookieMetadata = $this->createMock(\Magento\Framework\Stdlib\Cookie\PublicCookieMetadata::class); - $cookieMetadata->expects($this->once()) - ->method('setDuration') - ->with($lifetime) - ->will($this->returnSelf()); - $cookieMetadata->expects($this->once()) - ->method('setPath') - ->with($path) - ->will($this->returnSelf()); - $cookieMetadata->expects($this->once()) - ->method('setDomain') - ->with($domain) - ->will($this->returnSelf()); - $cookieMetadata->expects($this->once()) - ->method('setSecure') - ->with($secure) - ->will($this->returnSelf()); - $cookieMetadata->expects($this->once()) - ->method('setHttpOnly') - ->with($httpOnly) - ->will($this->returnSelf()); - - $this->cookieMetadataFactory->expects($this->once()) - ->method('createPublicCookieMetadata') - ->will($this->returnValue($cookieMetadata)); - - $this->cookieManager->expects($this->once()) - ->method('getCookie') - ->with($name) - ->will($this->returnValue($cookie)); - $this->cookieManager->expects($this->once()) - ->method('setPublicCookie') - ->with($name, $cookie, $cookieMetadata); - - $this->sessionConfig->expects($this->once()) - ->method('getCookiePath') - ->will($this->returnValue($path)); - $this->sessionConfig->expects($this->once()) - ->method('getCookieDomain') - ->will($this->returnValue($domain)); - $this->sessionConfig->expects($this->once()) - ->method('getCookieSecure') - ->will($this->returnValue($secure)); - $this->sessionConfig->expects($this->once()) - ->method('getCookieHttpOnly') - ->will($this->returnValue($httpOnly)); - - $this->session->prolong(); - - $this->assertLessThanOrEqual(time(), $this->session->getUpdatedAt()); - } - - /** - * @dataProvider isAllowedDataProvider - * @param bool $isUserDefined - * @param bool $isAclDefined - * @param bool $isAllowed - * @param true $expectedResult - */ - public function testIsAllowed($isUserDefined, $isAclDefined, $isAllowed, $expectedResult) - { - $userAclRole = 'userAclRole'; - if ($isAclDefined) { - $aclMock = $this->getMockBuilder(\Magento\Framework\Acl::class)->disableOriginalConstructor()->getMock(); - $this->storage->expects($this->any())->method('getAcl')->willReturn($aclMock); - } - if ($isUserDefined) { - $userMock = $this->getMockBuilder(\Magento\User\Model\User::class)->disableOriginalConstructor()->getMock(); - $this->storage->expects($this->once())->method('getUser')->willReturn($userMock); - } - if ($isAclDefined && $isUserDefined) { - $userMock->expects($this->any())->method('getAclRole')->willReturn($userAclRole); - $aclMock->expects($this->once())->method('isAllowed')->with($userAclRole)->willReturn($isAllowed); - } - - $this->assertEquals($expectedResult, $this->session->isAllowed('resource')); - } - - /** - * @return array - */ - public function isAllowedDataProvider() - { - return [ - "Negative: User not defined" => [false, true, true, false], - "Negative: Acl not defined" => [true, false, true, false], - "Negative: Permission denied" => [true, true, false, false], - "Positive: Permission granted" => [true, true, false, false], - ]; - } - - /** - * @dataProvider firstPageAfterLoginDataProvider - * @param bool $isFirstPageAfterLogin - */ - public function testFirstPageAfterLogin($isFirstPageAfterLogin) - { - $this->session->setIsFirstPageAfterLogin($isFirstPageAfterLogin); - $this->assertEquals($isFirstPageAfterLogin, $this->session->isFirstPageAfterLogin()); - } - - /** - * @return array - */ - public function firstPageAfterLoginDataProvider() - { - return [ - 'First page after login' => [true], - 'Not first page after login' => [false], - ]; - } -} diff --git a/app/code/Magento/Backend/Test/Unit/Model/Authorization/RoleLocatorTest.php b/app/code/Magento/Backend/Test/Unit/Model/Authorization/RoleLocatorTest.php deleted file mode 100644 index 77c428a6a116a..0000000000000 --- a/app/code/Magento/Backend/Test/Unit/Model/Authorization/RoleLocatorTest.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -namespace Magento\Backend\Test\Unit\Model\Authorization; - -/** - * Class RoleLocatorTest - */ -class RoleLocatorTest extends \PHPUnit\Framework\TestCase -{ - /** - * @var \Magento\Backend\Model\Authorization\RoleLocator - */ - protected $_model; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $_sessionMock = []; - - protected function setUp() - { - $this->_sessionMock = $this->createPartialMock( - \Magento\Backend\Model\Auth\Session::class, - ['getUser', 'getAclRole', 'hasUser'] - ); - $this->_model = new \Magento\Backend\Model\Authorization\RoleLocator($this->_sessionMock); - } - - public function testGetAclRoleIdReturnsCurrentUserAclRoleId() - { - $this->_sessionMock->expects($this->once())->method('hasUser')->will($this->returnValue(true)); - $this->_sessionMock->expects($this->once())->method('getUser')->will($this->returnSelf()); - $this->_sessionMock->expects($this->once())->method('getAclRole')->will($this->returnValue('some_role')); - $this->assertEquals('some_role', $this->_model->getAclRoleId()); - } -} diff --git a/app/code/Magento/Backend/Test/Unit/Model/Locale/ManagerTest.php b/app/code/Magento/Backend/Test/Unit/Model/Locale/ManagerTest.php deleted file mode 100644 index ce2b65a2249ac..0000000000000 --- a/app/code/Magento/Backend/Test/Unit/Model/Locale/ManagerTest.php +++ /dev/null @@ -1,130 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -namespace Magento\Backend\Test\Unit\Model\Locale; - -use Magento\Framework\Locale\Resolver; - -/** - * Class ManagerTest - */ -class ManagerTest extends \PHPUnit\Framework\TestCase -{ - /** - * @var \Magento\Backend\Model\Locale\Manager - */ - protected $_model; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Framework\TranslateInterface - */ - protected $_translator; - - /** - * @var \Magento\Backend\Model\Session - */ - protected $_session; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Backend\Model\Auth\Session - */ - protected $_authSession; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Backend\App\ConfigInterface - */ - protected $_backendConfig; - - protected function setUp() - { - $this->_session = $this->createMock(\Magento\Backend\Model\Session::class); - - $this->_authSession = $this->createPartialMock(\Magento\Backend\Model\Auth\Session::class, ['getUser']); - - $this->_backendConfig = $this->getMockForAbstractClass( - \Magento\Backend\App\ConfigInterface::class, - [], - '', - false - ); - - $userMock = new \Magento\Framework\DataObject(); - - $this->_authSession->expects($this->any())->method('getUser')->will($this->returnValue($userMock)); - - $this->_translator = $this->getMockBuilder(\Magento\Framework\TranslateInterface::class) - ->setMethods(['init', 'setLocale']) - ->getMockForAbstractClass(); - - $this->_translator->expects($this->any())->method('setLocale')->will($this->returnValue($this->_translator)); - - $this->_translator->expects($this->any())->method('init')->will($this->returnValue(false)); - - $this->_model = new \Magento\Backend\Model\Locale\Manager( - $this->_session, - $this->_authSession, - $this->_translator, - $this->_backendConfig - ); - } - - /** - * @return array - */ - public function switchBackendInterfaceLocaleDataProvider() - { - return ['case1' => ['locale' => 'de_DE'], 'case2' => ['locale' => 'en_US']]; - } - - /** - * @param string $locale - * @dataProvider switchBackendInterfaceLocaleDataProvider - * @covers \Magento\Backend\Model\Locale\Manager::switchBackendInterfaceLocale - */ - public function testSwitchBackendInterfaceLocale($locale) - { - $this->_model->switchBackendInterfaceLocale($locale); - - $userInterfaceLocale = $this->_authSession->getUser()->getInterfaceLocale(); - $this->assertEquals($userInterfaceLocale, $locale); - - $sessionLocale = $this->_session->getSessionLocale(); - $this->assertEquals($sessionLocale, null); - } - - /** - * @covers \Magento\Backend\Model\Locale\Manager::getUserInterfaceLocale - */ - public function testGetUserInterfaceLocaleDefault() - { - $locale = $this->_model->getUserInterfaceLocale(); - - $this->assertEquals($locale, Resolver::DEFAULT_LOCALE); - } - - /** - * @covers \Magento\Backend\Model\Locale\Manager::getUserInterfaceLocale - */ - public function testGetUserInterfaceLocale() - { - $this->_model->switchBackendInterfaceLocale('de_DE'); - $locale = $this->_model->getUserInterfaceLocale(); - - $this->assertEquals($locale, 'de_DE'); - } - - /** - * @covers \Magento\Backend\Model\Locale\Manager::getUserInterfaceLocale - */ - public function testGetUserInterfaceGeneralLocale() - { - $this->_backendConfig->expects($this->any()) - ->method('getValue') - ->with('general/locale/code') - ->willReturn('test_locale'); - $locale = $this->_model->getUserInterfaceLocale(); - $this->assertEquals($locale, 'test_locale'); - } -} diff --git a/app/code/Magento/Backend/composer.json b/app/code/Magento/Backend/composer.json index 4862e701404f7..5a7884a9607fe 100644 --- a/app/code/Magento/Backend/composer.json +++ b/app/code/Magento/Backend/composer.json @@ -22,6 +22,7 @@ "magento/module-store": "*", "magento/module-translation": "*", "magento/module-ui": "*", + "magento/module-authorization": "*", "magento/module-user": "*" }, "suggest": { diff --git a/app/code/Magento/Backend/etc/di.xml b/app/code/Magento/Backend/etc/di.xml index c526703da9975..41db85b9323a8 100644 --- a/app/code/Magento/Backend/etc/di.xml +++ b/app/code/Magento/Backend/etc/di.xml @@ -198,4 +198,8 @@ <argument name="anchorRenderer" xsi:type="object">Magento\Backend\Block\AnchorRenderer</argument> </arguments> </type> + <preference for="Magento\Backend\Spi\SessionUserHydratorInterface" + type="Magento\Backend\Model\Auth\SessionUserHydrator" /> + <preference for="Magento\Backend\Spi\SessionAclHydratorInterface" + type="Magento\Backend\Model\Auth\SessionAclHydrator" /> </config> diff --git a/app/code/Magento/Backup/README.md b/app/code/Magento/Backup/README.md index 59688ea3e716e..e1167bc4f2429 100644 --- a/app/code/Magento/Backup/README.md +++ b/app/code/Magento/Backup/README.md @@ -1,3 +1,28 @@ -The Backup module allows administrators to perform backups and rollbacks. Types of backups include system, database and media backups. This module relies on the Cron module to schedule backups. +# Magento_Backup module -This module does not affect the storefront. +The Magento_Backup module allows administrators to perform backups and rollbacks. Types of backups include system, database and media backups. This module relies on the Cron module to schedule backups. + +The Magento_Backup module does not affect the storefront. + +For more information about this module, see [Magento Backups](https://docs.magento.com/m2/ce/user_guide/system/backups.html) + +## Extensibility + +Extension developers can interact with the Magento_Backup module. For more information about the Magento extension mechanism, see [Magento plug-ins](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/plugins.html). + +[The Magento dependency injection mechanism](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/depend-inj.html) enables you to override the functionality of the Magento_Backup module. + +### Layouts + +This module introduces the following layouts and layout handles in the `view/adminhtml/layout` directory: + +`backup_index_block` +`backup_index_disabled` +`backup_index_grid` +`backup_index_index` + +For more information about layouts in Magento 2, see the [Layout documentation](https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/layouts/layout-overview.html). + +## Additional information + +For information about significant changes in patch releases, see [2.3.x Release information](https://devdocs.magento.com/guides/v2.3/release-notes/bk-release-notes.html). diff --git a/app/code/Magento/Braintree/Gateway/Request/BillingAddressDataBuilder.php b/app/code/Magento/Braintree/Gateway/Request/BillingAddressDataBuilder.php new file mode 100644 index 0000000000000..403c4d72fe358 --- /dev/null +++ b/app/code/Magento/Braintree/Gateway/Request/BillingAddressDataBuilder.php @@ -0,0 +1,112 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Braintree\Gateway\Request; + +use Magento\Payment\Gateway\Request\BuilderInterface; +use Magento\Braintree\Gateway\SubjectReader; + +/** + * Class BillingAddressDataBuilder + */ +class BillingAddressDataBuilder implements BuilderInterface +{ + /** + * @var SubjectReader + */ + private $subjectReader; + + /** + * BillingAddress block name + */ + private const BILLING_ADDRESS = 'billing'; + + /** + * The customer’s company. 255 character maximum. + */ + private const COMPANY = 'company'; + + /** + * The first name value must be less than or equal to 255 characters. + */ + private const FIRST_NAME = 'firstName'; + + /** + * The last name value must be less than or equal to 255 characters. + */ + private const LAST_NAME = 'lastName'; + + /** + * The street address. Maximum 255 characters, and must contain at least 1 digit. + * Required when AVS rules are configured to require street address. + */ + private const STREET_ADDRESS = 'streetAddress'; + + /** + * The postal code. Postal code must be a string of 5 or 9 alphanumeric digits, + * optionally separated by a dash or a space. Spaces, hyphens, + * and all other special characters are ignored. + */ + private const POSTAL_CODE = 'postalCode'; + + /** + * The ISO 3166-1 alpha-2 country code specified in an address. + * The gateway only accepts specific alpha-2 values. + * + * @link https://developers.braintreepayments.com/reference/general/countries/php#list-of-countries + */ + private const COUNTRY_CODE = 'countryCodeAlpha2'; + + /** + * The extended address information—such as apartment or suite number. 255 character maximum. + */ + private const EXTENDED_ADDRESS = 'extendedAddress'; + + /** + * The locality/city. 255 character maximum. + */ + private const LOCALITY = 'locality'; + + /** + * The state or province. For PayPal addresses, the region must be a 2-letter abbreviation; + */ + private const REGION = 'region'; + + /** + * @param SubjectReader $subjectReader + */ + public function __construct(SubjectReader $subjectReader) + { + $this->subjectReader = $subjectReader; + } + + /** + * @inheritdoc + */ + public function build(array $buildSubject) + { + $paymentDO = $this->subjectReader->readPayment($buildSubject); + + $result = []; + $order = $paymentDO->getOrder(); + + $billingAddress = $order->getBillingAddress(); + if ($billingAddress) { + $result[self::BILLING_ADDRESS] = [ + self::REGION => $billingAddress->getRegionCode(), + self::POSTAL_CODE => $billingAddress->getPostcode(), + self::COUNTRY_CODE => $billingAddress->getCountryId(), + self::FIRST_NAME => $billingAddress->getFirstname(), + self::STREET_ADDRESS => $billingAddress->getStreetLine1(), + self::LAST_NAME => $billingAddress->getLastname(), + self::COMPANY => $billingAddress->getCompany(), + self::EXTENDED_ADDRESS => $billingAddress->getStreetLine2(), + self::LOCALITY => $billingAddress->getCity() + ]; + } + + return $result; + } +} diff --git a/app/code/Magento/Braintree/README.md b/app/code/Magento/Braintree/README.md index 8c34b7ae1af67..66d872e55a21a 100644 --- a/app/code/Magento/Braintree/README.md +++ b/app/code/Magento/Braintree/README.md @@ -1 +1,47 @@ -Module Magento\Braintree implements integration with the Braintree payment system. \ No newline at end of file +# Magento_Braintree module + +The Magento_Braintree module implements integration with the Braintree payment system. + +## Extensibility + +Extension developers can interact with the Magento_Braintree module. For more information about the Magento extension mechanism, see [Magento plug-ins](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/plugins.html). + +[The Magento dependency injection mechanism](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/depend-inj.html) enables you to override the functionality of the Magento_Braintree module. + +### Events + +This module observes the following events: + + - `payment_method_assign_data_braintree` event in `Magento\Braintree\Observer\DataAssignObserver` file. + - `payment_method_assign_data_braintree_paypal` event in `Magento\Braintree\Observer\DataAssignObserver` file. + - `shortcut_buttons_container` event in `Magento\Braintree\Observer\AddPaypalShortcuts` file. + +For information about an event in Magento 2, see [Events and observers](https://devdocs.magento.com/guides/v2.3/extension-dev-guide/events-and-observers.html#events). + +### Layouts + +This module interacts with the following layouts and layout handles in the `view/adminhtml/layout` directory: + +- `braintree_paypal_review` +- `checkout_index_index` +- `multishipping_checkout_billing` +- `vault_cards_listaction` + +This module interacts with the following layout handles in the `view/frontend/layout` directory: + +- `adminhtml_system_config_edit` +- `braintree_report_index` +- `sales_order_create_index` +- `sales_order_create_load_block_billing_method` + +For more information about layouts in Magento 2, see the [Layout documentation](https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/layouts/layout-overview.html). + +### UI components + +You can extend admin notifications using the `view/adminhtml/ui_component/braintree_report.xml` configuration file. + +For information about UI components in Magento 2, see [Overview of UI components](https://devdocs.magento.com/guides/v2.3/ui_comp_guide/bk-ui_comps.html). + +## Additional information + +For information about significant changes in patch releases, see [2.3.x Release information](https://devdocs.magento.com/guides/v2.3/release-notes/bk-release-notes.html). diff --git a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ErrorCodeProviderTest.php b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ErrorCodeProviderTest.php index cddb4852da0e3..605e9253fe2cc 100644 --- a/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ErrorCodeProviderTest.php +++ b/app/code/Magento/Braintree/Test/Unit/Gateway/Validator/ErrorCodeProviderTest.php @@ -74,9 +74,9 @@ public function getErrorCodeDataProvider(): array 'errors' => [], 'transaction' => [ 'status' => 'processor_declined', - 'processorResponseCode' => '1000' + 'processorResponseCode' => '2059' ], - 'expectedResult' => ['1000'] + 'expectedResult' => ['2059'] ], [ 'errors' => [ diff --git a/app/code/Magento/Braintree/composer.json b/app/code/Magento/Braintree/composer.json index 5b5eeaf2b3dd7..58049f7bf0f93 100644 --- a/app/code/Magento/Braintree/composer.json +++ b/app/code/Magento/Braintree/composer.json @@ -22,11 +22,11 @@ "magento/module-sales": "*", "magento/module-ui": "*", "magento/module-vault": "*", - "magento/module-multishipping": "*" + "magento/module-multishipping": "*", + "magento/module-theme": "*" }, "suggest": { - "magento/module-checkout-agreements": "*", - "magento/module-theme": "*" + "magento/module-checkout-agreements": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Braintree/etc/braintree_error_mapping.xml b/app/code/Magento/Braintree/etc/braintree_error_mapping.xml index 7155264b4e6ad..bffcc75705938 100644 --- a/app/code/Magento/Braintree/etc/braintree_error_mapping.xml +++ b/app/code/Magento/Braintree/etc/braintree_error_mapping.xml @@ -21,6 +21,7 @@ <message code="81723" translate="true">Cardholder name is too long.</message> <message code="81736" translate="true">CVV verification failed.</message> <message code="cvv" translate="true">CVV verification failed.</message> + <message code="2059" translate="true">Address Verification Failed.</message> <message code="81737" translate="true">Postal code verification failed.</message> <message code="81750" translate="true">Credit card number is prohibited.</message> <message code="81801" translate="true">Addresses must have at least one field filled in.</message> diff --git a/app/code/Magento/Braintree/etc/di.xml b/app/code/Magento/Braintree/etc/di.xml index f6ad552fc9bef..ef12b5eae6835 100644 --- a/app/code/Magento/Braintree/etc/di.xml +++ b/app/code/Magento/Braintree/etc/di.xml @@ -381,7 +381,7 @@ <item name="customer" xsi:type="string">Magento\Braintree\Gateway\Request\CustomerDataBuilder</item> <item name="payment" xsi:type="string">Magento\Braintree\Gateway\Request\PaymentDataBuilder</item> <item name="channel" xsi:type="string">Magento\Braintree\Gateway\Request\ChannelDataBuilder</item> - <item name="address" xsi:type="string">Magento\Braintree\Gateway\Request\AddressDataBuilder</item> + <item name="address" xsi:type="string">Magento\Braintree\Gateway\Request\BillingAddressDataBuilder</item> <item name="dynamic_descriptor" xsi:type="string">Magento\Braintree\Gateway\Request\DescriptorDataBuilder</item> <item name="store" xsi:type="string">Magento\Braintree\Gateway\Request\StoreConfigBuilder</item> <item name="merchant_account" xsi:type="string">Magento\Braintree\Gateway\Request\MerchantAccountDataBuilder</item> diff --git a/app/code/Magento/Braintree/view/frontend/web/js/view/payment/method-renderer/paypal.js b/app/code/Magento/Braintree/view/frontend/web/js/view/payment/method-renderer/paypal.js index ae9f69c405c2b..ea5200e4ba51f 100644 --- a/app/code/Magento/Braintree/view/frontend/web/js/view/payment/method-renderer/paypal.js +++ b/app/code/Magento/Braintree/view/frontend/web/js/view/payment/method-renderer/paypal.js @@ -17,7 +17,8 @@ define([ 'Magento_Vault/js/view/payment/vault-enabler', 'Magento_Checkout/js/action/create-billing-address', 'Magento_Braintree/js/view/payment/kount', - 'mage/translate' + 'mage/translate', + 'Magento_Ui/js/model/messageList' ], function ( $, _, @@ -31,7 +32,8 @@ define([ VaultEnabler, createBillingAddress, kount, - $t + $t, + globalMessageList ) { 'use strict'; @@ -334,7 +336,7 @@ define([ } return { - line1: address.street[0], + line1: _.isUndefined(address.street) || _.isUndefined(address.street[0]) ? '' : address.street[0], city: address.city, state: address.regionCode, postalCode: address.postcode, @@ -415,6 +417,18 @@ define([ */ onVaultPaymentTokenEnablerChange: function () { this.reInitPayPal(); + }, + + /** + * Show error message + * + * @param {String} errorMessage + * @private + */ + showError: function (errorMessage) { + globalMessageList.addErrorMessage({ + message: errorMessage + }); } }); }); diff --git a/app/code/Magento/BraintreeGraphQl/etc/schema.graphqls b/app/code/Magento/BraintreeGraphQl/etc/schema.graphqls index 0492f8aaf989b..08bd10fd4c2dd 100644 --- a/app/code/Magento/BraintreeGraphQl/etc/schema.graphqls +++ b/app/code/Magento/BraintreeGraphQl/etc/schema.graphqls @@ -2,7 +2,7 @@ # See COPYING.txt for license details. type Mutation { - createBraintreeClientToken: String! @resolver(class: "\\Magento\\BraintreeGraphQl\\Model\\Resolver\\CreateBraintreeClientToken") @doc(description:"Creates Braintree Client Token for creating client-side nonce.") + createBraintreeClientToken: String! @resolver(class: "\\Magento\\BraintreeGraphQl\\Model\\Resolver\\CreateBraintreeClientToken") @doc(description:"Creates Client Token for Braintree Javascript SDK initialization.") } input PaymentMethodInput { @@ -11,9 +11,9 @@ input PaymentMethodInput { } input BraintreeInput { - payment_method_nonce: String! - is_active_payment_token_enabler: Boolean! - device_data: String + payment_method_nonce: String! @doc(description:"The one-time payment token generated by Braintree payment gateway based on card details. Required field to make sale transaction.") + is_active_payment_token_enabler: Boolean! @doc(description:"States whether an entered by a customer credit/debit card should be tokenized for later usage. Required only if Vault is enabled for Braintree payment integration.") + device_data: String @doc(description:"Contains a fingerprint provided by Braintree JS SDK and should be sent with sale transaction details to the Braintree payment gateway. Should be specified only in a case if Kount (advanced fraud protection) is enabled for Braintree payment integration.") } input BraintreeCcVaultInput { diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminAssociateBundleProductToWebsitesTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminAssociateBundleProductToWebsitesTest.xml new file mode 100644 index 0000000000000..505a319c5c44f --- /dev/null +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminAssociateBundleProductToWebsitesTest.xml @@ -0,0 +1,126 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminAssociateBundleProductToWebsitesTest"> + <annotations> + <features value="Bundle"/> + <stories value="Create/Edit bundle product in Admin"/> + <title value="Admin should be able to associate bundle product to websites"/> + <description value="Admin should be able to associate bundle product to websites"/> + <testCaseId value="MC-3344"/> + <severity value="CRITICAL"/> + <group value="bundle"/> + <group value="catalog"/> + </annotations> + <before> + <!-- Configure Store URLs --> + <magentoCLI command="config:set {{StorefrontEnableAddStoreCodeToUrls.path}} {{StorefrontEnableAddStoreCodeToUrls.value}}" stepKey="setAddStoreCodeToUrlsToYes"/> + + <!-- Create category --> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + + <!-- Create Simple product --> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct"/> + + <!-- Create Bundle product --> + <createData entity="ApiBundleProductPriceViewRange" stepKey="createBundleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="DropDownBundleOption" stepKey="bundleOption"> + <requiredEntity createDataKey="createBundleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createNewBundleLink"> + <requiredEntity createDataKey="createBundleProduct"/> + <requiredEntity createDataKey="bundleOption"/> + <requiredEntity createDataKey="createSimpleProduct"/> + </createData> + + <!-- Reindex --> + <magentoCLI command="indexer:reindex" stepKey="reindex"/> + + <!-- Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + + <!--Create website--> + <actionGroup ref="AdminCreateWebsiteActionGroup" stepKey="createSecondWebsite"> + <argument name="newWebsiteName" value="{{secondCustomWebsite.name}}"/> + <argument name="websiteCode" value="{{secondCustomWebsite.code}}"/> + </actionGroup> + <!-- Create second store --> + <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="createSecondStoreGroup"> + <argument name="website" value="{{secondCustomWebsite.name}}"/> + <argument name="storeGroupName" value="{{SecondStoreGroupUnique.name}}"/> + <argument name="storeGroupCode" value="{{SecondStoreGroupUnique.code}}"/> + </actionGroup> + <!-- Create second store view --> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createSecondStoreView"> + <argument name="StoreGroup" value="SecondStoreGroupUnique"/> + <argument name="customStore" value="SecondStoreUnique"/> + </actionGroup> + </before> + <after> + <!-- Disabled Store URLs --> + <magentoCLI command="config:set {{StorefrontDisableAddStoreCodeToUrls.path}} {{StorefrontDisableAddStoreCodeToUrls.value}}" stepKey="setAddStoreCodeToUrlsToNo"/> + + <!-- Delete simple product --> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + <!-- Delete bundle product --> + <deleteData createDataKey="createBundleProduct" stepKey="deleteBundleProduct"/> + + <!-- Delete second website --> + <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteWebsite"> + <argument name="websiteName" value="{{secondCustomWebsite.name}}"/> + </actionGroup> + + <actionGroup ref="NavigateToAndResetProductGridToDefaultView" stepKey="resetProductGridFilter"/> + + <!-- Admin logout --> + <actionGroup ref="logout" stepKey="adminLogout"/> + </after> + + <!-- Open product page and assign grouped project to second website --> + <actionGroup ref="filterAndSelectProduct" stepKey="openAdminProductPage"> + <argument name="productSku" value="$$createBundleProduct.sku$$"/> + </actionGroup> + <actionGroup ref="AdminAssignProductInWebsiteActionGroup" stepKey="assignProductToSecondWebsite"> + <argument name="website" value="{{secondCustomWebsite.name}}"/> + </actionGroup> + <actionGroup ref="AdminUnassignProductInWebsiteActionGroup" stepKey="unassignProductFromDefaultWebsite"> + <argument name="website" value="{{_defaultWebsite.name}}"/> + </actionGroup> + <actionGroup ref="saveProductForm" stepKey="saveGroupedProduct"/> + + <!-- Assert product is assigned to Second website --> + <actionGroup ref="AssertProductIsAssignedToWebsite" stepKey="seeCustomWebsiteIsChecked"> + <argument name="website" value="{{secondCustomWebsite.name}}"/> + </actionGroup> + + <!-- Assert product is not assigned to Main website --> + <actionGroup ref="AssertProductIsNotAssignedToWebsite" stepKey="seeMainWebsiteIsNotChecked"> + <argument name="website" value="{{_defaultWebsite.name}}"/> + </actionGroup> + + <!-- Go to frontend and open product on Main website --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openProductPage"> + <argument name="productUrl" value="$$createBundleProduct.custom_attributes[url_key]$$"/> + </actionGroup> + + <!-- Assert 404 page --> + <actionGroup ref="StorefrontAssertPageNotFoundErrorOnProductDetailPageActionGroup" stepKey="assertPageNotFoundError"> + <argument name="product" value="$$createBundleProduct$$"/> + </actionGroup> + + <!-- Assert product is present at Second website --> + <actionGroup ref="StorefrontOpenProductPageUsingStoreCodeInUrlActionGroup" stepKey="openProductPageUsingStoreCodeInUrl"> + <argument name="product" value="$$createBundleProduct$$"/> + <argument name="storeView" value="SecondStoreUnique"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/StorefrontSortBundleProductsByPriceTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/StorefrontSortBundleProductsByPriceTest.xml new file mode 100644 index 0000000000000..18316e41241e4 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Mftf/Test/StorefrontSortBundleProductsByPriceTest.xml @@ -0,0 +1,197 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontSortBundleProductsByPriceTest"> + <annotations> + <features value="Bundle"/> + <stories value="Bundle products list on Storefront"/> + <title value="Customer should be able to sort bundle products by price when viewing products list"/> + <description value="Customer should be able to sort bundle products by price when viewing products list"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-228"/> + <group value="bundle"/> + </annotations> + <before> + <!-- Create category --> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + + <!-- Create simple products for first bundle product --> + <createData entity="SimpleProduct2" stepKey="createFirstSimpleProduct"> + <field key="price">100.00</field> + </createData> + <createData entity="SimpleProduct2" stepKey="createSecondSimpleProduct"/> + + <!-- Create first bundle product --> + <createData entity="ApiBundleProductPriceViewRange" stepKey="createFirstBundleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="DropDownBundleOption" stepKey="firstProductBundleOption"> + <requiredEntity createDataKey="createFirstBundleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createFirstBundleLink"> + <requiredEntity createDataKey="createFirstBundleProduct"/> + <requiredEntity createDataKey="firstProductBundleOption"/> + <requiredEntity createDataKey="createFirstSimpleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createSecondBundleLink"> + <requiredEntity createDataKey="createFirstBundleProduct"/> + <requiredEntity createDataKey="firstProductBundleOption"/> + <requiredEntity createDataKey="createSecondSimpleProduct"/> + </createData> + + <!-- Create simple products for second bundle product --> + <createData entity="SimpleProduct2" stepKey="createFirstProduct"> + <field key="price">10.00</field> + </createData> + <createData entity="SimpleProduct2" stepKey="createSecondProduct"/> + + <!-- Create second bundle product --> + <createData entity="ApiBundleProductPriceViewRange" stepKey="createSecondBundleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="DropDownBundleOption" stepKey="secondProductBundleOption"> + <requiredEntity createDataKey="createSecondBundleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createBundleLinkFirst"> + <requiredEntity createDataKey="createSecondBundleProduct"/> + <requiredEntity createDataKey="secondProductBundleOption"/> + <requiredEntity createDataKey="createFirstProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createBundleLinkSecond"> + <requiredEntity createDataKey="createSecondBundleProduct"/> + <requiredEntity createDataKey="secondProductBundleOption"/> + <requiredEntity createDataKey="createSecondProduct"/> + </createData> + + <!-- Create simple products for third bundle product --> + <createData entity="SimpleProduct2" stepKey="createFirstProductForBundle"/> + <createData entity="SimpleProduct2" stepKey="createSecondProductForBundle"> + <field key="price">500.00</field> + </createData> + + <!-- Create third bundle product --> + <createData entity="ApiBundleProductPriceViewRange" stepKey="createThirdBundleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="DropDownBundleOption" stepKey="createThirdProductBundleOption"> + <requiredEntity createDataKey="createThirdBundleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createBundleFirstLink"> + <requiredEntity createDataKey="createThirdBundleProduct"/> + <requiredEntity createDataKey="createThirdProductBundleOption"/> + <requiredEntity createDataKey="createFirstProductForBundle"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createBundleSecondLink"> + <requiredEntity createDataKey="createThirdBundleProduct"/> + <requiredEntity createDataKey="createThirdProductBundleOption"/> + <requiredEntity createDataKey="createSecondProductForBundle"/> + </createData> + + <!-- Perform CLI reindex --> + <magentoCLI command="indexer:reindex" stepKey="reindex"/> + </before> + <after> + <!-- Delete all created data --> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createFirstSimpleProduct" stepKey="deleteFirstSimpleProduct"/> + <deleteData createDataKey="createSecondSimpleProduct" stepKey="deleteSecondSimpleProduct"/> + <deleteData createDataKey="createFirstBundleProduct" stepKey="deleteFirstBundleProduct"/> + <deleteData createDataKey="createFirstProduct" stepKey="deleteFirstProduct"/> + <deleteData createDataKey="createSecondProduct" stepKey="deleteSecondProduct"/> + <deleteData createDataKey="createSecondBundleProduct" stepKey="deleteSecondBundleProduct"/> + <deleteData createDataKey="createFirstProductForBundle" stepKey="deleteFirstProductForBundle"/> + <deleteData createDataKey="createSecondProductForBundle" stepKey="deleteSecondProductForBundle"/> + <deleteData createDataKey="createThirdBundleProduct" stepKey="deleteThirdBundleProduct"/> + </after> + + <!-- Open created category on Storefront --> + <actionGroup ref="StorefrontGoToCategoryPageActionGroup" stepKey="openCategoryPage"> + <argument name="categoryName" value="$$createCategory.name$$"/> + </actionGroup> + + <!-- Assert first bundle products in category product grid --> + <actionGroup ref="AssertProductOnCategoryPageActionGroup" stepKey="assertFirstBundleProduct"> + <argument name="product" value="$$createFirstBundleProduct$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seePriceRangeFromForFirstBundleProduct"> + <argument name="selector" value="{{StorefrontCategoryProductSection.priceFromByProductId($$createFirstBundleProduct.id$$)}}"/> + <argument name="userInput" value="From $100.00"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seePriceRangeToForFirstBundleProduct"> + <argument name="selector" value="{{StorefrontCategoryProductSection.priceToByProductId($$createFirstBundleProduct.id$$)}}"/> + <argument name="userInput" value="To $123.00"/> + </actionGroup> + + <!-- Assert second bundle products in category product grid --> + <actionGroup ref="AssertProductOnCategoryPageActionGroup" stepKey="assertSecondBundleProduct"> + <argument name="product" value="$$createSecondBundleProduct$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seePriceRangeFromForSecondBundleProduct"> + <argument name="selector" value="{{StorefrontCategoryProductSection.priceFromByProductId($$createSecondBundleProduct.id$$)}}"/> + <argument name="userInput" value="From $10.00"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seePriceRangeToForSecondBundleProduct"> + <argument name="selector" value="{{StorefrontCategoryProductSection.priceToByProductId($$createSecondBundleProduct.id$$)}}"/> + <argument name="userInput" value="To $123.00"/> + </actionGroup> + + <!-- Assert third bundle products in category product grid --> + <actionGroup ref="AssertProductOnCategoryPageActionGroup" stepKey="assertThirdBundleProduct"> + <argument name="product" value="$$createThirdBundleProduct$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seePriceRangeFromForThirdBundleProduct"> + <argument name="selector" value="{{StorefrontCategoryProductSection.priceFromByProductId($$createThirdBundleProduct.id$$)}}"/> + <argument name="userInput" value="From $123.00"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seePriceRangeToForThirdBundleProduct"> + <argument name="selector" value="{{StorefrontCategoryProductSection.priceToByProductId($$createThirdBundleProduct.id$$)}}"/> + <argument name="userInput" value="To $500.00"/> + </actionGroup> + + <!-- Switch category view to List mode --> + <actionGroup ref="StorefrontSwitchCategoryViewToListMode" stepKey="switchCategoryViewToListMode"/> + + <!-- Sort products By Price --> + <actionGroup ref="StorefrontCategoryPageSortProductActionGroup" stepKey="sortProductByPrice"/> + <!-- Set Ascending Direction --> + <actionGroup ref="StorefrontCategoryPageSortAscendingActionGroup" stepKey="setAscendingDirection"/> + + <!-- Assert new products positions --> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seeProductFirstPosition"> + <argument name="selector" value="{{StorefrontCategoryMainSection.lineProductName('1')}}"/> + <argument name="userInput" value="$$createThirdBundleProduct.name$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seeProductSecondPosition"> + <argument name="selector" value="{{StorefrontCategoryMainSection.lineProductName('2')}}"/> + <argument name="userInput" value="$$createFirstBundleProduct.name$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seeProductThirdPosition"> + <argument name="selector" value="{{StorefrontCategoryMainSection.lineProductName('3')}}"/> + <argument name="userInput" value="$$createSecondBundleProduct.name$$"/> + </actionGroup> + + <!-- Set Descending Direction --> + <actionGroup ref="StorefrontCategoryPageSortDescendingActionGroup" stepKey="setDescendingDirection"/> + + <!-- Assert new products positions --> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seeProductNewFirstPosition"> + <argument name="selector" value="{{StorefrontCategoryMainSection.lineProductName('1')}}"/> + <argument name="userInput" value="$$createSecondBundleProduct.name$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seeProductNewSecondPosition"> + <argument name="selector" value="{{StorefrontCategoryMainSection.lineProductName('2')}}"/> + <argument name="userInput" value="$$createFirstBundleProduct.name$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontElementVisibleActionGroup" stepKey="seeProductNewThirdPosition"> + <argument name="selector" value="{{StorefrontCategoryMainSection.lineProductName('3')}}"/> + <argument name="userInput" value="$$createThirdBundleProduct.name$$"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Bundle/etc/db_schema.xml b/app/code/Magento/Bundle/etc/db_schema.xml index 97e86e5c17359..8eafa23cbd0f3 100644 --- a/app/code/Magento/Bundle/etc/db_schema.xml +++ b/app/code/Magento/Bundle/etc/db_schema.xml @@ -10,9 +10,9 @@ <table name="catalog_product_bundle_option" resource="default" engine="innodb" comment="Catalog Product Bundle Option"> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Option Id"/> + comment="Option ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="smallint" name="required" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Required"/> <column xsi:type="int" name="position" padding="10" unsigned="true" nullable="false" identity="false" @@ -31,14 +31,14 @@ <table name="catalog_product_bundle_option_value" resource="default" engine="innodb" comment="Catalog Product Bundle Option Value"> <column xsi:type="int" name="value_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Option Id"/> + comment="Option ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="title" nullable="true" length="255" comment="Title"/> <column xsi:type="int" name="parent_product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Product Id"/> + comment="Parent Product ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="value_id"/> </constraint> @@ -54,13 +54,13 @@ <table name="catalog_product_bundle_selection" resource="default" engine="innodb" comment="Catalog Product Bundle Selection"> <column xsi:type="int" name="selection_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Selection Id"/> + comment="Selection ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Option Id"/> + comment="Option ID"/> <column xsi:type="int" name="parent_product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Product Id"/> + comment="Parent Product ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="int" name="position" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Position"/> <column xsi:type="smallint" name="is_default" padding="5" unsigned="true" nullable="false" identity="false" @@ -92,15 +92,15 @@ <table name="catalog_product_bundle_selection_price" resource="default" engine="innodb" comment="Catalog Product Bundle Selection Price"> <column xsi:type="int" name="selection_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Selection Id"/> + comment="Selection ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="smallint" name="selection_price_type" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Selection Price Type"/> <column xsi:type="decimal" name="selection_price_value" scale="6" precision="20" unsigned="false" nullable="false" default="0" comment="Selection Price Value"/> <column xsi:type="int" name="parent_product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Product Id"/> + comment="Parent Product ID"/> <constraint xsi:type="primary" referenceId="PK_CATALOG_PRODUCT_BUNDLE_SELECTION_PRICE"> <column name="selection_id"/> <column name="parent_product_id"/> @@ -122,7 +122,7 @@ <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Entity ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="int" name="customer_group_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Customer Group ID"/> <column xsi:type="decimal" name="min_price" scale="6" precision="20" unsigned="false" nullable="false" @@ -159,7 +159,7 @@ <column xsi:type="smallint" name="stock_id" padding="5" unsigned="true" nullable="false" identity="false" comment="Stock ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Option Id"/> + default="0" comment="Option ID"/> <column xsi:type="smallint" name="stock_status" padding="6" unsigned="false" nullable="true" identity="false" default="0" comment="Stock Status"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -246,9 +246,9 @@ <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" comment="Website ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Option Id"/> + default="0" comment="Option ID"/> <column xsi:type="int" name="selection_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Selection Id"/> + default="0" comment="Selection ID"/> <column xsi:type="smallint" name="group_type" padding="5" unsigned="true" nullable="true" identity="false" default="0" comment="Group Type"/> <column xsi:type="smallint" name="is_required" padding="5" unsigned="true" nullable="true" identity="false" @@ -274,9 +274,9 @@ <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" comment="Website ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Option Id"/> + default="0" comment="Option ID"/> <column xsi:type="int" name="selection_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Selection Id"/> + default="0" comment="Selection ID"/> <column xsi:type="smallint" name="group_type" padding="5" unsigned="true" nullable="true" identity="false" default="0" comment="Group Type"/> <column xsi:type="smallint" name="is_required" padding="5" unsigned="true" nullable="true" identity="false" @@ -302,7 +302,7 @@ <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" comment="Website ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Option Id"/> + default="0" comment="Option ID"/> <column xsi:type="decimal" name="min_price" scale="6" precision="20" unsigned="false" nullable="true" comment="Min Price"/> <column xsi:type="decimal" name="alt_price" scale="6" precision="20" unsigned="false" nullable="true" @@ -329,7 +329,7 @@ <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" comment="Website ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Option Id"/> + default="0" comment="Option ID"/> <column xsi:type="decimal" name="min_price" scale="6" precision="20" unsigned="false" nullable="true" comment="Min Price"/> <column xsi:type="decimal" name="alt_price" scale="6" precision="20" unsigned="false" nullable="true" diff --git a/app/code/Magento/Captcha/Observer/CheckContactUsFormObserver.php b/app/code/Magento/Captcha/Observer/CheckContactUsFormObserver.php index 91737c1a3d779..8c1da0e1ef104 100644 --- a/app/code/Magento/Captcha/Observer/CheckContactUsFormObserver.php +++ b/app/code/Magento/Captcha/Observer/CheckContactUsFormObserver.php @@ -9,6 +9,9 @@ use Magento\Framework\App\Request\DataPersistorInterface; use Magento\Framework\App\ObjectManager; +/** + * Class CheckContactUsFormObserver + */ class CheckContactUsFormObserver implements ObserverInterface { /** @@ -76,7 +79,7 @@ public function execute(\Magento\Framework\Event\Observer $observer) /** @var \Magento\Framework\App\Action\Action $controller */ $controller = $observer->getControllerAction(); if (!$captcha->isCorrect($this->captchaStringResolver->resolve($controller->getRequest(), $formId))) { - $this->messageManager->addError(__('Incorrect CAPTCHA.')); + $this->messageManager->addErrorMessage(__('Incorrect CAPTCHA.')); $this->getDataPersistor()->set($formId, $controller->getRequest()->getPostValue()); $this->_actionFlag->set('', \Magento\Framework\App\Action\Action::FLAG_NO_DISPATCH, true); $this->redirect->redirect($controller->getResponse(), 'contact/index/index'); diff --git a/app/code/Magento/Captcha/Observer/CheckForgotpasswordObserver.php b/app/code/Magento/Captcha/Observer/CheckForgotpasswordObserver.php index 0736c7514a568..623d11903926e 100644 --- a/app/code/Magento/Captcha/Observer/CheckForgotpasswordObserver.php +++ b/app/code/Magento/Captcha/Observer/CheckForgotpasswordObserver.php @@ -7,6 +7,9 @@ use Magento\Framework\Event\ObserverInterface; +/** + * Class CheckForgotpasswordObserver + */ class CheckForgotpasswordObserver implements ObserverInterface { /** @@ -69,7 +72,7 @@ public function execute(\Magento\Framework\Event\Observer $observer) /** @var \Magento\Framework\App\Action\Action $controller */ $controller = $observer->getControllerAction(); if (!$captchaModel->isCorrect($this->captchaStringResolver->resolve($controller->getRequest(), $formId))) { - $this->messageManager->addError(__('Incorrect CAPTCHA')); + $this->messageManager->addErrorMessage(__('Incorrect CAPTCHA')); $this->_actionFlag->set('', \Magento\Framework\App\Action\Action::FLAG_NO_DISPATCH, true); $this->redirect->redirect($controller->getResponse(), '*/*/forgotpassword'); } diff --git a/app/code/Magento/Captcha/Observer/CheckUserCreateObserver.php b/app/code/Magento/Captcha/Observer/CheckUserCreateObserver.php index 6d2ed4d1050ca..ef66116432f55 100644 --- a/app/code/Magento/Captcha/Observer/CheckUserCreateObserver.php +++ b/app/code/Magento/Captcha/Observer/CheckUserCreateObserver.php @@ -7,6 +7,11 @@ use Magento\Framework\Event\ObserverInterface; +/** + * Class CheckUserCreateObserver + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ class CheckUserCreateObserver implements ObserverInterface { /** @@ -86,7 +91,7 @@ public function execute(\Magento\Framework\Event\Observer $observer) /** @var \Magento\Framework\App\Action\Action $controller */ $controller = $observer->getControllerAction(); if (!$captchaModel->isCorrect($this->captchaStringResolver->resolve($controller->getRequest(), $formId))) { - $this->messageManager->addError(__('Incorrect CAPTCHA')); + $this->messageManager->addErrorMessage(__('Incorrect CAPTCHA')); $this->_actionFlag->set('', \Magento\Framework\App\Action\Action::FLAG_NO_DISPATCH, true); $this->_session->setCustomerFormData($controller->getRequest()->getPostValue()); $url = $this->_urlManager->getUrl('*/*/create', ['_nosecret' => true]); diff --git a/app/code/Magento/Captcha/Observer/CheckUserEditObserver.php b/app/code/Magento/Captcha/Observer/CheckUserEditObserver.php index 9d3cd8d367093..872bbec4ffa56 100644 --- a/app/code/Magento/Captcha/Observer/CheckUserEditObserver.php +++ b/app/code/Magento/Captcha/Observer/CheckUserEditObserver.php @@ -11,13 +11,12 @@ use Magento\Framework\App\Config\ScopeConfigInterface; /** - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * Class CheckUserEditObserver + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class CheckUserEditObserver implements ObserverInterface { - /** - * Form ID - */ const FORM_ID = 'user_edit'; /** @@ -96,7 +95,8 @@ public function __construct( * Check Captcha On Forgot Password Page * * @param \Magento\Framework\Event\Observer $observer - * @return $this + * @return $this|void + * @throws \Magento\Framework\Exception\SessionException */ public function execute(\Magento\Framework\Event\Observer $observer) { @@ -119,9 +119,9 @@ public function execute(\Magento\Framework\Event\Observer $observer) 'The account is locked. Please wait and try again or contact %1.', $this->scopeConfig->getValue('contact/email/recipient_email') ); - $this->messageManager->addError($message); + $this->messageManager->addErrorMessage($message); } - $this->messageManager->addError(__('Incorrect CAPTCHA')); + $this->messageManager->addErrorMessage(__('Incorrect CAPTCHA')); $this->actionFlag->set('', \Magento\Framework\App\Action\Action::FLAG_NO_DISPATCH, true); $this->redirect->redirect($controller->getResponse(), '*/*/edit'); } diff --git a/app/code/Magento/Captcha/Observer/CheckUserForgotPasswordBackendObserver.php b/app/code/Magento/Captcha/Observer/CheckUserForgotPasswordBackendObserver.php index 2de93dcf6b59b..e11e48a527169 100644 --- a/app/code/Magento/Captcha/Observer/CheckUserForgotPasswordBackendObserver.php +++ b/app/code/Magento/Captcha/Observer/CheckUserForgotPasswordBackendObserver.php @@ -7,6 +7,11 @@ use Magento\Framework\Event\ObserverInterface; +/** + * Class CheckUserForgotPasswordBackendObserver + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ class CheckUserForgotPasswordBackendObserver implements ObserverInterface { /** @@ -76,7 +81,7 @@ public function execute(\Magento\Framework\Event\Observer $observer) ) { $this->_session->setEmail((string)$controller->getRequest()->getPost('email')); $this->_actionFlag->set('', \Magento\Framework\App\Action\Action::FLAG_NO_DISPATCH, true); - $this->messageManager->addError(__('Incorrect CAPTCHA')); + $this->messageManager->addErrorMessage(__('Incorrect CAPTCHA')); $controller->getResponse()->setRedirect( $controller->getUrl('*/*/forgotpassword', ['_nosecret' => true]) ); diff --git a/app/code/Magento/Captcha/Observer/CheckUserLoginObserver.php b/app/code/Magento/Captcha/Observer/CheckUserLoginObserver.php index dd4974c5d842c..27507423e77eb 100644 --- a/app/code/Magento/Captcha/Observer/CheckUserLoginObserver.php +++ b/app/code/Magento/Captcha/Observer/CheckUserLoginObserver.php @@ -6,10 +6,10 @@ namespace Magento\Captcha\Observer; +use Magento\Customer\Api\CustomerRepositoryInterface; use Magento\Customer\Model\AuthenticationInterface; use Magento\Framework\Event\ObserverInterface; use Magento\Framework\Exception\NoSuchEntityException; -use Magento\Customer\Api\CustomerRepositoryInterface; /** * Check captcha on user login page observer. @@ -64,6 +64,8 @@ class CheckUserLoginObserver implements ObserverInterface protected $authentication; /** + * CheckUserLoginObserver constructor. + * * @param \Magento\Captcha\Helper\Data $helper * @param \Magento\Framework\App\ActionFlag $actionFlag * @param \Magento\Framework\Message\ManagerInterface $messageManager @@ -125,8 +127,7 @@ private function getAuthentication() * Check captcha on user login page * * @param \Magento\Framework\Event\Observer $observer - * @throws NoSuchEntityException - * @return $this + * @return $this|void */ public function execute(\Magento\Framework\Event\Observer $observer) { @@ -143,10 +144,11 @@ public function execute(\Magento\Framework\Event\Observer $observer) try { $customer = $this->getCustomerRepository()->get($login); $this->getAuthentication()->processAuthenticationFailure($customer->getId()); + // phpcs:ignore Magento2.CodeAnalysis.EmptyBlock } catch (NoSuchEntityException $e) { //do nothing as customer existence is validated later in authenticate method } - $this->messageManager->addError(__('Incorrect CAPTCHA')); + $this->messageManager->addErrorMessage(__('Incorrect CAPTCHA')); $this->_actionFlag->set('', \Magento\Framework\App\Action\Action::FLAG_NO_DISPATCH, true); $this->_session->setUsername($login); $beforeUrl = $this->_session->getBeforeAuthUrl(); diff --git a/app/code/Magento/Captcha/Test/Unit/Observer/CheckContactUsFormObserverTest.php b/app/code/Magento/Captcha/Test/Unit/Observer/CheckContactUsFormObserverTest.php index 08f76aa74ac6d..83bfb2910f9f8 100644 --- a/app/code/Magento/Captcha/Test/Unit/Observer/CheckContactUsFormObserverTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Observer/CheckContactUsFormObserverTest.php @@ -69,7 +69,10 @@ protected function setUp() $this->messageManagerMock = $this->createMock(\Magento\Framework\Message\ManagerInterface::class); $this->redirectMock = $this->createMock(\Magento\Framework\App\Response\RedirectInterface::class); $this->captchaStringResolverMock = $this->createMock(\Magento\Captcha\Observer\CaptchaStringResolver::class); - $this->sessionMock = $this->createPartialMock(\Magento\Framework\Session\SessionManager::class, ['addError']); + $this->sessionMock = $this->createPartialMock( + \Magento\Framework\Session\SessionManager::class, + ['addErrorMessage'] + ); $this->dataPersistorMock = $this->getMockBuilder(\Magento\Framework\App\Request\DataPersistorInterface::class) ->getMockForAbstractClass(); @@ -116,7 +119,7 @@ public function testCheckContactUsFormWhenCaptchaIsRequiredAndValid() $this->helperMock->expects($this->any()) ->method('getCaptcha') ->with($formId)->willReturn($this->captchaMock); - $this->sessionMock->expects($this->never())->method('addError'); + $this->sessionMock->expects($this->never())->method('addErrorMessage'); $this->checkContactUsFormObserver->execute( new \Magento\Framework\Event\Observer(['controller_action' => $controller]) @@ -163,7 +166,7 @@ public function testCheckContactUsFormRedirectsCustomerWithWarningMessageWhenCap ->method('getCaptcha') ->with($formId) ->willReturn($this->captchaMock); - $this->messageManagerMock->expects($this->once())->method('addError')->with($warningMessage); + $this->messageManagerMock->expects($this->once())->method('addErrorMessage')->with($warningMessage); $this->actionFlagMock->expects($this->once()) ->method('set') ->with('', \Magento\Framework\App\Action\Action::FLAG_NO_DISPATCH, true); diff --git a/app/code/Magento/Captcha/Test/Unit/Observer/CheckForgotpasswordObserverTest.php b/app/code/Magento/Captcha/Test/Unit/Observer/CheckForgotpasswordObserverTest.php index b05a3b2e34af0..93b58191cc334 100644 --- a/app/code/Magento/Captcha/Test/Unit/Observer/CheckForgotpasswordObserverTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Observer/CheckForgotpasswordObserverTest.php @@ -138,7 +138,7 @@ public function testCheckForgotpasswordRedirects() )->will( $this->returnValue($this->_captcha) ); - $this->_messageManager->expects($this->once())->method('addError')->with($warningMessage); + $this->_messageManager->expects($this->once())->method('addErrorMessage')->with($warningMessage); $this->_actionFlag->expects( $this->once() )->method( diff --git a/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserCreateObserverTest.php b/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserCreateObserverTest.php index 8dc67437f4879..a57faabda99eb 100644 --- a/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserCreateObserverTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserCreateObserverTest.php @@ -151,7 +151,7 @@ public function testCheckUserCreateRedirectsError() )->will( $this->returnValue($this->_captcha) ); - $this->_messageManager->expects($this->once())->method('addError')->with($warningMessage); + $this->_messageManager->expects($this->once())->method('addErrorMessage')->with($warningMessage); $this->_actionFlag->expects( $this->once() )->method( diff --git a/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserEditObserverTest.php b/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserEditObserverTest.php index 26fd8fd928c56..0f08e5c569dfc 100644 --- a/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserEditObserverTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserEditObserverTest.php @@ -146,7 +146,7 @@ public function testExecute() $message = __('The account is locked. Please wait and try again or contact %1.', $email); $this->messageManagerMock->expects($this->exactly(2)) - ->method('addError') + ->method('addErrorMessage') ->withConsecutive([$message], [__('Incorrect CAPTCHA')]); $this->actionFlagMock->expects($this->once()) diff --git a/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserLoginObserverTest.php b/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserLoginObserverTest.php index 19dc096b9ef66..0499ec3255c51 100644 --- a/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserLoginObserverTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Observer/CheckUserLoginObserverTest.php @@ -145,7 +145,7 @@ public function testExecute() ->with($customerId); $this->messageManagerMock->expects($this->once()) - ->method('addError') + ->method('addErrorMessage') ->with(__('Incorrect CAPTCHA')); $this->actionFlagMock->expects($this->once()) diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Button/Back.php b/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Button/Back.php index db42bb66c9bd1..60e17599f6dec 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Button/Back.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Product/Edit/Button/Back.php @@ -11,15 +11,32 @@ class Back extends Generic { /** + * Get Button Data + * * @return array */ public function getButtonData() { return [ 'label' => __('Back'), - 'on_click' => sprintf("location.href = '%s';", $this->getUrl('*/*/')), + 'on_click' => sprintf("location.href = '%s';", $this->getBackUrl()), 'class' => 'back', 'sort_order' => 10 ]; } + /** + * Get URL for back + * + * @return string + */ + private function getBackUrl() + { + if ($this->context->getRequestParam('customerId')) { + return $this->getUrl( + 'customer/index/edit', + ['id' => $this->context->getRequestParam('customerId')] + ); + } + return $this->getUrl('*/*/'); + } } diff --git a/app/code/Magento/Catalog/Block/Widget/Link.php b/app/code/Magento/Catalog/Block/Widget/Link.php index 85e50dbd3dc27..a25af297111d2 100644 --- a/app/code/Magento/Catalog/Block/Widget/Link.php +++ b/app/code/Magento/Catalog/Block/Widget/Link.php @@ -4,17 +4,15 @@ * See COPYING.txt for license details. */ -/** - * Widget to display catalog link - * - * @author Magento Core Team <core@magentocommerce.com> - */ namespace Magento\Catalog\Block\Widget; use Magento\CatalogUrlRewrite\Model\ProductUrlRewriteGenerator; use Magento\UrlRewrite\Model\UrlFinderInterface; use Magento\UrlRewrite\Service\V1\Data\UrlRewrite; +/** + * Render the URL of given entity + */ class Link extends \Magento\Framework\View\Element\Html\Link implements \Magento\Widget\Block\BlockInterface { /** @@ -63,10 +61,9 @@ public function __construct( /** * Prepare url using passed id path and return it - * or return false if path was not found in url rewrites. * * @throws \RuntimeException - * @return string|false + * @return string|false if path was not found in url rewrites. * @SuppressWarnings(PHPMD.NPathComplexity) */ public function getHref() @@ -93,7 +90,7 @@ public function getHref() if ($rewrite) { $href = $store->getUrl('', ['_direct' => $rewrite->getRequestPath()]); - if (strpos($href, '___store') === false) { + if ($this->addStoreCodeParam($store, $href)) { $href .= (strpos($href, '?') === false ? '?' : '&') . '___store=' . $store->getCode(); } } @@ -102,6 +99,22 @@ public function getHref() return $this->_href; } + /** + * Checks whether store code query param should be appended to the URL + * + * @param \Magento\Store\Model\Store $store + * @param string $url + * @return bool + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function addStoreCodeParam(\Magento\Store\Model\Store $store, string $url): bool + { + return $this->getStoreId() + && !$store->isUseStoreInUrl() + && $store->getId() !== $this->_storeManager->getStore()->getId() + && strpos($url, '___store') === false; + } + /** * Parse id_path * @@ -121,6 +134,7 @@ protected function parseIdPath($idPath) /** * Prepare label using passed text as parameter. + * * If anchor text was not specified get entity name from DB. * * @return string @@ -150,9 +164,8 @@ public function getLabel() /** * Render block HTML - * or return empty string if url can't be prepared * - * @return string + * @return string empty string if url can't be prepared */ protected function _toHtml() { diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Category/Add.php b/app/code/Magento/Catalog/Controller/Adminhtml/Category/Add.php index 733e270174e4c..8af59dfeaf76a 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Category/Add.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Category/Add.php @@ -6,31 +6,41 @@ */ namespace Magento\Catalog\Controller\Adminhtml\Category; -use Magento\Framework\App\Action\HttpGetActionInterface as HttpGetActionInterface; +use Magento\Backend\App\Action\Context; +use Magento\Backend\Model\View\Result\Page; +use Magento\Framework\Controller\ResultFactory; +use Magento\Backend\Model\View\Result\Redirect; +use Magento\Framework\Controller\ResultInterface; +use Magento\Catalog\Controller\Adminhtml\Category; +use Magento\Backend\Model\View\Result\ForwardFactory; +use Magento\Framework\App\Action\HttpGetActionInterface; /** * Class Add Category * * @package Magento\Catalog\Controller\Adminhtml\Category */ -class Add extends \Magento\Catalog\Controller\Adminhtml\Category implements HttpGetActionInterface +class Add extends Category implements HttpGetActionInterface { /** * Forward factory for result * - * @var \Magento\Backend\Model\View\Result\ForwardFactory + * @deprecated Unused Class: ForwardFactory + * @see $this->resultFactory->create() + * @var ForwardFactory + * */ protected $resultForwardFactory; /** * Add category constructor * - * @param \Magento\Backend\App\Action\Context $context - * @param \Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory + * @param Context $context + * @param ForwardFactory $resultForwardFactory */ public function __construct( - \Magento\Backend\App\Action\Context $context, - \Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory + Context $context, + ForwardFactory $resultForwardFactory ) { parent::__construct($context); $this->resultForwardFactory = $resultForwardFactory; @@ -39,7 +49,7 @@ public function __construct( /** * Add new category form * - * @return \Magento\Backend\Model\View\Result\Forward + * @return ResultInterface */ public function execute() { @@ -47,7 +57,7 @@ public function execute() $category = $this->_initCategory(true); if (!$category || !$parentId || $category->getId()) { - /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ + /** @var Redirect $resultRedirect */ $resultRedirect = $this->resultRedirectFactory->create(); return $resultRedirect->setPath('catalog/*/', ['_current' => true, 'id' => null]); } @@ -61,9 +71,8 @@ public function execute() $category->addData($categoryData); } - $resultPageFactory = $this->_objectManager->get(\Magento\Framework\View\Result\PageFactory::class); - /** @var \Magento\Backend\Model\View\Result\Page $resultPage */ - $resultPage = $resultPageFactory->create(); + /** @var Page $resultPage */ + $resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE); if ($this->getRequest()->getQuery('isAjax')) { return $this->ajaxRequestResponse($category, $resultPage); diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/StockDataFilter.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/StockDataFilter.php index f7e69bc72ea18..47324c5b70908 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/StockDataFilter.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/StockDataFilter.php @@ -7,6 +7,7 @@ use Magento\CatalogInventory\Api\StockConfigurationInterface; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\CatalogInventory\Model\Stock; /** * Class StockDataFilter @@ -60,8 +61,8 @@ public function filter(array $stockData) $stockData['qty'] = self::MAX_QTY_VALUE; } - if (isset($stockData['min_qty']) && (int)$stockData['min_qty'] < 0) { - $stockData['min_qty'] = 0; + if (isset($stockData['min_qty'])) { + $stockData['min_qty'] = $this->purifyMinQty($stockData['min_qty'], $stockData['backorders']); } if (!isset($stockData['is_decimal_divided']) || $stockData['is_qty_decimal'] == 0) { @@ -70,4 +71,27 @@ public function filter(array $stockData) return $stockData; } + + /** + * Purifies min_qty. + * + * @param int $minQty + * @param int $backOrders + * @return float + */ + private function purifyMinQty(int $minQty, int $backOrders): float + { + /** + * As described in the documentation if the Backorders Option is disabled + * it is recommended to set the Out Of Stock Threshold to a positive number. + * That's why to clarify the logic to the end user the code below prevent him to set a negative number so such + * a number will turn to zero. + * @see https://docs.magento.com/m2/ce/user_guide/catalog/inventory-backorders.html + */ + if ($backOrders === Stock::BACKORDERS_NO && $minQty < 0) { + $minQty = 0; + } + + return (float)$minQty; + } } diff --git a/app/code/Magento/Catalog/Model/Layer/FilterList.php b/app/code/Magento/Catalog/Model/Layer/FilterList.php index 9d7b71c981c6b..b8e9b8ad4aaa5 100644 --- a/app/code/Magento/Catalog/Model/Layer/FilterList.php +++ b/app/code/Magento/Catalog/Model/Layer/FilterList.php @@ -3,9 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Catalog\Model\Layer; +/** + * Layer navigation filters + */ class FilterList { const CATEGORY_FILTER = 'category'; @@ -106,9 +110,9 @@ protected function getAttributeFilterClass(\Magento\Catalog\Model\ResourceModel\ { $filterClassName = $this->filterTypes[self::ATTRIBUTE_FILTER]; - if ($attribute->getAttributeCode() == 'price') { + if ($attribute->getFrontendInput() === 'price') { $filterClassName = $this->filterTypes[self::PRICE_FILTER]; - } elseif ($attribute->getBackendType() == 'decimal') { + } elseif ($attribute->getBackendType() === 'decimal') { $filterClassName = $this->filterTypes[self::DECIMAL_FILTER]; } diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php b/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php index 2b4739ebeb736..6ac48c565e842 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php @@ -159,7 +159,7 @@ public function prepareForCart() if ($this->_dateExists()) { if ($this->useCalendar()) { - $timestamp += $this->_localeDate->date($value['date'], null, true, false)->getTimestamp(); + $timestamp += $this->_localeDate->date($value['date'], null, false, false)->getTimestamp(); } else { $timestamp += mktime(0, 0, 0, $value['month'], $value['day'], $value['year']); } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category.php b/app/code/Magento/Catalog/Model/ResourceModel/Category.php index 786cec391c460..797ce72ae9b7a 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category.php @@ -9,13 +9,15 @@ * * @author Magento Core Team <core@magentocommerce.com> */ +declare(strict_types=1); + namespace Magento\Catalog\Model\ResourceModel; use Magento\Catalog\Model\Indexer\Category\Product\Processor; use Magento\Framework\App\ObjectManager; use Magento\Framework\DataObject; use Magento\Framework\EntityManager\EntityManager; -use Magento\Catalog\Model\Category as CategoryEntity; +use Magento\Catalog\Setup\CategorySetup; /** * Resource model for category entity @@ -92,6 +94,7 @@ class Category extends AbstractResource * @var Processor */ private $indexerProcessor; + /** * Category constructor. * @param \Magento\Eav\Model\Entity\Context $context @@ -275,7 +278,7 @@ protected function _beforeSave(\Magento\Framework\DataObject $object) if ($object->getPosition() === null) { $object->setPosition($this->_getMaxPosition($object->getPath()) + 1); } - $path = explode('/', $object->getPath()); + $path = explode('/', (string)$object->getPath()); $level = count($path) - ($object->getId() ? 1 : 0); $toUpdateChild = array_diff($path, [$object->getId()]); @@ -314,7 +317,7 @@ protected function _afterSave(\Magento\Framework\DataObject $object) /** * Add identifier for new category */ - if (substr($object->getPath(), -1) == '/') { + if (substr((string)$object->getPath(), -1) == '/') { $object->setPath($object->getPath() . $object->getId()); $this->_savePath($object); } @@ -352,7 +355,7 @@ protected function _getMaxPosition($path) { $connection = $this->getConnection(); $positionField = $connection->quoteIdentifier('position'); - $level = count(explode('/', $path)); + $level = count(explode('/', (string)$path)); $bind = ['c_level' => $level, 'c_path' => $path . '/%']; $select = $connection->select()->from( $this->getTable('catalog_category_entity'), @@ -717,7 +720,7 @@ public function getCategories($parent, $recursionLevel = 0, $sorted = false, $as */ public function getParentCategories($category) { - $pathIds = array_reverse(explode(',', $category->getPathInStore())); + $pathIds = array_reverse(explode(',', (string)$category->getPathInStore())); /** @var \Magento\Catalog\Model\ResourceModel\Category\Collection $categories */ $categories = $this->_categoryCollectionFactory->create(); return $categories->setStore( @@ -1134,4 +1137,44 @@ private function getAggregateCount() } return $this->aggregateCount; } + + /** + * Get category with children. + * + * @param int $categoryId + * @return array + */ + public function getCategoryWithChildren(int $categoryId): array + { + $connection = $this->getConnection(); + + $selectAttributeCode = $connection->select() + ->from( + ['eav_attribute' => $this->getTable('eav_attribute')], + ['attribute_id'] + )->where('entity_type_id = ?', CategorySetup::CATEGORY_ENTITY_TYPE_ID) + ->where('attribute_code = ?', 'is_anchor') + ->limit(1); + $isAnchorAttributeCode = $connection->fetchOne($selectAttributeCode); + if (empty($isAnchorAttributeCode) || (int)$isAnchorAttributeCode <= 0) { + return []; + } + + $select = $connection->select() + ->from( + ['cce' => $this->getTable('catalog_category_entity')], + ['entity_id', 'parent_id', 'path'] + )->join( + ['cce_int' => $this->getTable('catalog_category_entity_int')], + 'cce.entity_id = cce_int.entity_id', + ['is_anchor' => 'cce_int.value'] + )->where( + 'cce_int.attribute_id = ?', + $isAnchorAttributeCode + )->where( + "cce.path LIKE '%/{$categoryId}' OR cce.path LIKE '%/{$categoryId}/%'" + )->order('path'); + + return $connection->fetchAll($select); + } } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php index 23f612582f42e..355561c5e384d 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php @@ -845,6 +845,9 @@ public function afterDelete() /** * @inheritdoc * @since 100.0.9 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -858,6 +861,9 @@ public function __sleep() /** * @inheritdoc * @since 100.0.9 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php index dbd6a7a2e1094..42d55892b6ec6 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Catalog\Model\ResourceModel\Product; @@ -22,6 +23,7 @@ use Magento\Framework\Indexer\DimensionFactory; use Magento\Store\Model\Indexer\WebsiteDimensionProvider; use Magento\Store\Model\Store; +use Magento\Catalog\Model\ResourceModel\Category; /** * Product collection @@ -302,6 +304,11 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Collection\Abstrac */ private $urlFinder; + /** + * @var Category + */ + private $categoryResourceModel; + /** * Collection constructor * @@ -330,6 +337,7 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Collection\Abstrac * @param TableMaintainer|null $tableMaintainer * @param PriceTableResolver|null $priceTableResolver * @param DimensionFactory|null $dimensionFactory + * @param Category|null $categoryResourceModel * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -358,7 +366,8 @@ public function __construct( MetadataPool $metadataPool = null, TableMaintainer $tableMaintainer = null, PriceTableResolver $priceTableResolver = null, - DimensionFactory $dimensionFactory = null + DimensionFactory $dimensionFactory = null, + Category $categoryResourceModel = null ) { $this->moduleManager = $moduleManager; $this->_catalogProductFlatState = $catalogProductFlatState; @@ -392,6 +401,8 @@ public function __construct( $this->priceTableResolver = $priceTableResolver ?: ObjectManager::getInstance()->get(PriceTableResolver::class); $this->dimensionFactory = $dimensionFactory ?: ObjectManager::getInstance()->get(DimensionFactory::class); + $this->categoryResourceModel = $categoryResourceModel ?: ObjectManager::getInstance() + ->get(Category::class); } /** @@ -1673,7 +1684,11 @@ public function addFilterByRequiredOptions() public function setVisibility($visibility) { $this->_productLimitationFilters['visibility'] = $visibility; - $this->_applyProductLimitations(); + if ($this->getStoreId() == Store::DEFAULT_STORE_ID) { + $this->addAttributeToFilter('visibility', $visibility); + } else { + $this->_applyProductLimitations(); + } return $this; } @@ -2053,12 +2068,13 @@ protected function _applyProductLimitations() protected function _applyZeroStoreProductLimitations() { $filters = $this->_productLimitationFilters; + $categories = $this->getChildrenCategories((int)$filters['category_id']); $conditions = [ 'cat_pro.product_id=e.entity_id', $this->getConnection()->quoteInto( - 'cat_pro.category_id=?', - $filters['category_id'] + 'cat_pro.category_id IN (?)', + $categories ), ]; $joinCond = join(' AND ', $conditions); @@ -2079,6 +2095,39 @@ protected function _applyZeroStoreProductLimitations() return $this; } + /** + * Get children categories. + * + * @param int $categoryId + * @return array + */ + private function getChildrenCategories(int $categoryId): array + { + $categoryIds[] = $categoryId; + $anchorCategory = []; + + $categories = $this->categoryResourceModel->getCategoryWithChildren($categoryId); + if (empty($categories)) { + return $categoryIds; + } + + $firstCategory = array_shift($categories); + if ($firstCategory['is_anchor'] == 1) { + $anchorCategory[] = (int)$firstCategory['entity_id']; + foreach ($categories as $category) { + if (in_array($category['parent_id'], $categoryIds) + && in_array($category['parent_id'], $anchorCategory)) { + $categoryIds[] = (int)$category['entity_id']; + if ($category['is_anchor'] == 1) { + $anchorCategory[] = (int)$category['entity_id']; + } + } + } + } + + return $categoryIds; + } + /** * Add category ids to loaded items * diff --git a/app/code/Magento/Catalog/Plugin/Block/Topmenu.php b/app/code/Magento/Catalog/Plugin/Block/Topmenu.php index 44f9193ab4012..b4aa5bd960b01 100644 --- a/app/code/Magento/Catalog/Plugin/Block/Topmenu.php +++ b/app/code/Magento/Catalog/Plugin/Block/Topmenu.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Plugin\Block; use Magento\Catalog\Model\Category; @@ -156,12 +158,13 @@ private function getCurrentCategory() */ private function getCategoryAsArray($category, $currentCategory, $isParentActive) { + $categoryId = $category->getId(); return [ 'name' => $category->getName(), - 'id' => 'category-node-' . $category->getId(), + 'id' => 'category-node-' . $categoryId, 'url' => $this->catalogCategory->getCategoryUrl($category), - 'has_active' => in_array((string)$category->getId(), explode('/', $currentCategory->getPath()), true), - 'is_active' => $category->getId() == $currentCategory->getId(), + 'has_active' => in_array((string)$categoryId, explode('/', (string)$currentCategory->getPath()), true), + 'is_active' => $categoryId == $currentCategory->getId(), 'is_category' => true, 'is_parent_active' => $isParentActive ]; @@ -193,4 +196,22 @@ protected function getCategoryTree($storeId, $rootId) return $collection; } + + /** + * Add active + * + * @param \Magento\Theme\Block\Html\Topmenu $subject + * @param string[] $result + * @return string[] + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetCacheKeyInfo(\Magento\Theme\Block\Html\Topmenu $subject, array $result) + { + $activeCategory = $this->getCurrentCategory(); + if ($activeCategory) { + $result[] = Category::CACHE_TAG . '_' . $activeCategory->getId(); + } + + return $result; + } } diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml index 12bf5179a07d0..b9b31b780c8f7 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml @@ -99,7 +99,11 @@ <attachFile selector="{{AdminCategoryContentSection.uploadImageFile}}" userInput="{{image.file}}" stepKey="uploadFile"/> <waitForAjaxLoad time="30" stepKey="waitForAjaxUpload"/> <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> - <see selector="{{AdminCategoryContentSection.imageFileName}}" userInput="{{image.file}}" stepKey="seeImage"/> + <grabTextFrom selector="{{AdminCategoryContentSection.imageFileName}}" stepKey="grabCategoryFileName"/> + <assertRegExp stepKey="assertEquals" message="pass"> + <expectedResult type="string">/magento-logo(_[0-9]+)*?\.png$/</expectedResult> + <actualResult type="variable">grabCategoryFileName</actualResult> + </assertRegExp> </actionGroup> <!-- Remove image from category --> @@ -128,7 +132,11 @@ <conditionalClick selector="{{AdminCategoryContentSection.sectionHeader}}" dependentSelector="{{AdminCategoryContentSection.uploadButton}}" visible="false" stepKey="openContentSection"/> <waitForPageLoad stepKey="waitForPageLoad"/> <waitForElementVisible selector="{{AdminCategoryContentSection.uploadButton}}" stepKey="seeImageSectionIsReady"/> - <see selector="{{AdminCategoryContentSection.imageFileName}}" userInput="{{image.file}}" stepKey="seeImage"/> + <grabTextFrom selector="{{AdminCategoryContentSection.imageFileName}}" stepKey="grabCategoryFileName"/> + <assertRegExp stepKey="assertEquals" message="pass"> + <expectedResult type="string">/magento-logo(_[0-9]+)*?\.png$/</expectedResult> + <actualResult type="variable">grabCategoryFileName</actualResult> + </assertRegExp> </actionGroup> <!-- Action to navigate to Media Gallery. Used in tests to cleanup uploaded images --> @@ -396,4 +404,44 @@ <click selector="{{AdminCategoryMainActionsSection.SaveButton}}" stepKey="saveCategory"/> <waitForPageLoad stepKey="waitForPageToLoad1"/> </actionGroup> + + <actionGroup name="AdminCategoryAssignProduct"> + <annotations> + <description>Requires navigation to category creation/edit page. Assign products to category - using "Products in Category" tab.</description> + </annotations> + <arguments> + <argument name="productSku" type="string"/> + </arguments> + + <conditionalClick selector="{{AdminCategoryBasicFieldSection.productsInCategory}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="false" stepKey="clickOnProductInCategory"/> + <click selector="{{AdminDataGridHeaderSection.clearFilters}}" stepKey="clickOnResetFilter"/> + <fillField selector="{{AdminCategoryContentSection.productTableColumnSku}}" userInput="{{productSku}}" stepKey="fillSkuFilter"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton"/> + <click selector="{{AdminCategoryContentSection.productTableRow}}" stepKey="selectProductFromTableRow"/> + </actionGroup> + + <actionGroup name="DeleteDefaultCategoryChildren"> + <annotations> + <description>Deletes all children categories of Default Root Category.</description> + </annotations> + + <amOnPage url="{{AdminCategoryPage.url}}" stepKey="navigateToAdminCategoryPage"/> + <executeInSelenium function="function ($webdriver) use ($I) { + $children = $webdriver->findElements(\Facebook\WebDriver\WebDriverBy::xpath('//ul[contains(@class, \'x-tree-node-ct\')]/li[@class=\'x-tree-node\' and contains(., + \'{{DefaultCategory.name}}\')]/ul[contains(@class, \'x-tree-node-ct\')]/li//a')); + while (!empty($children)) { + $I->click('//ul[contains(@class, \'x-tree-node-ct\')]/li[@class=\'x-tree-node\' and contains(., + \'{{DefaultCategory.name}}\')]/ul[contains(@class, \'x-tree-node-ct\')]/li//a'); + $I->waitForPageLoad(30); + $I->click('#delete'); + $I->waitForElementVisible('aside.confirm .modal-footer button.action-accept'); + $I->click('aside.confirm .modal-footer button.action-accept'); + $I->waitForPageLoad(30); + $I->waitForElementVisible('#messages div.message-success', 30); + $I->see('You deleted the category.', '#messages div.message-success'); + $children = $webdriver->findElements(\Facebook\WebDriver\WebDriverBy::xpath('//ul[contains(@class, \'x-tree-node-ct\')]/li[@class=\'x-tree-node\' and contains(., + \'{{DefaultCategory.name}}\')]/ul[contains(@class, \'x-tree-node-ct\')]/li//a')); + } + }" stepKey="deleteAllChildCategories"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml index 0bb73e7416b07..5c5ee0f9cb321 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml @@ -134,8 +134,8 @@ <scrollToTopOfPage stepKey="scrollTopPageProduct"/> <waitForElementVisible selector="{{AdminProductFormActionSection.saveButton}}" stepKey="waitForSaveProductButton"/> <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickSaveProduct"/> - <waitForPageLoad stepKey="waitForProductToSave"/> - <see selector="{{AdminProductMessagesSection.successMessage}}" userInput="You saved the product." stepKey="seeSaveConfirmation"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitProductSaveSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the product." stepKey="seeSaveConfirmation"/> </actionGroup> <actionGroup name="toggleProductEnabled"> @@ -149,9 +149,10 @@ <!-- Save product but do not expect a success message --> <actionGroup name="SaveProductFormNoSuccessCheck" extends="saveProductForm"> <annotations> - <description>EXTENDS: saveProductForm. Removes 'seeSaveConfirmation'.</description> + <description>EXTENDS: saveProductForm. Removes 'waitProductSaveSuccessMessage' and 'seeSaveConfirmation'.</description> </annotations> + <remove keyForRemoval="waitProductSaveSuccessMessage"/> <remove keyForRemoval="seeSaveConfirmation"/> </actionGroup> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml index 6a260bbf22522..aef79e651b584 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml @@ -423,4 +423,23 @@ <click selector="{{AdminProductGridConfirmActionSection.ok}}" stepKey="confirmProductDelete"/> <waitForPageLoad stepKey="waitForGridLoad"/> </actionGroup> + + <actionGroup name="deleteAllProductsUsingProductGrid"> + <annotations> + <description>Deletes all products in Admin Products grid page.</description> + </annotations> + + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="openAdminGridProductsPage"/> + <waitForPageLoad time="60" stepKey="waitForPageFullyLoad"/> + <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clearGridFilters"/> + + <conditionalClick selector="{{AdminProductGridSection.multicheckDropdown}}" dependentSelector="{{AdminDataGridTableSection.dataGridEmpty}}" visible="false" stepKey="openMulticheckDropdown"/> + <conditionalClick selector="{{AdminProductGridSection.multicheckOption('Select All')}}" dependentSelector="{{AdminDataGridTableSection.dataGridEmpty}}" visible="false" stepKey="selectAllProductsInGrid"/> + <click selector="{{AdminProductGridSection.bulkActionDropdown}}" stepKey="clickActionDropdown"/> + <click selector="{{AdminProductGridSection.bulkActionOption('Delete')}}" stepKey="clickDeleteAction"/> + + <waitForElementVisible selector="{{AdminConfirmationModalSection.message}}" stepKey="waitForConfirmModal"/> + <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmDelete"/> + <waitForElementVisible selector="{{AdminDataGridTableSection.dataGridEmpty}}" stepKey="waitGridIsEmpty"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontAssertProductAbsentOnCategoryPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontAssertProductAbsentOnCategoryPageActionGroup.xml new file mode 100644 index 0000000000000..4c641b621a504 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontAssertProductAbsentOnCategoryPageActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontAssertProductAbsentOnCategoryPageActionGroup"> + <annotations> + <description>Navigate to category page and verify product is absent.</description> + </annotations> + <arguments> + <argument name="category" defaultValue="_defaultCategory"/> + <argument name="product" defaultValue="SimpleProduct"/> + </arguments> + <amOnPage url="{{StorefrontCategoryPage.url(category.name)}}" stepKey="navigateToCategoryPage"/> + <waitForPageLoad stepKey="waitForCategoryPageLoad"/> + <dontSee selector="{{StorefrontCategoryMainSection.productName}}" userInput="{{product.name}}" stepKey="assertProductIsNotPresent"/> + <dontSee selector="{{StorefrontCategoryMainSection.productPrice}}" userInput="{{product.price}}" stepKey="assertProductIsNotPricePresent"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml index 341a00d3158d6..9393669f6e46d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml @@ -78,6 +78,15 @@ <seeElement selector="{{StorefrontCategoryProductSection.ProductAddToCartByName(product.name)}}" stepKey="AssertAddToCart"/> </actionGroup> + <actionGroup name="AssertProductOnCategoryPageActionGroup" extends="StorefrontCheckCategorySimpleProduct"> + <annotations> + <description>EXTENDS:StorefrontCheckCategorySimpleProduct. Removes 'AssertProductPrice', 'moveMouseOverProduct', 'AssertAddToCart'</description> + </annotations> + <remove keyForRemoval="AssertProductPrice"/> + <remove keyForRemoval="moveMouseOverProduct"/> + <remove keyForRemoval="AssertAddToCart"/> + </actionGroup> + <actionGroup name="StorefrontCheckAddToCartButtonAbsence"> <arguments> <argument name="product" defaultValue="_defaultProduct"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryPageSortProductActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryPageSortProductActionGroup.xml new file mode 100644 index 0000000000000..64dd2c97a382f --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryPageSortProductActionGroup.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontCategoryPageSortProductActionGroup"> + <annotations> + <description>Select "Sort by" parameter for sorting Products on Category page</description> + </annotations> + <arguments> + <argument name="sortBy" type="string" defaultValue="Price"/> + </arguments> + <selectOption selector="{{StorefrontCategoryTopToolbarSection.sortByDropdown}}" userInput="{{sortBy}}" stepKey="selectSortByParameter"/> + </actionGroup> + <actionGroup name="StorefrontCategoryPageSortAscendingActionGroup"> + <annotations> + <description>Set Ascending Direction for sorting Products on Category page</description> + </annotations> + <click selector="{{StorefrontCategoryTopToolbarSection.sortDirectionAsc}}" stepKey="setAscendingDirection"/> + </actionGroup> + <actionGroup name="StorefrontCategoryPageSortDescendingActionGroup"> + <annotations> + <description>Set Descending Direction for sorting Products on Category page</description> + </annotations> + <click selector="{{StorefrontCategoryTopToolbarSection.sortDirectionDesc}}" stepKey="setDescendingDirection"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml index 13951a0d197d1..6ffb4e1902424 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml @@ -117,4 +117,14 @@ <data key="is_active">true</data> <data key="include_in_menu">true</data> </entity> + <entity name="DefaultCategory" type="category"> + <data key="name">Default Category</data> + </entity> + <!-- Category from file "export_import_configurable_product.csv" --> + <entity name="CategoryExportImport" extends="SimpleSubCategory" type="category"> + <data key="name">CategoryExportImport</data> + </entity> + <entity name="SubCategoryNonAnchor" extends="SubCategoryWithParent"> + <requiredEntity type="custom_attribute">CustomAttributeCategoryNonAnchor</requiredEntity> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/CustomAttributeData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/CustomAttributeData.xml index 1684bd0c8a2c3..7bd392f0aa74a 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/CustomAttributeData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/CustomAttributeData.xml @@ -51,4 +51,8 @@ <data key="attribute_code">short_description</data> <data key="value">Short Fixedtest 555</data> </entity> + <entity name="CustomAttributeCategoryNonAnchor" type="custom_attribute"> + <data key="attribute_code">is_anchor</data> + <data key="value">0</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/FrontendLabelData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/FrontendLabelData.xml index a2bdaa7dbc62f..e4ffdbde4368d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/FrontendLabelData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/FrontendLabelData.xml @@ -20,4 +20,8 @@ <data key="store_id">0</data> <data key="label" unique="suffix">attributeThree</data> </entity> + <entity name="ProductAttributeFrontendLabelForExportImport" type="FrontendLabel"> + <data key="store_id">0</data> + <data key="label">attributeExportImport</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ImageContentData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ImageContentData.xml index 1f4b1470098e2..6e40499d0efeb 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ImageContentData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ImageContentData.xml @@ -18,4 +18,7 @@ <data key="type">image/png</data> <data key="name" unique="prefix">magento-logo.png</data> </entity> + <entity name="MagentoLogoImageContentExportImport" extends="MagentoLogoImageContent" type="ImageContent"> + <data key="name">magento-logo.png</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml index 2deec6b8c1f8e..6614fa4b5dbeb 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml @@ -278,6 +278,28 @@ <data key="used_for_sort_by">false</data> <requiredEntity type="FrontendLabel">ProductAttributeFrontendLabel</requiredEntity> </entity> + <entity name="productAttributeTypeOfPrice" type="ProductAttribute"> + <data key="attribute_code" unique="suffix">attribute</data> + <data key="frontend_input">price</data> + <data key="scope">global</data> + <data key="is_required">false</data> + <data key="is_unique">false</data> + <data key="is_searchable">false</data> + <data key="is_visible">true</data> + <data key="is_wysiwyg_enabled">false</data> + <data key="is_visible_in_advanced_search">false</data> + <data key="is_visible_on_front">true</data> + <data key="is_filterable">true</data> + <data key="is_filterable_in_search">false</data> + <data key="used_in_product_listing">false</data> + <data key="is_used_for_promo_rules">false</data> + <data key="is_comparable">true</data> + <data key="is_used_in_grid">false</data> + <data key="is_visible_in_grid">false</data> + <data key="is_filterable_in_grid">false</data> + <data key="used_for_sort_by">false</data> + <requiredEntity type="FrontendLabel">ProductAttributeFrontendLabel</requiredEntity> + </entity> <entity name="textProductAttribute" extends="productAttributeWysiwyg" type="ProductAttribute"> <data key="frontend_input">text</data> <data key="default_value" unique="suffix">defaultValue</data> @@ -376,4 +398,9 @@ <data key="frontend_label">Size</data> <data key="attribute_code" unique="suffix">size_attr</data> </entity> + <!-- Product attribute from file "export_import_configurable_product.csv" --> + <entity name="ProductAttributeWithTwoOptionsForExportImport" extends="productAttributeDropdownTwoOptions" type="ProductAttribute"> + <data key="attribute_code">attribute</data> + <requiredEntity type="FrontendLabel">ProductAttributeFrontendLabelForExportImport</requiredEntity> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMediaGalleryEntryData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMediaGalleryEntryData.xml index 98c9a70e6aad4..75b4ef773a934 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMediaGalleryEntryData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMediaGalleryEntryData.xml @@ -30,4 +30,9 @@ <data key="disabled">false</data> <requiredEntity type="ImageContent">MagentoLogoImageContent</requiredEntity> </entity> + <!-- From file "export_import_configurable_product.csv" --> + <entity name="ApiProductAttributeMediaGalleryForExportImport" extends="ApiProductAttributeMediaGalleryEntryTestImage" type="ProductAttributeMediaGalleryEntry"> + <data key="label">Magento Logo</data> + <requiredEntity type="ImageContent">MagentoLogoImageContentExportImport</requiredEntity> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml index fcb56cf298a98..bb0e85bcbb40b 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml @@ -86,4 +86,11 @@ <data key="label" unique="suffix">White</data> <data key="value" unique="suffix">white</data> </entity> + <!-- Product attribute options from file "export_import_configurable_product.csv" --> + <entity name="ProductAttributeOptionOneForExportImport" extends="productAttributeOption1" type="ProductAttributeOption"> + <data key="label">option1</data> + </entity> + <entity name="ProductAttributeOptionTwoForExportImport" extends="productAttributeOption2" type="ProductAttributeOption"> + <data key="label">option2</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml index 517ab253b8238..e122615eb8aa4 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml @@ -351,6 +351,11 @@ <data key="filename">adobe-base</data> <data key="file_extension">jpg</data> </entity> + <entity name="TestImage" extends="TestImageAdobe" type="image"> + <data key="title" unique="suffix">test_image</data> + <data key="file">test_image.jpg</data> + <data key="filename">test_image</data> + </entity> <entity name="ProductWithUnicode" type="product"> <data key="sku" unique="suffix">霁产品</data> <data key="type_id">simple</data> @@ -1165,6 +1170,15 @@ <requiredEntity type="product_extension_attribute">EavStock10</requiredEntity> <requiredEntity type="custom_attribute">CustomAttributeProductAttribute</requiredEntity> </entity> + <!-- Products from file "export_import_configurable_product.csv" --> + <entity name="ApiSimpleOneExportImport" extends="ApiSimpleOne" type="product2"> + <data key="sku">api-simple-one-export-import</data> + <data key="name">Api Simple Product One Export Import</data> + </entity> + <entity name="ApiSimpleTwoExportImport" extends="ApiSimpleTwo" type="product2"> + <data key="sku">api-simple-two-export-import</data> + <data key="name">Api Simple Product Two Export Import</data> + </entity> <entity name="SimpleProductPrice10Qty1" type="product"> <data key="sku" unique="suffix">simple-product_</data> <data key="type_id">simple</data> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductCreatePage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductCreatePage.xml index e4c4ece5ac6cf..5c7cb4d51084f 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductCreatePage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductCreatePage.xml @@ -18,7 +18,6 @@ <section name="AdminProductAttributesSection"/> <section name="AdminProductFormRelatedUpSellCrossSellSection"/> <section name="AdminProductFormAdvancedPricingSection"/> - <section name="AdminProductFormAdvancedInventorySection"/> <section name="AdminAddAttributeModalSection"/> </page> </pages> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCatalogStorefrontConfigSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCatalogStorefrontConfigSection.xml new file mode 100644 index 0000000000000..d0200f1e0a5b0 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCatalogStorefrontConfigSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCatalogStorefrontConfigSection"> + <element name="sectionHeader" type="button" selector="#catalog_frontend-head"/> + <element name="productsPerPageAllowedValues" type="input" selector="#catalog_frontend_grid_per_page_values"/> + <element name="productsPerPageDefaultValue" type="input" selector="#catalog_frontend_grid_per_page"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml index e3d224904671b..1cb095974d0fd 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml @@ -24,5 +24,6 @@ <element name="productTableColumnName" type="input" selector="#catalog_category_products_filter_name"/> <element name="productTableRow" type="button" selector="#catalog_category_products_table tbody tr"/> <element name="productSearch" type="button" selector="//button[@data-action='grid-filter-apply']" timeout="30"/> + <element name="productTableColumnSku" type="input" selector="#catalog_category_products_filter_sku"/> </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml index fba28b3feaff1..bc552721e6ab8 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml @@ -11,6 +11,8 @@ <section name="AdminCategorySidebarTreeSection"> <element name="collapseAll" type="button" selector=".tree-actions a:first-child"/> <element name="expandAll" type="button" selector=".tree-actions a:last-child"/> + <element name="categoryHighlighted" type="text" selector="//div[@id='store.menu']//span[contains(text(),'{{name}}')]/ancestor::li" parameterized="true" timeout="30"/> + <element name="categoryNotHighlighted" type="text" selector="ul[id=\'ui-id-2\'] li[class~=\'active\']" timeout="30"/> <element name="categoryTreeRoot" type="text" selector="div.x-tree-root-node>li.x-tree-node:first-of-type>div.x-tree-node-el:first-of-type" timeout="30"/> <element name="categoryInTree" type="text" selector="//a/span[contains(text(), '{{name}}')]" parameterized="true" timeout="30"/> <element name="categoryInTreeUnderRoot" type="text" selector="//li/ul/li[@class='x-tree-node']/div/a/span[contains(text(), '{{name}}')]" parameterized="true"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml index 80b4159167453..98b23a4669b72 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml @@ -215,6 +215,7 @@ <element name="textAttributeByName" type="text" selector="//div[@data-index='attributes']//fieldset[contains(@class, 'admin__field') and .//*[contains(.,'{{var}}')]]//input" parameterized="true"/> <element name="dropDownAttribute" type="select" selector="//select[@name='product[{{arg}}]']" parameterized="true" timeout="30"/> <element name="attributeSection" type="block" selector="//div[@data-index='attributes']/div[contains(@class, 'admin__collapsible-content _show')]" timeout="30"/> + <element name="customAttribute" type="text" selector="product[{{attributecode}}]" timeout="30" parameterized="true"/> <element name="attributeGroupByName" type="button" selector="//div[@class='fieldset-wrapper-title']//span[text()='{{group}}']" parameterized="true"/> <element name="attributeByGroupAndName" type="text" selector="//div[@class='fieldset-wrapper-title']//span[text()='{{group}}']/../../following-sibling::div//span[contains(text(),'attribute')]" parameterized="true"/> </section> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategoryFilterSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategoryFilterSection.xml index ddec4428f90e2..faee605e77319 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategoryFilterSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategoryFilterSection.xml @@ -11,5 +11,6 @@ <section name="StorefrontCategoryFilterSection"> <element name="CategoryFilter" type="button" selector="//main//div[@class='filter-options']//div[contains(text(), 'Category')]"/> <element name="CategoryByName" type="button" selector="//main//div[@class='filter-options']//li[@class='item']//a[contains(text(), '{{var1}}')]" parameterized="true"/> + <element name="CustomPriceAttribute" type="button" selector="div.filter-options-title"/> </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminAddImageToWYSIWYGProductTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminAddImageToWYSIWYGProductTest.xml index f3d3e653b260b..ee105320c5f29 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminAddImageToWYSIWYGProductTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminAddImageToWYSIWYGProductTest.xml @@ -16,9 +16,6 @@ <description value="Admin should be able to add image to WYSIWYG Editor on Product Page"/> <severity value="CRITICAL"/> <testCaseId value="MAGETWO-84375"/> - <skip> - <issueId value="MC-17232"/> - </skip> </annotations> <before> <actionGroup ref="LoginActionGroup" stepKey="login"/> @@ -78,6 +75,8 @@ <waitForLoadingMaskToDisappear stepKey="waitForLoading13"/> <see selector="{{ProductShortDescriptionWYSIWYGToolbarSection.CreateFolder}}" userInput="Create Folder" stepKey="seeCreateFolderBtn2" /> <waitForLoadingMaskToDisappear stepKey="waitForLoading14"/> + <click userInput="Storage Root" stepKey="clickOnRootFolder" /> + <waitForLoadingMaskToDisappear stepKey="waitForLoading15"/> <dontSeeElement selector="{{ProductShortDescriptionWYSIWYGToolbarSection.InsertFile}}" stepKey="dontSeeAddSelectedBtn3" /> <attachFile selector="{{ProductShortDescriptionWYSIWYGToolbarSection.BrowseUploadImage}}" userInput="{{ImageUpload3.value}}" stepKey="uploadImage3"/> <waitForLoadingMaskToDisappear stepKey="waitForFileUpload3"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminBackorderAllowedAddProductToCartTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminBackorderAllowedAddProductToCartTest.xml index 88c524eff387c..679cab4159ebd 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminBackorderAllowedAddProductToCartTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminBackorderAllowedAddProductToCartTest.xml @@ -23,13 +23,11 @@ <createData entity="SimpleProductInStockQuantityZero" stepKey="createProduct"/> <!-- Configure Magento to show out of stock products and to allow backorders --> - <magentoCLI command="config:set {{CatalogInventoryOptionsShowOutOfStockEnable.path}} {{CatalogInventoryOptionsShowOutOfStockEnable.value}}" stepKey="setConfigShowOutOfStockTrue"/> <magentoCLI command="config:set {{CatalogInventoryItemOptionsBackordersEnable.path}} {{CatalogInventoryItemOptionsBackordersEnable.value}}" stepKey="setConfigAllowBackordersTrue"/> </before> <after> <!-- Set Magento back to default configuration --> - <magentoCLI command="config:set {{CatalogInventoryOptionsShowOutOfStockDisable.path}} {{CatalogInventoryOptionsShowOutOfStockDisable.value}}" stepKey="setConfigShowOutOfStockFalse"/> <magentoCLI command="config:set {{CatalogInventoryItemOptionsBackordersDisable.path}} {{CatalogInventoryItemOptionsBackordersDisable.value}}" stepKey="setConfigAllowBackordersFalse"/> <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> </after> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCheckPaginationInStorefrontTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCheckPaginationInStorefrontTest.xml index f40a62c164ecc..1b72458747067 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCheckPaginationInStorefrontTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCheckPaginationInStorefrontTest.xml @@ -41,6 +41,16 @@ <createData entity="PaginationProduct" stepKey="simpleProduct18"/> <createData entity="PaginationProduct" stepKey="simpleProduct19"/> <createData entity="PaginationProduct" stepKey="simpleProduct20"/> + <createData entity="PaginationProduct" stepKey="simpleProduct21"/> + <createData entity="PaginationProduct" stepKey="simpleProduct22"/> + <createData entity="PaginationProduct" stepKey="simpleProduct23"/> + <createData entity="PaginationProduct" stepKey="simpleProduct24"/> + <createData entity="PaginationProduct" stepKey="simpleProduct25"/> + <createData entity="PaginationProduct" stepKey="simpleProduct26"/> + <createData entity="PaginationProduct" stepKey="simpleProduct27"/> + <createData entity="PaginationProduct" stepKey="simpleProduct28"/> + <createData entity="PaginationProduct" stepKey="simpleProduct29"/> + <createData entity="PaginationProduct" stepKey="simpleProduct30"/> <actionGroup ref="LoginAsAdmin" stepKey="loginToAdminPanel"/> </before> <after> @@ -67,18 +77,36 @@ <deleteData createDataKey="simpleProduct18" stepKey="deleteSimpleProduct18"/> <deleteData createDataKey="simpleProduct19" stepKey="deleteSimpleProduct19"/> <deleteData createDataKey="simpleProduct20" stepKey="deleteSimpleProduct20"/> + <deleteData createDataKey="simpleProduct21" stepKey="deleteSimpleProduct21"/> + <deleteData createDataKey="simpleProduct22" stepKey="deleteSimpleProduct22"/> + <deleteData createDataKey="simpleProduct23" stepKey="deleteSimpleProduct23"/> + <deleteData createDataKey="simpleProduct24" stepKey="deleteSimpleProduct24"/> + <deleteData createDataKey="simpleProduct25" stepKey="deleteSimpleProduct25"/> + <deleteData createDataKey="simpleProduct26" stepKey="deleteSimpleProduct26"/> + <deleteData createDataKey="simpleProduct27" stepKey="deleteSimpleProduct27"/> + <deleteData createDataKey="simpleProduct28" stepKey="deleteSimpleProduct28"/> + <deleteData createDataKey="simpleProduct29" stepKey="deleteSimpleProduct29"/> + <deleteData createDataKey="simpleProduct30" stepKey="deleteSimpleProduct30"/> <actionGroup ref="logout" stepKey="logout"/> </after> - + <!--Verify default number of products displayed in the grid view--> + <comment userInput="Verify default number of products displayed in the grid view" stepKey="commentVerifyDefaultValues"/> + <amOnPage url="{{CatalogConfigPage.url}}" stepKey="goToCatalogConfigPagePage"/> + <waitForPageLoad stepKey="waitForConfigPageLoad" /> + <conditionalClick selector="{{AdminCatalogStorefrontConfigSection.sectionHeader}}" dependentSelector="{{AdminCatalogStorefrontConfigSection.productsPerPageAllowedValues}}" visible="false" stepKey="openCatalogConfigStorefrontSection"/> + <waitForElementVisible selector="{{AdminCatalogStorefrontConfigSection.productsPerPageAllowedValues}}" stepKey="waitForSectionOpen"/> + <seeInField selector="{{AdminCatalogStorefrontConfigSection.productsPerPageAllowedValues}}" userInput="12,24,36" stepKey="seeDefaultValueAllowedNumberProductsPerPage"/> + <seeInField selector="{{AdminCatalogStorefrontConfigSection.productsPerPageDefaultValue}}" userInput="12" stepKey="seeDefaultValueProductPerPage"/> <!--Open Category Page and select created category--> + <comment userInput="Open Category Page and select created category" stepKey="commentOpenCategoryPage"/> <amOnPage url="{{AdminCategoryPage.url}}" stepKey="openAdminCategoryIndexPage"/> <waitForPageLoad stepKey="waitForPageToLoad1"/> <click selector="{{AdminCategorySidebarTreeSection.expandAll}}" stepKey="clickOnExpandTree"/> <waitForPageLoad stepKey="waitForPageToLoad0"/> <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(_defaultCategory.name)}}" stepKey="selectCreatedCategory"/> <waitForPageLoad stepKey="waitForPageToLoaded2"/> - <!--Select Products--> + <comment userInput="Select Products" stepKey="commentSelectProducts"/> <scrollTo selector="{{AdminCategoryBasicFieldSection.productsInCategory}}" x="0" y="-80" stepKey="scrollToProductInCategory"/> <click selector="{{AdminCategoryBasicFieldSection.productsInCategory}}" stepKey="clickOnProductInCategory"/> <waitForPageLoad stepKey="waitForProductsToLoad"/> @@ -86,91 +114,92 @@ <waitForElementVisible selector="{{CatalogProductsSection.resetFilter}}" time="30" stepKey="waitForResetButtonToVisible"/> <click selector="{{CatalogProductsSection.resetFilter}}" stepKey="clickOnResetFilter"/> <waitForPageLoad stepKey="waitForPageToLoad3"/> - <selectOption selector="{{AdminProductGridFilterSection.productPerPage}}" userInput="20" stepKey="selectPagePerView"/> + <selectOption selector="{{AdminProductGridFilterSection.productPerPage}}" userInput="30" stepKey="selectPagePerView"/> + <wait stepKey="waitFroPageToLoad1" time="30"/> <fillField selector="{{AdminCategoryContentSection.productTableColumnName}}" userInput="pagi" stepKey="selectProduct1"/> <click selector="{{AdminCategoryContentSection.productSearch}}" stepKey="clickSearchButton"/> - <waitForPageLoad stepKey="waitFroPageToLoad1"/> - <see selector="{{AdminProductGridFilterSection.productCount}}" userInput="20" stepKey="seeNumberOfProductsFound"/> + <waitForPageLoad stepKey="waitFroPageToLoad2"/> + <see selector="{{AdminProductGridFilterSection.productCount}}" userInput="30" stepKey="seeNumberOfProductsFound"/> <click selector="{{AdminCategoryProductsGridSection.productSelectAll}}" stepKey="selectSelectAll"/> <click selector="{{AdminCategoryMainActionsSection.SaveButton}}" stepKey="clickSaveButton"/> <waitForPageLoad stepKey="waitForCategorySaved"/> <see selector="{{AdminCategoryMessagesSection.SuccessMessage}}" userInput="You saved the category." stepKey="assertSuccessMessage"/> <waitForPageLoad stepKey="waitForPageTitleToBeSaved"/> - <!--Open Category Store Front Page--> + <comment userInput="Open Category Store Front Page" stepKey="commentOpenCategoryOnStorefront"/> <amOnPage url="{{_defaultCategory.name}}.html" stepKey="goToStorefront"/> <waitForPageLoad stepKey="waitForCategoryPageToLoad"/> <seeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(_defaultCategory.name)}}" stepKey="seeCategoryOnNavigation"/> <click selector="{{StorefrontHeaderSection.NavigationCategoryByName(_defaultCategory.name)}}" stepKey="selectCategory"/> <waitForPageLoad stepKey="waitForProductToLoad"/> - - <!--Select 9 items per page and verify number of products displayed in each page --> + <!--Select 12 items per page and verify number of products displayed in each page --> + <comment userInput="Select 12 items per page and verify number of products displayed in each page" stepKey="comment12ItemsPerPage"/> <conditionalClick selector="{{StorefrontCategoryTopToolbarSection.gridMode}}" visible="true" dependentSelector="{{StorefrontCategoryTopToolbarSection.gridMode}}" stepKey="seeProductGridIsActive"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" stepKey="scrollToBottomToolbarSection"/> - <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="9" stepKey="selectPerPageOption"/> - + <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="12" stepKey="selectPerPageOption"/> <!--Verify number of products displayed in First Page --> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="9" stepKey="seeNumberOfProductsInFirstPage"/> - + <comment userInput="Verify number of products displayed in First Page" stepKey="commentVerifyNumberOfProducts"/> + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="12" stepKey="seeNumberOfProductsInFirstPage"/> <!--Verify number of products displayed in Second Page --> + <comment userInput="Verify number of products displayed in Second Page" stepKey="commentVerifyNumberOfProductsSecondPage"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="scrollToNextButton"/> <click selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="clickOnNextPage"/> <waitForPageLoad stepKey="waitForPageToLoad4"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="9" stepKey="seeNumberOfProductsInSecondPage"/> - + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="12" stepKey="seeNumberOfProductsInSecondPage"/> <!--Verify number of products displayed in third Page --> + <comment userInput="Verify number of products displayed in third Page" stepKey="commentVerifyNumberOfProductsThirdPage"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="scrollToNextButton1"/> <click selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="clickOnNextPage1"/> <waitForPageLoad stepKey="waitForPageToLoad2"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="2" stepKey="seeNumberOfProductsInThirdPage"/> - + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="6" stepKey="seeNumberOfProductsInThirdPage"/> <!--Change Pages using Previous Page selector and verify number of products displayed in each page--> + <comment userInput="Change Pages using Previous Page selector and verify number of products displayed in each page" stepKey="commentVerifyProductsOnEachPage"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.previousPage}}" stepKey="scrollToPreviousPage"/> <click selector="{{StorefrontCategoryBottomToolbarSection.previousPage}}" stepKey="clickOnPreviousPage1"/> <waitForPageLoad stepKey="waitForPageToLoad5"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="9" stepKey="seeNumberOfProductsInSecondPage1"/> + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="12" stepKey="seeNumberOfProductsInSecondPage1"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.previousPage}}" stepKey="scrollToPreviousPage1"/> <click selector="{{StorefrontCategoryBottomToolbarSection.previousPage}}" stepKey="clickOnPreviousPage2"/> <waitForPageLoad stepKey="waitForPageToLoad6"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="9" stepKey="seeNumberOfProductsInFirstPage1"/> - + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="12" stepKey="seeNumberOfProductsInFirstPage1"/> <!--Select Pages by using page Number and verify number of products displayed--> + <comment userInput="Select Pages by using page Number and verify number of products displayed" stepKey="commentSelectPagesAndVerify"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="scrollToPreviousPage2"/> <click selector="{{StorefrontCategoryBottomToolbarSection.pageNumber('2')}}" stepKey="clickOnPage2"/> <waitForPageLoad stepKey="waitForPageToLoad7"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="9" stepKey="seeNumberOfProductsInSecondPage2"/> - + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="12" stepKey="seeNumberOfProductsInSecondPage2"/> <!--Select Third Page using page number--> + <comment userInput="Select Third Page using page number" stepKey="commentSelectThirdPage"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="scrollToPreviousPage3"/> <click selector="{{StorefrontCategoryBottomToolbarSection.pageNumber('3')}}" stepKey="clickOnThirdPage"/> <waitForPageLoad stepKey="waitForPageToLoad8"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="2" stepKey="seeNumberOfProductsInThirdPage2"/> - + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="6" stepKey="seeNumberOfProductsInThirdPage2"/> <!--Select First Page using page number--> + <comment userInput="Select First Page using page number" stepKey="commentSelectFirstPage"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.previousPage}}" stepKey="scrollToPreviousPage4"/> <click selector="{{StorefrontCategoryBottomToolbarSection.pageNumber('1')}}" stepKey="clickOnFirstPage"/> <waitForPageLoad stepKey="waitForPageToLoad9"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="9" stepKey="seeNumberOfProductsFirstPage2"/> - - <!--Select 15 items per page and verify number of products displayed in each page --> + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="12" stepKey="seeNumberOfProductsFirstPage2"/> + <!--Select 24 items per page and verify number of products displayed in each page --> + <comment userInput="Select 24 items per page and verify number of products displayed in each page" stepKey="commentSelect24ItemsPerPage"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" stepKey="scrollToPerPage"/> - <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="15" stepKey="selectPerPageOption1"/> + <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="24" stepKey="selectPerPageOption1"/> <waitForPageLoad stepKey="waitForPageToLoad10"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="15" stepKey="seeNumberOfProductsInFirstPage3"/> + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="24" stepKey="seeNumberOfProductsInFirstPage3"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="scrollToNextButton2"/> <click selector="{{StorefrontCategoryBottomToolbarSection.nextPage}}" stepKey="clickOnNextPage2"/> <waitForPageLoad stepKey="waitForPageToLoad11"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="5" stepKey="seeNumberOfProductsInSecondPage3"/> - + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="6" stepKey="seeNumberOfProductsInSecondPage3"/> <!--Select First Page using page number--> + <comment userInput="Select First Page using page number" stepKey="commentSelectFirstPageSecondTime"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.pageNumber('1')}}" stepKey="scrollToPreviousPage5"/> <click selector="{{StorefrontCategoryBottomToolbarSection.pageNumber('1')}}" stepKey="clickOnFirstPage2"/> <waitForPageLoad stepKey="waitForPageToLoad13"/> - - <!--Select 30 items per page and verify number of products displayed in each page --> + <!--Select 36 items per page and verify number of products displayed in each page --> + <comment userInput="Select 36 items per page and verify number of products displayed in each page" stepKey="commentSelect36ItemsPerPage"/> <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" stepKey="scrollToPerPage4"/> - <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="30" stepKey="selectPerPageOption2"/> + <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="36" stepKey="selectPerPageOption2"/> <waitForPageLoad stepKey="waitForPageToLoad12"/> - <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="20" stepKey="seeNumberOfProductsInFirstPage4"/> + <seeNumberOfElements selector="{{StorefrontCategoryMainSection.productLink}}" userInput="30" stepKey="seeNumberOfProductsInFirstPage4"/> </test> </tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminMassProductPriceUpdateTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminMassProductPriceUpdateTest.xml index 4d581bae700d7..f5ad5b8079d1f 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminMassProductPriceUpdateTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminMassProductPriceUpdateTest.xml @@ -10,7 +10,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="AdminMassProductPriceUpdateTest"> <annotations> - <stories value="Mass product update "/> + <stories value="Mass product update"/> <features value="Catalog"/> <title value="Mass update simple product price"/> <description value="Login as admin and update mass product price"/> @@ -24,8 +24,8 @@ <createData entity="defaultSimpleProduct" stepKey="simpleProduct2"/> </before> <after> - <deleteData createDataKey="simpleProduct1" stepKey="deleteSimpleProduct1"/> - <deleteData createDataKey="simpleProduct2" stepKey="deleteSimpleProduct2"/> + <deleteData createDataKey="simpleProduct1" stepKey="deleteSimpleProduct1"/> + <deleteData createDataKey="simpleProduct2" stepKey="deleteSimpleProduct2"/> <actionGroup ref="logout" stepKey="logout"/> </after> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductCustomURLKeyPreservedWhenAssignedToCategoryWithoutCustomURLKey.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductCustomURLKeyPreservedWhenAssignedToCategoryWithoutCustomURLKey.xml new file mode 100644 index 0000000000000..bae81513de632 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductCustomURLKeyPreservedWhenAssignedToCategoryWithoutCustomURLKey.xml @@ -0,0 +1,117 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminProductCustomURLKeyPreservedWhenAssignedToCategoryWithoutCustomURLKey"> + <annotations> + <stories value="Product"/> + <features value="Catalog"/> + <title value="Product custom URL Key is preserved when assigned to a Category (without custom URL Key) alongside with another Product without custom URL Key"/> + <description value="The test verifies that product custom URL Key is preserved when assigned to a Category (without custom URL Key) alongside with another Product without custom URL Key."/> + <severity value="MAJOR"/> + <testCaseId value="MC-6443"/> + <useCaseId value="MAGETWO-90331"/> + <group value="catalog"/> + </annotations> + <before> + <!-- Create category --> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + + <!-- Create Simple Products --> + <createData entity="SimpleProduct2" stepKey="createSimpleProductFirst"/> + <createData entity="SimpleProduct2" stepKey="createSimpleProductSecond"/> + + <!--Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="loginToAdminPanel"/> + + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createStoreView"> + <argument name="customStore" value="storeViewData"/> + </actionGroup> + + <!--Run full reindex and clear caches --> + <magentoCLI command="indexer:reindex" stepKey="reindex"/> + <magentoCLI command="cache:flush" arguments="full_page" stepKey="flushCache"/> + </before> + <after> + <deleteData createDataKey="createSimpleProductFirst" stepKey="deleteFirstSimpleProduct"/> + <deleteData createDataKey="createSimpleProductSecond" stepKey="deleteSecondSimpleProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteStoreView"> + <argument name="customStore" value="storeViewData"/> + </actionGroup> + + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Open product --> + <amOnPage url="{{AdminProductEditPage.url($createSimpleProductSecond.id$)}}" stepKey="openProductSecondEditPage"/> + <!-- switch store view --> + <actionGroup ref="AdminSwitchStoreViewActionGroup" stepKey="switchToStoreView"> + <argument name="storeView" value="storeViewData.name"/> + </actionGroup> + + <!-- set url key --> + <conditionalClick selector="{{AdminProductSEOSection.sectionHeader}}" dependentSelector="{{AdminProductSEOSection.urlKeyInput}}" visible="false" stepKey="openSeoSection"/> + <uncheckOption selector="{{AdminProductSEOSection.useDefaultUrl}}" stepKey="uncheckUseDefaultUrlKey"/> + <fillField userInput="U2" selector="{{AdminProductSEOSection.urlKeyInput}}" stepKey="fillUrlKey"/> + <actionGroup ref="saveProductForm" stepKey="saveProduct"/> + + <actionGroup ref="goToAdminCategoryPageById" stepKey="openCategory"> + <argument name="id" value="$createCategory.id$"/> + </actionGroup> + + <actionGroup ref="AdminCategoryAssignProduct" stepKey="assignSimpleProductFirst"> + <argument name="productSku" value="$createSimpleProductFirst.sku$"/> + </actionGroup> + <actionGroup ref="AdminCategoryAssignProduct" stepKey="assignSimpleProductSecond"> + <argument name="productSku" value="$createSimpleProductSecond.sku$"/> + </actionGroup> + + <actionGroup ref="saveCategoryForm" stepKey="saveCategory"/> + + <executeJS function="return '$createCategory.name$'.toLowerCase();" stepKey="categoryNameLower" /> + <executeJS function="return '$createSimpleProductFirst.name$'.toLowerCase();" stepKey="simpleProductFirstNameLower" /> + <executeJS function="return '$createSimpleProductSecond.name$'.toLowerCase();" stepKey="simpleProductSecondNameLower" /> + + <!-- Make assertions on frontend --> + <amOnPage url="{{StorefrontHomePage.url}}" stepKey="goToStorefrontPage"/> + <click selector="{{StorefrontHeaderSection.NavigationCategoryByName($createCategory.name$)}}" stepKey="onCategoryPage"/> + <seeInCurrentUrl url="{$categoryNameLower}.html" stepKey="checkCategryUrlKey"/> + + <!-- Open first product --> + <click selector="{{StorefrontCategoryProductSection.ProductTitleByName($createSimpleProductFirst.name$)}}" stepKey="openFirstProduct"/> + <waitForPageLoad time="30" stepKey="waitForFirstProduct"/> + <seeInCurrentUrl url="{$simpleProductFirstNameLower}.html" stepKey="checkFirstSimpleProductUrlKey"/> + + <amOnPage url="{{StorefrontCategoryPage.url($createCategory.custom_attributes[url_key]$)}}" stepKey="onCategoryView"/> + <!-- Open second product --> + <click selector="{{StorefrontCategoryProductSection.ProductTitleByName($createSimpleProductSecond.name$)}}" stepKey="openSecondProduct"/> + <waitForPageLoad time="30" stepKey="waitForSecondProduct"/> + <seeInCurrentUrl url="{$simpleProductSecondNameLower}.html" stepKey="checkSecondSimpleProductUrlKey"/> + + <actionGroup ref="StorefrontSwitchStoreViewActionGroup" stepKey="switchToCustomStoreView"> + <argument name="storeView" value="storeViewData"/> + </actionGroup> + + <click selector="{{StorefrontHeaderSection.NavigationCategoryByName($createCategory.name$)}}" stepKey="openCategoryPage"/> + <seeInCurrentUrl url="{$categoryNameLower}.html" stepKey="seeCategoryUrlKey"/> + + <!-- Open product first --> + <click selector="{{StorefrontCategoryProductSection.ProductTitleByName($$createSimpleProductFirst.name$$)}}" stepKey="openFirstSimpleProduct"/> + <waitForPageLoad time="30" stepKey="waitForFirstSimpleProduct"/> + <seeInCurrentUrl url="{$simpleProductFirstNameLower}.html" stepKey="assertFirstSimpleProductUrlKey"/> + + <click selector="{{StorefrontHeaderSection.NavigationCategoryByName($createCategory.name$)}}" stepKey="openCategoryView"/> + <!-- Open product2 --> + <click selector="{{StorefrontCategoryProductSection.ProductTitleByName($createSimpleProductSecond.name$)}}" stepKey="openSecondSimpleProduct"/> + <waitForPageLoad time="30" stepKey="waitForSecondSimpleProduct"/> + <seeInCurrentUrl url="u2.html" stepKey="assertSecondSimpleProductUrlKey"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml new file mode 100644 index 0000000000000..a11646cc46875 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml @@ -0,0 +1,96 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminVirtualProductTypeSwitchingToDownloadableProductTest"> + <annotations> + <features value="Catalog"/> + <stories value="Product type switching"/> + <title value="Virtual product type switching on editing to Downloadable product"/> + <description value="Virtual product type switching on editing to Downloadable product"/> + <testCaseId value="MC-17954"/> + <useCaseId value="MAGETWO-44170"/> + <severity value="MAJOR"/> + <group value="catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create product--> + <comment userInput="Create product" stepKey="commentCreateProduct"/> + <createData entity="VirtualProduct" stepKey="createProduct"/> + </before> + <after> + <!--Delete product--> + <comment userInput="Delete product" stepKey="commentDeleteProduct"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearProductFilters"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!--Change product type to Downloadable--> + <comment userInput="Change product type to Downloadable" stepKey="commentCreateDownloadable"/> + <amOnPage url="{{AdminProductEditPage.url($$createProduct.id$$)}}" stepKey="gotToDownloadableProductPage"/> + <waitForPageLoad stepKey="waitForDownloadableProductPageLoad"/> + <actionGroup ref="AdminAddDownloadableLinkInformationActionGroup" stepKey="addDownloadableLinkInformation"/> + <checkOption selector="{{AdminProductDownloadableSection.isLinksPurchasedSeparately}}" stepKey="checkOptionPurchaseSeparately"/> + <actionGroup ref="addDownloadableProductLinkWithMaxDownloads" stepKey="addDownloadableProductLink"> + <argument name="link" value="downloadableLinkWithMaxDownloads"/> + </actionGroup> + <actionGroup ref="saveProductForm" stepKey="saveDownloadableProductForm"/> + <!--Assert downloadable product on Admin product page grid--> + <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertDownloadableProductOnAdmin"/> + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> + <actionGroup ref="filterProductGridBySku2" stepKey="filterProductGridBySku"> + <argument name="sku" value="$$createProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Name')}}" userInput="$$createProduct.name$$" stepKey="seeDownloadableProductNameInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Type')}}" userInput="Downloadable Product" stepKey="seeDownloadableProductTypeInGrid"/> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearDownloadableProductFilters"/> + <!--Assert downloadable product on storefront--> + <comment userInput="Assert downloadable product on storefront" stepKey="commentAssertDownloadableProductOnStorefront"/> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="openDownloadableProductPage"/> + <waitForPageLoad stepKey="waitForStorefrontDownloadableProductPageLoad"/> + <see userInput="IN STOCK" selector="{{StorefrontProductInfoMainSection.productStockStatus}}" stepKey="assertDownloadableProductInStock"/> + <scrollTo selector="{{StorefrontDownloadableProductSection.downloadableLinkBlock}}" stepKey="scrollToLinksInStorefront"/> + <seeElement selector="{{StorefrontDownloadableProductSection.downloadableLinkLabel(downloadableLinkWithMaxDownloads.title)}}" stepKey="seeDownloadableLink" /> + </test> + <test name="AdminDownloadableProductTypeSwitchingToSimpleProductTest" extends="AdminVirtualProductTypeSwitchingToDownloadableProductTest"> + <annotations> + <features value="Catalog"/> + <stories value="Product type switching"/> + <title value="Downloadable product type switching on editing to Simple product"/> + <description value="Downloadable product type switching on editing to Simple product"/> + <testCaseId value="MC-17955"/> + <useCaseId value="MAGETWO-44170"/> + <severity value="MAJOR"/> + <group value="catalog"/> + </annotations> + <!--Change product type to Simple--> + <comment userInput="Change product type to Simple Product" stepKey="commentCreateSimple"/> + <amOnPage url="{{AdminProductEditPage.url($$createProduct.id$$)}}" stepKey="gotToProductPage"/> + <waitForPageLoad stepKey="waitForProductPageLoad"/> + <actionGroup ref="AdminAddDownloadableLinkInformationActionGroup" stepKey="addDownloadableLinkInformation"/> + <selectOption selector="{{AdminProductFormSection.productWeightSelect}}" userInput="This item has weight" stepKey="selectWeightForProduct"/> + <actionGroup ref="saveProductForm" stepKey="saveProductForm"/> + <!--Assert simple product on Admin product page grid--> + <comment userInput="Assert simple product in Admin product page grid" stepKey="commentAssertProductOnAdmin"/> + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogSimpleProductPage"/> + <actionGroup ref="filterProductGridBySku2" stepKey="filterSimpleProductGridBySku"> + <argument name="sku" value="$$createProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Name')}}" userInput="$$createProduct.name$$" stepKey="seeSimpleProductNameInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Type')}}" userInput="Simple Product" stepKey="seeSimpleProductTypeInGrid"/> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearSimpleProductFilters"/> + <!--Assert simple product on storefront--> + <comment userInput="Assert simple product on storefront" stepKey="commentAssertSimpleProductOnStorefront"/> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="openSimpleProductPage"/> + <waitForPageLoad stepKey="waitForStorefrontSimpleProductPageLoad"/> + <see userInput="IN STOCK" selector="{{StorefrontProductInfoMainSection.productStockStatus}}" stepKey="assertSimpleProductInStock"/> + <dontSeeElement selector="{{StorefrontDownloadableProductSection.downloadableLinkLabel(downloadableLinkWithMaxDownloads.title)}}" stepKey="dontSeeDownloadableLink" /> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCatalogNavigationMenuUIDesktopTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCatalogNavigationMenuUIDesktopTest.xml new file mode 100644 index 0000000000000..ae54b72a5a702 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCatalogNavigationMenuUIDesktopTest.xml @@ -0,0 +1,336 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCatalogNavigationMenuUIDesktopTest"> + <annotations> + <features value="Catalog"/> + <stories value="Storefront Catalog Navigation Menu UI"/> + <title value="Storefront Catalog Navigation Menu UI, desktop"/> + <description value="Verify UI of Navigation Menu functionality on Storefront"/> + <testCaseId value="MC-11329"/> + <severity value="CRITICAL"/> + <group value="catalog"/> + <group value="theme"/> + </annotations> + <before> + <!-- Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <actionGroup ref="DeleteDefaultCategoryChildren" stepKey="deleteRootCategoryChildren"/> + </before> + <after> + <actionGroup ref="DeleteDefaultCategoryChildren" stepKey="deleteRootCategoryChildren"/> + <actionGroup ref="AdminChangeStorefrontThemeActionGroup" stepKey="changeThemeToDefault"> + <argument name="theme" value="{{MagentoLumaTheme.name}}"/> + </actionGroup> + <!-- Admin log out --> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Go to Content > Themes. Change theme to Blank --> + <actionGroup ref="AdminChangeStorefrontThemeActionGroup" stepKey="changeThemeToBlank"> + <argument name="theme" value="{{MagentoBlankTheme.name}}"/> + </actionGroup> + + <!-- Open storefront --> + <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="openStorefrontPage"/> + + <!-- Assert no category - no menu --> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.navigationMenu}}" stepKey="dontSeeMenu"/> + + <!-- Assert single row - no hover state --> + <createData entity="ApiCategory" stepKey="createFirstCategoryBlank"> + <field key="name">Category A</field> + </createData> + <reloadPage stepKey="refreshPage"/> + <waitForPageLoad stepKey="waitForBlankSingleRowAppear"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createFirstCategoryBlank.name$$)}}" stepKey="hoverFirstCategoryBlank"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.subItemLevelHover('level0')}}" stepKey="assertNoHoverState"/> + + <!-- Create categories --> + <createData entity="ApiCategory" stepKey="createSecondCategoryBlank"> + <field key="name">TEST</field> + </createData> + <createData entity="ApiCategory" stepKey="createThirdCategoryBlank"> + <field key="name">_test2</field> + </createData> + <createData entity="ApiCategory" stepKey="createFourthCategoryBlank"> + <field key="name">test 3</field> + </createData> + <createData entity="ApiCategory" stepKey="createFifthCategoryBlank"> + <field key="name">Category with several products</field> + </createData> + <createData entity="ApiCategory" stepKey="createSixthCategoryBlank"> + <field key="name">test 5</field> + </createData> + <createData entity="ApiCategory" stepKey="createSeventhCategoryBlank"> + <field key="name">test 8</field> + </createData> + <createData entity="ApiCategory" stepKey="createEighthCategoryBlank"> + <field key="name">This is a very very very very very looong title</field> + </createData> + <createData entity="ApiCategory" stepKey="createNinthCategoryBlank"> + <field key="name">test 6</field> + </createData> + <createData entity="ApiCategory" stepKey="createTenthCategoryBlank"> + <field key="name">test 7</field> + </createData> + <createData entity="ApiCategory" stepKey="createEleventhCategoryBlank"> + <field key="name">test 4</field> + </createData> + <createData entity="ApiCategory" stepKey="createTwelfthCategoryBlank"> + <field key="name">Category with image</field> + </createData> + <createData entity="ApiCategory" stepKey="createThirteenthCategoryBlank"> + <field key="name">test 0</field> + </createData> + <createData entity="ApiCategory" stepKey="createCategoryWithoutChildrenBlank"> + <field key="name">Category with description & custom title</field> + </createData> + <createData entity="ApiCategory" stepKey="createCategoryWithChildrenBlank"> + <field key="name">Category with children</field> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createFirstCategoryLevelOneBlank"> + <field key="name">level 1 test category very very very long name</field> + <requiredEntity createDataKey="createCategoryWithChildrenBlank"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createSecondCategoryLevelOneBlank"> + <field key="name">level 1 test category name</field> + <requiredEntity createDataKey="createCategoryWithChildrenBlank"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createThirdCategoryLevelOneBlank"> + <field key="name">level 1 with children</field> + <requiredEntity createDataKey="createCategoryWithChildrenBlank"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createCategoryLevelTwoBlank"> + <field key="name">level 2 with children</field> + <requiredEntity createDataKey="createThirdCategoryLevelOneBlank"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createCategoryLevelThreeBlank"> + <field key="name">level 3 test</field> + <requiredEntity createDataKey="createCategoryLevelTwoBlank"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createFirstCategoryLevelFourBlank"> + <field key="name">level 4</field> + <requiredEntity createDataKey="createCategoryLevelThreeBlank"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createSecondCategoryLevelFourBlank"> + <field key="name">level 4 test</field> + <requiredEntity createDataKey="createCategoryLevelThreeBlank"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createCategoryLevelFiveBlank"> + <field key="name">level 5</field> + <requiredEntity createDataKey="createSecondCategoryLevelFourBlank"/> + </createData> + + <!-- Several rows. Hover on category without children --> + <reloadPage stepKey="reloadPage"/> + <waitForPageLoad stepKey="waitForBlankSeveralRowsAppear"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryWithoutChildrenBlank.name$$)}}" stepKey="hoverCategoryWithoutChildren"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createCategoryWithoutChildrenBlank.name$$, 'level0')}}" stepKey="dontSeeChildrenInCategory"/> + + <!-- Nested level 1. No hover state --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryWithChildrenBlank.name$$)}}" stepKey="hoverCategoryWithChildrenTopLevel"/> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkNoHoverState"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemByLevel('level0')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.white}}"/> + </actionGroup> + + <!-- Nested level 1. Hover state on 1st item --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createFirstCategoryLevelOneBlank.name$$)}}" stepKey="hoverCategoryLevelOneFirstItem"/> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkHighlightedAfterHoverFirstItem"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemLevelHover('level0')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.gray}}"/> + </actionGroup> + + <!-- Nested level 1 & 2. Hover state on the last item --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createThirdCategoryLevelOneBlank.name$$)}}" stepKey="hoverCategoryLevelOneLastItem"/> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkHighlightedAfterHoverLastItem"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemLevelHover('level0')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.gray}}"/> + </actionGroup> + + <!-- Submenu appears rightward --> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuRightDirection('level0')}}" stepKey="assertTopLevelMenuLeftDirection"/> + + <!-- Nested level 1 & 5 --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryLevelTwoBlank.name$$)}}" stepKey="hoverCategoryLevelTwo"/> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuLeftDirection('level1')}}" stepKey="seeLevelOneMenuLeftDirection"/> + + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryLevelThreeBlank.name$$)}}" stepKey="hoverCategoryLevelThree"/> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuLeftDirection('level2')}}" stepKey="seeLevelTwoMenuRightDirection"/> + + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createSecondCategoryLevelFourBlank.name$$)}}" stepKey="hoverCategoryLevelFour"/> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuRightDirection('level3')}}" stepKey="seeLevelThreeMenuRightDirection"/> + + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkSubcategoryHighlighted"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemLevelHover('level3')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.gray}}"/> + </actionGroup> + + <!-- Delete all creation for Blank theme --> + <deleteData createDataKey="createFirstCategoryBlank" stepKey="deleteFirstCategoryBlank"/> + <deleteData createDataKey="createSecondCategoryBlank" stepKey="deleteSecondCategoryBlank"/> + <deleteData createDataKey="createThirdCategoryBlank" stepKey="deleteThirdCategoryBlank"/> + <deleteData createDataKey="createFourthCategoryBlank" stepKey="deleteFourthCategoryBlank"/> + <deleteData createDataKey="createFifthCategoryBlank" stepKey="deleteFifthCategoryBlank"/> + <deleteData createDataKey="createSixthCategoryBlank" stepKey="deleteSixthCategoryBlank"/> + <deleteData createDataKey="createSeventhCategoryBlank" stepKey="deleteSeventhCategoryBlank"/> + <deleteData createDataKey="createEighthCategoryBlank" stepKey="deleteEighthCategoryBlank"/> + <deleteData createDataKey="createNinthCategoryBlank" stepKey="deleteNinthCategoryBlank"/> + <deleteData createDataKey="createTenthCategoryBlank" stepKey="deleteTenthCategoryBlank"/> + <deleteData createDataKey="createEleventhCategoryBlank" stepKey="deleteEleventhCategoryBlank"/> + <deleteData createDataKey="createTwelfthCategoryBlank" stepKey="deleteTwelfthCategoryBlank"/> + <deleteData createDataKey="createThirteenthCategoryBlank" stepKey="deleteThirteenthCategoryBlank"/> + <deleteData createDataKey="createCategoryWithChildrenBlank" stepKey="deleteCategoryWithChildrenBlank"/> + <deleteData createDataKey="createCategoryWithoutChildrenBlank" stepKey="deleteCategoryWithoutChildrenBlank"/> + + <!-- Go to Content > Themes. Change theme to Luma --> + <actionGroup ref="AdminChangeStorefrontThemeActionGroup" stepKey="changeThemeToLuma"> + <argument name="theme" value="{{MagentoLumaTheme.name}}"/> + </actionGroup> + + <!-- Open storefront --> + <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="openStorefront"/> + + <!-- Assert no category - no menu --> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.navigationMenu}}" stepKey="dontSeeMenuOnStorefront"/> + + <!-- Create categories --> + <createData entity="ApiCategory" stepKey="createFirstCategoryLuma"/> + <createData entity="ApiCategory" stepKey="createSecondCategoryLuma"/> + <createData entity="ApiCategory" stepKey="createThirdCategoryLuma"/> + <createData entity="ApiCategory" stepKey="createFourthCategoryLuma"/> + + <!-- Single row. No hover state --> + <reloadPage stepKey="reload"/> + <waitForPageLoad stepKey="waitForLumaSingleRowAppear"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createFirstCategoryLuma.name$$, 'level0')}}" stepKey="noHoverStateInFirstCategory"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createSecondCategoryLuma.name$$, 'level0')}}" stepKey="noHoverStateInSecondCategory"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createThirdCategoryLuma.name$$, 'level0')}}" stepKey="noHoverStateThirdCategory"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createFourthCategoryLuma.name$$, 'level0')}}" stepKey="noHoverStateInFourthCategory"/> + + <!-- Create categories for testing Luma theme --> + <createData entity="ApiCategory" stepKey="createFifthCategoryLuma"/> + <createData entity="ApiCategory" stepKey="createCategoryWithChildrenLuma"/> + <createData entity="SubCategoryWithParent" stepKey="createFirstCategoryLevelOneLuma"> + <requiredEntity createDataKey="createCategoryWithChildrenLuma"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createSecondCategoryLevelOneLuma"> + <requiredEntity createDataKey="createCategoryWithChildrenLuma"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createThirdCategoryLevelOneLuma"> + <requiredEntity createDataKey="createCategoryWithChildrenLuma"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createFirstCategoryLevelTwoLuma"> + <requiredEntity createDataKey="createThirdCategoryLevelOneLuma"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createSecondCategoryLevelTwoLuma"> + <requiredEntity createDataKey="createThirdCategoryLevelOneLuma"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createCategoryLevelThreeLuma"> + <requiredEntity createDataKey="createSecondCategoryLevelTwoLuma"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createCategoryLevelFourLuma"> + <requiredEntity createDataKey="createCategoryLevelThreeLuma"/> + </createData> + <createData entity="ApiCategory" stepKey="createSixthCategoryLuma"/> + <createData entity="ApiCategory" stepKey="createSeventhCategoryLuma"/> + <createData entity="ApiCategory" stepKey="createEighthCategoryLuma"/> + + <!-- Several rows. Hover on Category without children --> + <reloadPage stepKey="refresh"/> + <waitForPageLoad stepKey="waitForLumaSeveralRowsAppear"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createFifthCategoryLuma.name$$)}}" stepKey="hoverOnCategoryWithoutChildren"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createFifthCategoryLuma.name$$, 'level0')}}" stepKey="dontSeeSubcategoriesInCategory"/> + + <!-- Nested level 1. No hover state --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryWithChildrenLuma.name$$)}}" stepKey="hoverOnCategoryWithChildren"/> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkNoHighlightedInSubmenuAfterHover"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemByLevel('level0')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.white}}"/> + </actionGroup> + + <!-- Nested level 1. Hover state on first item --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createFirstCategoryLevelOneLuma.name$$)}}" stepKey="hoverOnFirstItemLevelOne"/> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkHighlightedAfterHoverOnFirstItem"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemLevelHover('level0')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.gray}}"/> + </actionGroup> + + <!-- Nested levels 1 & 2. Hover state on last item --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createThirdCategoryLevelOneLuma.name$$)}}" stepKey="hoverOnLastItemLevelOne"/> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkHighlightedAfterHoverOnLastItem"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemLevelHover('level0')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.gray}}"/> + </actionGroup> + + <!-- Submenu appears rightward --> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuRightDirection('level0')}}" stepKey="seeTopLevelRightDirection"/> + + <!-- Nested levels 1 & 5 --> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createSecondCategoryLevelTwoLuma.name$$)}}" stepKey="hoverThirdCategoryLevelTwo"/> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuRightDirection('level1')}}" stepKey="seeFirstLevelRightDirection"/> + + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryLevelThreeLuma.name$$)}}" stepKey="hoverOnCategoryLevelThree"/> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuRightDirection('level2')}}" stepKey="seeSecondLevelRightDirection"/> + + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryLevelFourLuma.name$$)}}" stepKey="hoverOnCategoryLevelFour"/> + <seeElement selector="{{StorefrontNavigationMenuSection.submenuRightDirection('level3')}}" stepKey="seeThirdLevelRightDirection"/> + + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkSubcategoryHighlightedAfterHover"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemLevelHover('level3')}}"/> + <argument name="property" value="background-color"/> + <argument name="color" value="{{NavigationMenuColor.gray}}"/> + </actionGroup> + + <!-- Selected 1st level category --> + <click selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryWithChildrenLuma.name$$)}}" stepKey="openTopLevelCategory"/> + <waitForPageLoad stepKey="waitForCategoryPageLoaded"/> + + <!-- Assert category active state --> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkCategoryActiveState"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.itemActiveState}}"/> + <argument name="property" value="border-color"/> + <argument name="color" value="{{NavigationMenuColor.orange}}"/> + </actionGroup> + + <!-- Selected subcategory. Assert active state --> + <actionGroup ref="StorefrontGoToSubCategoryPageActionGroup" stepKey="openSubcategory"> + <argument name="categoryName" value="$$createCategoryWithChildrenLuma.name$$"/> + <argument name="subCategoryName" value="$$createThirdCategoryLevelOneLuma.name$$"/> + </actionGroup> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryWithChildrenLuma.name$$)}}" stepKey="hoverOnCategory"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createThirdCategoryLevelOneLuma.name$$)}}" stepKey="hoverOnSubcategory"/> + + <!-- Assert subcategory active state --> + <actionGroup ref="StorefrontCheckElementColorActionGroup" stepKey="checkSubitemActiveState"> + <argument name="selector" value="{{StorefrontNavigationMenuSection.subItemActiveState}}"/> + <argument name="property" value="border-color"/> + <argument name="color" value="{{NavigationMenuColor.orange}}"/> + </actionGroup> + + <!-- Delete created category --> + <deleteData createDataKey="createFirstCategoryLuma" stepKey="deleteFirstCategoryLuma"/> + <deleteData createDataKey="createSecondCategoryLuma" stepKey="deleteSecondCategoryLuma"/> + <deleteData createDataKey="createThirdCategoryLuma" stepKey="deleteThirdCategoryLuma"/> + <deleteData createDataKey="createFourthCategoryLuma" stepKey="deleteFourthCategoryLuma"/> + <deleteData createDataKey="createFifthCategoryLuma" stepKey="deleteFifthCategoryLuma"/> + <deleteData createDataKey="createSixthCategoryLuma" stepKey="deleteSixthCategoryLuma"/> + <deleteData createDataKey="createSeventhCategoryLuma" stepKey="deleteSeventhCategoryLuma"/> + <deleteData createDataKey="createEighthCategoryLuma" stepKey="deleteEighthCategoryLuma"/> + <deleteData createDataKey="createCategoryWithChildrenLuma" stepKey="deleteCategoryWithChildrenLuma"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCategoryHighlightedAndProductDisplayedTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCategoryHighlightedAndProductDisplayedTest.xml new file mode 100644 index 0000000000000..3e72df9133898 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCategoryHighlightedAndProductDisplayedTest.xml @@ -0,0 +1,85 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCategoryHighlightedAndProductDisplayedTest"> + <annotations> + <features value="Catalog"/> + <stories value="Category"/> + <title value="Сheck that current category is highlighted and all products displayed for it"/> + <description value="Сheck that current category is highlighted and all products displayed for it"/> + <severity value="MAJOR"/> + <testCaseId value="MC-19626"/> + <useCaseId value="MAGETWO-98748"/> + <group value="Catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <createData entity="SimpleSubCategory" stepKey="category1"/> + <createData entity="SimpleSubCategory" stepKey="category2"/> + <createData entity="SimpleSubCategory" stepKey="category3"/> + <createData entity="SimpleSubCategory" stepKey="category4"/> + <createData entity="SimpleProduct" stepKey="product1"> + <requiredEntity createDataKey="category1"/> + </createData> + <createData entity="SimpleProduct" stepKey="product2"> + <requiredEntity createDataKey="category1"/> + </createData> + <createData entity="SimpleProduct" stepKey="product3"> + <requiredEntity createDataKey="category2"/> + </createData> + <createData entity="SimpleProduct" stepKey="product4"> + <requiredEntity createDataKey="category2"/> + </createData> + </before> + <after> + <deleteData createDataKey="product1" stepKey="deleteProduct1"/> + <deleteData createDataKey="product2" stepKey="deleteProduct2"/> + <deleteData createDataKey="product3" stepKey="deleteProduct3"/> + <deleteData createDataKey="product4" stepKey="deleteProduct4"/> + <deleteData createDataKey="category1" stepKey="deleteCategory1"/> + <deleteData createDataKey="category2" stepKey="deleteCategory2"/> + <deleteData createDataKey="category3" stepKey="deleteCategory3"/> + <deleteData createDataKey="category4" stepKey="deleteCategory4"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!--Open Storefront home page--> + <comment userInput="Open Storefront home page" stepKey="openStorefrontHomePage"/> + <amOnPage url="{{StorefrontHomePage.url}}" stepKey="goToStorefrontHomePage"/> + <waitForPageLoad stepKey="waitForSimpleProductPage"/> + <!--Click on first category--> + <comment userInput="Click on first category" stepKey="openFirstCategoryPage"/> + <click selector="{{AdminCategorySidebarTreeSection.categoryInTree($$category1.name$$)}}" stepKey="clickCategory1Name"/> + <waitForPageLoad stepKey="waitForCategory1Page"/> + <!--Check if current category is highlighted and the others are not--> + <comment userInput="Check if current category is highlighted and the others are not" stepKey="checkCateg1NameIsHighlighted"/> + <grabAttributeFrom selector="{{AdminCategorySidebarTreeSection.categoryHighlighted($$category1.name$$)}}" userInput="class" stepKey="grabCategory1Class"/> + <assertContains expectedType="string" expected="active" actual="$grabCategory1Class" stepKey="assertCategory1IsHighlighted"/> + <executeJS function="return document.querySelectorAll('{{AdminCategorySidebarTreeSection.categoryNotHighlighted}}').length" stepKey="highlightedAmount"/> + <assertEquals expectedType="int" expected="1" actual="$highlightedAmount" stepKey="assertRestCategories1IsNotHighlighted"/> + <!--See products in the category page--> + <comment userInput="See products in the category page" stepKey="seeProductsInCategoryPage"/> + <seeElement selector="{{StorefrontCategoryMainSection.specifiedProductItemInfo($product1.name$)}}" stepKey="seeProduct1InCategoryPage"/> + <seeElement selector="{{StorefrontCategoryMainSection.specifiedProductItemInfo($product2.name$)}}" stepKey="seeProduct2InCategoryPage"/> + <!--Click on second category--> + <comment userInput="Click on second category" stepKey="openSecondCategoryPage"/> + <click selector="{{AdminCategorySidebarTreeSection.categoryInTree($$category2.name$$)}}" stepKey="clickCategory2Name"/> + <waitForPageLoad stepKey="waitForCategory2Page"/> + <!--Check if current category is highlighted and the others are not--> + <comment userInput="Check if current category is highlighted and the others are not" stepKey="checkCateg2NameIsHighlighted"/> + <grabAttributeFrom selector="{{AdminCategorySidebarTreeSection.categoryHighlighted($$category2.name$$)}}" userInput="class" stepKey="grabCategory2Class"/> + <assertContains expectedType="string" expected="active" actual="$grabCategory2Class" stepKey="assertCategory2IsHighlighted"/> + <executeJS function="return document.querySelectorAll('{{AdminCategorySidebarTreeSection.categoryNotHighlighted}}').length" stepKey="highlightedAmount2"/> + <assertEquals expectedType="int" expected="1" actual="$highlightedAmount2" stepKey="assertRestCategories1IsNotHighlighted2"/> + <!--Assert products in second category page--> + <comment userInput="Assert products in second category page" stepKey="commentAssertProducts"/> + <seeElement selector="{{StorefrontCategoryMainSection.specifiedProductItemInfo($product3.name$)}}" stepKey="seeProduct3InCategoryPage"/> + <seeElement selector="{{StorefrontCategoryMainSection.specifiedProductItemInfo($product4.name$)}}" stepKey="seeProduct4InCategoryPage"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCheckDefaultNumberProductsToDisplayTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCheckDefaultNumberProductsToDisplayTest.xml new file mode 100644 index 0000000000000..ac2605ff5f3e2 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCheckDefaultNumberProductsToDisplayTest.xml @@ -0,0 +1,201 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckDefaultNumbersProductsToDisplayTest"> + <annotations> + <features value="Catalog"/> + <stories value="Product grid"/> + <title value="Check default numbers: products to display"/> + <description value="Check default numbers: products to display"/> + <severity value="MAJOR"/> + <testCaseId value="MC-17386"/> + <useCaseId value="MC-15341"/> + <group value="catalog"/> + </annotations> + <before> + <!-- Login as Admin --> + <comment userInput="Login as Admin" stepKey="commentLoginAsAdmin"/> + <actionGroup ref="LoginAsAdmin" stepKey="login"/> + <!--Create 37 Products and Subcategory --> + <comment userInput="Create 37 Products and Subcategory" stepKey="commentCreateData"/> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createSimpleProductOne"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwo"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThree"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductFour"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductFive"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductSix"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductSeven"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductEight"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductNine"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductEleven"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwelve"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirteen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductFourteen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductFifteen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductSixteen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductSeventeen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductEighteen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductNineteen"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwenty"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentyOne"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentyTwo"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentyThree"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentyFour"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentyFive"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentySix"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentySeven"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentyEight"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductTwentyNine"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirty"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirtyOne"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirtyTwo"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirtyThree"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirtyFour"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirtyFive"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirtySix"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="createSimpleProductThirtySeven"> + <requiredEntity createDataKey="createCategory"/> + </createData> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createSimpleProductOne" stepKey="deleteProductOne"/> + <deleteData createDataKey="createSimpleProductTwo" stepKey="deleteProductTwo"/> + <deleteData createDataKey="createSimpleProductThree" stepKey="deleteProductThree"/> + <deleteData createDataKey="createSimpleProductFour" stepKey="deleteProductFour"/> + <deleteData createDataKey="createSimpleProductFive" stepKey="deleteProductFive"/> + <deleteData createDataKey="createSimpleProductSix" stepKey="deleteProductSix"/> + <deleteData createDataKey="createSimpleProductSeven" stepKey="deleteProductSeven"/> + <deleteData createDataKey="createSimpleProductEight" stepKey="deleteProductEight"/> + <deleteData createDataKey="createSimpleProductNine" stepKey="deleteProductNine"/> + <deleteData createDataKey="createSimpleProductTen" stepKey="deleteProductTen"/> + <deleteData createDataKey="createSimpleProductEleven" stepKey="deleteProductEleven"/> + <deleteData createDataKey="createSimpleProductTwelve" stepKey="deleteProductTwelve"/> + <deleteData createDataKey="createSimpleProductThirteen" stepKey="deleteProductThirteen"/> + <deleteData createDataKey="createSimpleProductFourteen" stepKey="deleteProductFourteen"/> + <deleteData createDataKey="createSimpleProductFifteen" stepKey="deleteProductFifteen"/> + <deleteData createDataKey="createSimpleProductSixteen" stepKey="deleteProductSixteen"/> + <deleteData createDataKey="createSimpleProductSeventeen" stepKey="deleteProductSeventeen"/> + <deleteData createDataKey="createSimpleProductEighteen" stepKey="deleteProductEighteen"/> + <deleteData createDataKey="createSimpleProductNineteen" stepKey="deleteProductNineteen"/> + <deleteData createDataKey="createSimpleProductTwenty" stepKey="deleteProductTwenty"/> + <deleteData createDataKey="createSimpleProductTwentyOne" stepKey="deleteProductTwentyOne"/> + <deleteData createDataKey="createSimpleProductTwentyTwo" stepKey="deleteProductTwentyTwo"/> + <deleteData createDataKey="createSimpleProductTwentyThree" stepKey="deleteProductTwentyThree"/> + <deleteData createDataKey="createSimpleProductTwentyFour" stepKey="deleteProductTwentyFour"/> + <deleteData createDataKey="createSimpleProductTwentyFive" stepKey="deleteProductTwentyFive"/> + <deleteData createDataKey="createSimpleProductTwentySix" stepKey="deleteProductTwentySix"/> + <deleteData createDataKey="createSimpleProductTwentySeven" stepKey="deleteProductTwentySeven"/> + <deleteData createDataKey="createSimpleProductTwentyEight" stepKey="deleteProductTwentyEight"/> + <deleteData createDataKey="createSimpleProductTwentyNine" stepKey="deleteProductTwentyNine"/> + <deleteData createDataKey="createSimpleProductThirty" stepKey="deleteProductThirty"/> + <deleteData createDataKey="createSimpleProductThirtyOne" stepKey="deleteProductThirtyOne"/> + <deleteData createDataKey="createSimpleProductThirtyTwo" stepKey="deleteProductThirtyTwo"/> + <deleteData createDataKey="createSimpleProductThirtyThree" stepKey="deleteProductThirtyThree"/> + <deleteData createDataKey="createSimpleProductThirtyFour" stepKey="deleteProductThirtyFour"/> + <deleteData createDataKey="createSimpleProductThirtyFive" stepKey="deleteProductThirtyFive"/> + <deleteData createDataKey="createSimpleProductThirtySix" stepKey="deleteProductThirtySix"/> + <deleteData createDataKey="createSimpleProductThirtySeven" stepKey="deleteProductThirtySeven"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!--Verify configuration for default number of products displayed in the grid view--> + <comment userInput="Verify configuration for default number of products displayed in the grid view" stepKey="commentVerifyDefaultValues"/> + <amOnPage url="{{CatalogConfigPage.url}}" stepKey="goToCatalogConfigPagePage"/> + <waitForPageLoad stepKey="waitForConfigPageLoad" /> + <conditionalClick selector="{{AdminCatalogStorefrontConfigSection.sectionHeader}}" dependentSelector="{{AdminCatalogStorefrontConfigSection.productsPerPageAllowedValues}}" visible="false" stepKey="openCatalogConfigStorefrontSection"/> + <waitForElementVisible selector="{{AdminCatalogStorefrontConfigSection.productsPerPageAllowedValues}}" stepKey="waitForSectionOpen"/> + <seeInField selector="{{AdminCatalogStorefrontConfigSection.productsPerPageAllowedValues}}" userInput="12,24,36" stepKey="seeDefaultValueAllowedNumberProductsPerPage"/> + <seeInField selector="{{AdminCatalogStorefrontConfigSection.productsPerPageDefaultValue}}" userInput="12" stepKey="seeDefaultValueProductPerPage"/> + <!-- Open storefront on the category page --> + <comment userInput="Open storefront on the category page" stepKey="commentOpenStorefront"/> + <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="goToStorefrontCreatedCategoryPage"/> + <!-- Check the drop-down at the bottom of page contains options --> + <comment userInput="Check the drop-down at the bottom of page contains options" stepKey="commentCheckOptions"/> + <scrollTo selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" stepKey="scrollToBottomToolbarSection"/> + <assertElementContainsAttribute selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" attribute="value" expectedValue="12" stepKey="assertPerPageFirstValue" /> + <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="24" stepKey="selectPerPageSecondValue" /> + <assertElementContainsAttribute selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" attribute="value" expectedValue="24" stepKey="assertPerPageSecondValue" /> + <selectOption selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" userInput="36" stepKey="selectPerPageThirdValue" /> + <assertElementContainsAttribute selector="{{StorefrontCategoryBottomToolbarSection.perPage}}" attribute="value" expectedValue="36" stepKey="assertPerPageThirdValue" /> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/VerifyCategoryProductAndProductCategoryPartialReindexTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/VerifyCategoryProductAndProductCategoryPartialReindexTest.xml new file mode 100644 index 0000000000000..6184a220f047c --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/VerifyCategoryProductAndProductCategoryPartialReindexTest.xml @@ -0,0 +1,226 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="VerifyCategoryProductAndProductCategoryPartialReindexTest"> + <annotations> + <features value="Catalog"/> + <stories value="Product Categories Indexer"/> + <title value="Verify Category Product and Product Category partial reindex"/> + <description value="Verify that Merchant Developer can use console commands to perform partial reindex for Category Products, Product Categories, and Catalog Search"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-11386"/> + <group value="catalog"/> + <group value="indexer"/> + </annotations> + <before> + <!-- Change "Category Products" and "Product Categories" indexers to "Update by Schedule" mode --> + <magentoCLI command="indexer:set-mode" arguments="schedule catalog_category_product catalog_product_category" stepKey="setIndexerMode"/> + + <!-- Create categories K, L, M, N with different nesting in the tree and Anchor = Yes/No--> + <!-- Category K is an anchor category --> + <createData entity="_defaultCategory" stepKey="categoryK"/> + <!-- Category L is a non-anchor subcategory of category K --> + <createData entity="SubCategoryNonAnchor" stepKey="categoryL"> + <requiredEntity createDataKey="categoryK"/> + </createData> + <!-- Category M is a subcategory of category L --> + <createData entity="SubCategoryWithParent" stepKey="categoryM"> + <requiredEntity createDataKey="categoryL"/> + </createData> + <!-- Category N is a subcategory of category K --> + <createData entity="SubCategoryWithParent" stepKey="categoryN"> + <requiredEntity createDataKey="categoryK"/> + </createData> + + <!-- Create different Products with different settings, assign to categories: --> + <!-- Product A in 0 categories, i.e. not assigned to any category --> + <createData entity="SimpleProduct2" stepKey="productA"/> + <!-- Product B in 1 category M --> + <createData entity="SimpleProduct3" stepKey="productB"> + <requiredEntity createDataKey="categoryM"/> + </createData> + <!-- Product C in 2 categories M and N --> + <createData entity="SimpleProduct2" stepKey="productC"/> + + <actionGroup ref="LoginAsAdmin" stepKey="LoginAsAdmin"/> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignCategoryNAndMToProductC"> + <argument name="productId" value="$$productC.id$$"/> + <argument name="categoryName" value="$$categoryN.name$$, $$categoryM.name$$"/> + </actionGroup> + + <magentoCLI command="indexer:reindex" stepKey="reindex"/> + </before> + <after> + <!-- Change "Category Products" and "Product Categories" indexers to "Update on Save" mode --> + <magentoCLI command="indexer:set-mode" arguments="realtime" stepKey="setRealtimeMode"/> + <magentoCLI command="indexer:reindex" stepKey="reindex"/> + + <!-- Delete data --> + <deleteData createDataKey="productA" stepKey="deleteProductA"/> + <deleteData createDataKey="productB" stepKey="deleteProductB"/> + <deleteData createDataKey="productC" stepKey="deleteProductC"/> + <deleteData createDataKey="categoryN" stepKey="deleteCategoryN"/> + <deleteData createDataKey="categoryM" stepKey="deleteCategoryM"/> + <deleteData createDataKey="categoryL" stepKey="deleteCategoryL"/> + <deleteData createDataKey="categoryK" stepKey="deleteCategoryK"/> + + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Open categories K, L, M, N on Storefront --> + <!-- Category K contains only Products B & C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$)}}" stepKey="onCategoryK"/> + <see userInput="$$productB.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductBOnCategoryK"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductCOnCategoryK"/> + + <!-- Category L contains no Products --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$)}}" stepKey="onCategoryL"/> + <see userInput="We can't find products matching the selection." selector="{{StorefrontCategoryMainSection.emptyProductMessage}}" stepKey="seeMessage"/> + <dontSeeElement selector="{{StorefrontCategoryMainSection.productName}}" stepKey="dontseeProducts"/> + + <!-- Category M contains only Products B & C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$/$$categoryM.custom_attributes[url_key]$$)}}" stepKey="onCategoryM"/> + <see userInput="$$productB.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductBOnCategoryM"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductCOnCategoryM"/> + + <!-- Category N contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryN.custom_attributes[url_key]$$)}}" stepKey="onCategoryN"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductCOnCategoryN"/> + + <!-- Open Products A, B, C to edit. Assign/unassign categories to/from them. Save changes --> + <!-- Assign category K to Product A --> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignCategoryK"> + <argument name="productId" value="$$productA.id$$"/> + <argument name="categoryName" value="$$categoryK.name$$"/> + </actionGroup> + + <!-- Unassign category M from Product B --> + <amOnPage url="{{AdminProductEditPage.url($$productB.id$$)}}" stepKey="amOnEditCategoryPageB"/> + <actionGroup ref="AdminUnassignCategoryOnProductAndSaveActionGroup" stepKey="unassignCategoryM"> + <argument name="categoryName" value="$$categoryM.name$$"/> + </actionGroup> + + <!-- Assign category L to Product C --> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignCategoryNAndM"> + <argument name="productId" value="$$productC.id$$"/> + <argument name="categoryName" value="$$categoryL.name$$"/> + </actionGroup> + + <!-- "One or more indexers are invalid. Make sure your Magento cron job is running." global warning message appears --> + <click selector="{{AdminSystemMessagesSection.systemMessagesDropdown}}" stepKey="openMessageSection"/> + <see userInput="One or more indexers are invalid. Make sure your Magento cron job is running." selector="{{AdminMessagesSection.warningMessage}}" stepKey="seeWarningMessage"/> + + <!-- Open categories K, L, M, N on Storefront in order to make sure that new assigments are not applied yet --> + <!-- Category K contains only Products B & C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$)}}" stepKey="amOnCategoryK"/> + <see userInput="$$productB.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductBCategoryK"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductCCategoryK"/> + + <!-- Category L contains no Products --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$)}}" stepKey="amOnCategoryL"/> + <see userInput="We can't find products matching the selection." selector="{{StorefrontCategoryMainSection.emptyProductMessage}}" stepKey="seeEmptyMessage"/> + <dontSeeElement selector="{{StorefrontCategoryMainSection.productName}}" stepKey="dontseeProduct"/> + + <!-- Category M contains only Products B & C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$/$$categoryM.custom_attributes[url_key]$$)}}" stepKey="amOnCategoryM"/> + <see userInput="$$productB.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductBCategoryM"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductCCategoryM"/> + + <!-- Category N contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryN.custom_attributes[url_key]$$)}}" stepKey="amOnCategoryN"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductInCategoryN"/> + + <!-- Run cron twice --> + <magentoCLI command="cron:run" stepKey="runCron"/> + <magentoCLI command="cron:run" stepKey="runCronAgain"/> + + <!-- Open categories K, L, M, N on Storefront in order to make sure that new assigments are applied --> + <!-- Category K contains only Products A, C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$)}}" stepKey="storefrontCategoryK"/> + <see userInput="$$productA.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductAOnCategoryK"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeCategoryKWithProductC"/> + + <!-- Category L contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$)}}" stepKey="storefrontCategoryL"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeCategoryLWithProductC"/> + + <!-- Category M contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$/$$categoryM.custom_attributes[url_key]$$)}}" stepKey="storefrontCategoryM"/> + <waitForPageLoad stepKey="waitForStorefrontCategoryM"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeCategoryMAndProductC"/> + + <!-- Category N contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryN.custom_attributes[url_key]$$)}}" stepKey="storefrontCategoryN"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductCAndCategoryN"/> + + <!-- Open categories K, L, N to edit. Assign/unassign Products to/from them. Save changes --> + + <!-- Remove Product A assignment for category K --> + <amOnPage url="{{AdminProductEditPage.url($$productA.id$$)}}" stepKey="amOnEditProductPageA"/> + <actionGroup ref="AdminUnassignCategoryOnProductAndSaveActionGroup" stepKey="unassignCategoryK"> + <argument name="categoryName" value="$$categoryK.name$$"/> + </actionGroup> + + <!-- Remove Product C assignment for category L --> + <amOnPage url="{{AdminProductEditPage.url($$productC.id$$)}}" stepKey="amOnEditProductPageC"/> + <actionGroup ref="AdminUnassignCategoryOnProductAndSaveActionGroup" stepKey="unassignCategoryL"> + <argument name="categoryName" value="$$categoryL.name$$"/> + </actionGroup> + + <!-- Add Product B assignment for category N --> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignCategoryN"> + <argument name="productId" value="$$productB.id$$"/> + <argument name="categoryName" value="$$categoryN.name$$"/> + </actionGroup> + + <!-- Open categories K, L, M, N on Storefront in order to make sure that new assigments are not applied yet --> + <!-- Category K contains only Products A, C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$)}}" stepKey="onStorefrontCategoryK"/> + <see userInput="$$productA.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductAWithCategoryK"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductC"/> + + <!-- Category L contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$)}}" stepKey="onStorefrontCategoryL"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeCategoryLAndProductC"/> + + <!-- Category M contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$/$$categoryM.custom_attributes[url_key]$$)}}" stepKey="onStorefrontCategoryM"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeCategoryMWithProductC"/> + + <!-- Category N contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryN.custom_attributes[url_key]$$)}}" stepKey="onStorefrontCategoryN"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="productCOnCategoryN"/> + + <!-- Run cron twice --> + <magentoCLI command="cron:run" stepKey="firstCronRun"/> + <magentoCLI command="cron:run" stepKey="secondCronRun"/> + + <!-- Open categories K, L, M, N on Storefront in order to make sure that new assigments are applied --> + + <!-- Category K contains only Products B & C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$)}}" stepKey="onFrontendCategoryK"/> + <see userInput="$$productB.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="productBOnCategoryK"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="productCOnCategoryK"/> + + <!-- Category L contains no Products --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$)}}" stepKey="onFrontendCategoryL"/> + <see userInput="We can't find products matching the selection." selector="{{StorefrontCategoryMainSection.emptyProductMessage}}" stepKey="noProductsMessage"/> + <dontSeeElement selector="{{StorefrontCategoryMainSection.productName}}" stepKey="dontSeeProductsOnCategoryL"/> + + <!-- Category M contains only Product C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryL.custom_attributes[url_key]$$/$$categoryM.custom_attributes[url_key]$$)}}" stepKey="onFrontendCategoryM"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeCategoryMPageAndProductC"/> + + <!-- Category N contains only Products B and C --> + <amOnPage url="{{StorefrontCategoryPage.url($$categoryK.custom_attributes[url_key]$$/$$categoryN.custom_attributes[url_key]$$)}}" stepKey="onFrontendCategoryN"/> + <see userInput="$$productB.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductBAndCategoryN"/> + <see userInput="$$productC.name$$" selector="{{StorefrontCategoryMainSection.productName}}" stepKey="seeProductCCategoryN"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Widget/LinkTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Widget/LinkTest.php index 8333ed22e1da0..dcbd3161733aa 100644 --- a/app/code/Magento/Catalog/Test/Unit/Block/Widget/LinkTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Block/Widget/LinkTest.php @@ -5,54 +5,81 @@ */ namespace Magento\Catalog\Test\Unit\Block\Widget; +use Exception; +use Magento\Catalog\Block\Widget\Link; +use Magento\Catalog\Model\ResourceModel\AbstractResource; use Magento\CatalogUrlRewrite\Model\ProductUrlRewriteGenerator; +use Magento\Framework\App\Config\ReinitableConfigInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Framework\Url; +use Magento\Framework\Url\ModifierInterface; +use Magento\Framework\View\Element\Template\Context; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use Magento\UrlRewrite\Model\UrlFinderInterface; use Magento\UrlRewrite\Service\V1\Data\UrlRewrite; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject; +use ReflectionClass; +use RuntimeException; -class LinkTest extends \PHPUnit\Framework\TestCase +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.UnusedLocalVariable) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class LinkTest extends TestCase { /** - * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Store\Model\StoreManagerInterface + * @var PHPUnit_Framework_MockObject_MockObject|StoreManagerInterface */ protected $storeManager; /** - * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\UrlRewrite\Model\UrlFinderInterface + * @var PHPUnit_Framework_MockObject_MockObject|UrlFinderInterface */ protected $urlFinder; /** - * @var \Magento\Catalog\Block\Widget\Link + * @var Link */ protected $block; /** - * @var \Magento\Catalog\Model\ResourceModel\AbstractResource|\PHPUnit_Framework_MockObject_MockObject + * @var AbstractResource|PHPUnit_Framework_MockObject_MockObject */ protected $entityResource; + /** + * @inheritDoc + */ protected function setUp() { - $this->storeManager = $this->createMock(\Magento\Store\Model\StoreManagerInterface::class); - $this->urlFinder = $this->createMock(\Magento\UrlRewrite\Model\UrlFinderInterface::class); + $this->storeManager = $this->createMock(StoreManagerInterface::class); + $this->urlFinder = $this->createMock(UrlFinderInterface::class); - $context = $this->createMock(\Magento\Framework\View\Element\Template\Context::class); + $context = $this->createMock(Context::class); $context->expects($this->any()) ->method('getStoreManager') ->will($this->returnValue($this->storeManager)); $this->entityResource = - $this->createMock(\Magento\Catalog\Model\ResourceModel\AbstractResource::class); - - $this->block = (new ObjectManager($this))->getObject(\Magento\Catalog\Block\Widget\Link::class, [ - 'context' => $context, - 'urlFinder' => $this->urlFinder, - 'entityResource' => $this->entityResource - ]); + $this->createMock(AbstractResource::class); + + $this->block = (new ObjectManager($this))->getObject( + Link::class, + [ + 'context' => $context, + 'urlFinder' => $this->urlFinder, + 'entityResource' => $this->entityResource + ] + ); } /** - * @expectedException \RuntimeException + * Tests getHref with wrong id_path + * + * @expectedException RuntimeException * @expectedExceptionMessage Parameter id_path is not set. */ public function testGetHrefWithoutSetIdPath() @@ -61,7 +88,9 @@ public function testGetHrefWithoutSetIdPath() } /** - * @expectedException \RuntimeException + * Tests getHref with wrong id_path + * + * @expectedException RuntimeException * @expectedExceptionMessage Wrong id_path structure. */ public function testGetHrefIfSetWrongIdPath() @@ -70,27 +99,30 @@ public function testGetHrefIfSetWrongIdPath() $this->block->getHref(); } + /** + * Tests getHref with wrong store ID + * + * @expectedException Exception + */ public function testGetHrefWithSetStoreId() { $this->block->setData('id_path', 'type/id'); $this->block->setData('store_id', 'store_id'); - $this->storeManager->expects($this->once()) - ->method('getStore')->with('store_id') - // interrupt test execution - ->will($this->throwException(new \Exception())); - - try { - $this->block->getHref(); - } catch (\Exception $e) { - } + ->method('getStore') + ->with('store_id') + ->will($this->throwException(new Exception())); + $this->block->getHref(); } + /** + * Tests getHref with not found URL + */ public function testGetHrefIfRewriteIsNotFound() { $this->block->setData('id_path', 'entity_type/entity_id'); - $store = $this->createPartialMock(\Magento\Store\Model\Store::class, ['getId', '__wakeUp']); + $store = $this->createPartialMock(Store::class, ['getId', '__wakeUp']); $store->expects($this->any()) ->method('getId'); @@ -105,52 +137,107 @@ public function testGetHrefIfRewriteIsNotFound() } /** - * @param string $url - * @param string $separator + * Tests getHref whether it should include the store code or not + * * @dataProvider dataProviderForTestGetHrefWithoutUrlStoreSuffix + * @param string $path + * @param int|null $storeId + * @param bool $includeStoreCode + * @param string $expected + * @throws \ReflectionException */ - public function testGetHrefWithoutUrlStoreSuffix($url, $separator) - { - $storeId = 15; - $storeCode = 'store-code'; - $requestPath = 'request-path'; + public function testStoreCodeShouldBeIncludedInURLOnlyIfItIsConfiguredSo( + string $path, + ?int $storeId, + bool $includeStoreCode, + string $expected + ) { $this->block->setData('id_path', 'entity_type/entity_id'); - - $rewrite = $this->createMock(\Magento\UrlRewrite\Service\V1\Data\UrlRewrite::class); - $rewrite->expects($this->once()) - ->method('getRequestPath') - ->will($this->returnValue($requestPath)); - - $store = $this->createPartialMock( - \Magento\Store\Model\Store::class, - ['getId', 'getUrl', 'getCode', '__wakeUp'] + $this->block->setData('store_id', $storeId); + $objectManager = new ObjectManager($this); + + $rewrite = $this->createPartialMock(UrlRewrite::class, ['getRequestPath']); + $url = $this->createPartialMock(Url::class, ['setScope', 'getUrl']); + $urlModifier = $this->getMockForAbstractClass(ModifierInterface::class); + $config = $this->getMockForAbstractClass(ReinitableConfigInterface::class); + $store = $objectManager->getObject( + Store::class, + [ + 'storeManager' => $this->storeManager, + 'url' => $url, + 'config' => $config + ] ); - $store->expects($this->once()) - ->method('getId') - ->will($this->returnValue($storeId)); - $store->expects($this->once()) + $property = (new ReflectionClass(get_class($store)))->getProperty('urlModifier'); + $property->setAccessible(true); + $property->setValue($store, $urlModifier); + + $urlModifier->expects($this->any()) + ->method('execute') + ->willReturnArgument(0); + $config->expects($this->any()) + ->method('getValue') + ->willReturnMap( + [ + [Store::XML_PATH_USE_REWRITES, ReinitableConfigInterface::SCOPE_TYPE_DEFAULT, null, true], + [ + Store::XML_PATH_STORE_IN_URL, + ReinitableConfigInterface::SCOPE_TYPE_DEFAULT, + null, $includeStoreCode + ] + ] + ); + + $url->expects($this->any()) + ->method('setScope') + ->willReturnSelf(); + + $url->expects($this->any()) ->method('getUrl') - ->with('', ['_direct' => $requestPath]) - ->will($this->returnValue($url)); - $store->expects($this->once()) - ->method('getCode') - ->will($this->returnValue($storeCode)); + ->willReturnCallback( + function ($route, $params) use ($storeId) { + $baseUrl = rtrim($this->storeManager->getStore($storeId)->getBaseUrl(), '/'); + return $baseUrl .'/' . ltrim($params['_direct'], '/'); + } + ); - $this->storeManager->expects($this->once()) - ->method('getStore') - ->will($this->returnValue($store)); + $store->addData(['store_id' => 1, 'code' => 'french']); - $this->urlFinder->expects($this->once())->method('findOneByData') - ->with([ + $store2 = clone $store; + $store2->addData(['store_id' => 2, 'code' => 'german']); + + $this->storeManager + ->expects($this->any()) + ->method('getStore') + ->willReturnMap( + [ + [null, $store], + [1, $store], + [2, $store2], + ] + ); + + $this->urlFinder->expects($this->once()) + ->method('findOneByData') + ->with( + [ UrlRewrite::ENTITY_ID => 'entity_id', UrlRewrite::ENTITY_TYPE => 'entity_type', - UrlRewrite::STORE_ID => $storeId, - ]) + UrlRewrite::STORE_ID => $this->storeManager->getStore($storeId)->getStoreId(), + ] + ) ->will($this->returnValue($rewrite)); - $this->assertEquals($url . $separator . '___store=' . $storeCode, $this->block->getHref()); + $rewrite->expects($this->once()) + ->method('getRequestPath') + ->will($this->returnValue($path)); + + $this->assertEquals($expected, $this->block->getHref()); } + /** + * Tests getLabel with custom text + */ public function testGetLabelWithCustomText() { $customText = 'Some text'; @@ -158,6 +245,9 @@ public function testGetLabelWithCustomText() $this->assertEquals($customText, $this->block->getLabel()); } + /** + * Tests getLabel without custom text + */ public function testGetLabelWithoutCustomText() { $category = 'Some text'; @@ -178,17 +268,25 @@ public function testGetLabelWithoutCustomText() public function dataProviderForTestGetHrefWithoutUrlStoreSuffix() { return [ - ['url', '?'], - ['url?some_parameter', '&'], + ['/accessories.html', null, true, 'french/accessories.html'], + ['/accessories.html', null, false, '/accessories.html'], + ['/accessories.html', 1, true, 'french/accessories.html'], + ['/accessories.html', 1, false, '/accessories.html'], + ['/accessories.html', 2, true, 'german/accessories.html'], + ['/accessories.html', 2, false, '/accessories.html?___store=german'], + ['/accessories.html?___store=german', 2, false, '/accessories.html?___store=german'], ]; } + /** + * Tests getHref with product entity and additional category id in the id_path + */ public function testGetHrefWithForProductWithCategoryIdParameter() { $storeId = 15; $this->block->setData('id_path', ProductUrlRewriteGenerator::ENTITY_TYPE . '/entity_id/category_id'); - $store = $this->createPartialMock(\Magento\Store\Model\Store::class, ['getId', '__wakeUp']); + $store = $this->createPartialMock(Store::class, ['getId', '__wakeUp']); $store->expects($this->any()) ->method('getId') ->will($this->returnValue($storeId)); @@ -197,13 +295,16 @@ public function testGetHrefWithForProductWithCategoryIdParameter() ->method('getStore') ->will($this->returnValue($store)); - $this->urlFinder->expects($this->once())->method('findOneByData') - ->with([ - UrlRewrite::ENTITY_ID => 'entity_id', - UrlRewrite::ENTITY_TYPE => ProductUrlRewriteGenerator::ENTITY_TYPE, - UrlRewrite::STORE_ID => $storeId, - UrlRewrite::METADATA => ['category_id' => 'category_id'], - ]) + $this->urlFinder->expects($this->once()) + ->method('findOneByData') + ->with( + [ + UrlRewrite::ENTITY_ID => 'entity_id', + UrlRewrite::ENTITY_TYPE => ProductUrlRewriteGenerator::ENTITY_TYPE, + UrlRewrite::STORE_ID => $storeId, + UrlRewrite::METADATA => ['category_id' => 'category_id'], + ] + ) ->will($this->returnValue(false)); $this->block->getHref(); diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/StockDataFilterTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/StockDataFilterTest.php index 0214de8120bae..cb23d0c0a9a24 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/StockDataFilterTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/StockDataFilterTest.php @@ -6,11 +6,15 @@ namespace Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\Initialization; use Magento\Catalog\Controller\Adminhtml\Product\Initialization\StockDataFilter; +use Magento\CatalogInventory\Model\Stock; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\CatalogInventory\Model\Configuration; +use PHPUnit\Framework\TestCase; /** - * Class StockDataFilterTest + * StockDataFilter test. */ -class StockDataFilterTest extends \PHPUnit\Framework\TestCase +class StockDataFilterTest extends TestCase { /** * @var \PHPUnit_Framework_MockObject_MockObject @@ -27,17 +31,23 @@ class StockDataFilterTest extends \PHPUnit\Framework\TestCase */ protected $stockDataFilter; - /** @var \PHPUnit_Framework_MockObject_MockObject */ + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ protected $stockConfiguration; + /** + * @inheritdoc + */ protected function setUp() { - $this->scopeConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class); - $this->scopeConfigMock->expects($this->any())->method('getValue')->will($this->returnValue(1)); + $this->scopeConfigMock->method('getValue') + ->will($this->returnValue(1)); $this->stockConfiguration = $this->createPartialMock( - \Magento\CatalogInventory\Model\Configuration::class, + Configuration::class, ['getManageStock'] ); @@ -45,8 +55,11 @@ protected function setUp() } /** + * Tests filter method. + * * @param array $inputStockData * @param array $outputStockData + * @return void * * @covers \Magento\Catalog\Controller\Adminhtml\Product\Initialization\StockDataFilter::filter * @dataProvider filterDataProvider @@ -54,8 +67,7 @@ protected function setUp() public function testFilter(array $inputStockData, array $outputStockData) { if (isset($inputStockData['use_config_manage_stock']) && $inputStockData['use_config_manage_stock'] === 1) { - $this->stockConfiguration->expects($this->once()) - ->method('getManageStock') + $this->stockConfiguration->method('getManageStock') ->will($this->returnValue($outputStockData['manage_stock'])); } @@ -93,8 +105,13 @@ public function filterDataProvider() ], ], 'case4' => [ - 'inputStockData' => ['min_qty' => -1], - 'outputStockData' => ['min_qty' => 0, 'is_decimal_divided' => 0, 'use_config_manage_stock' => 0], + 'inputStockData' => ['min_qty' => -1, 'backorders' => Stock::BACKORDERS_NO], + 'outputStockData' => [ + 'min_qty' => 0, + 'is_decimal_divided' => 0, + 'use_config_manage_stock' => 0, + 'backorders' => Stock::BACKORDERS_NO, + ], ], 'case5' => [ 'inputStockData' => ['is_qty_decimal' => 0], @@ -103,7 +120,25 @@ public function filterDataProvider() 'is_decimal_divided' => 0, 'use_config_manage_stock' => 0, ], - ] + ], + 'case6' => [ + 'inputStockData' => ['min_qty' => -1, 'backorders' => Stock::BACKORDERS_YES_NONOTIFY], + 'outputStockData' => [ + 'min_qty' => -1, + 'is_decimal_divided' => 0, + 'use_config_manage_stock' => 0, + 'backorders' => Stock::BACKORDERS_YES_NONOTIFY, + ], + ], + 'case7' => [ + 'inputStockData' => ['min_qty' => -1, 'backorders' => Stock::BACKORDERS_YES_NOTIFY], + 'outputStockData' => [ + 'min_qty' => -1, + 'is_decimal_divided' => 0, + 'use_config_manage_stock' => 0, + 'backorders' => Stock::BACKORDERS_YES_NOTIFY, + ], + ], ]; } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Layer/FilterListTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Layer/FilterListTest.php index 8733f305ce091..731c5efd99746 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Layer/FilterListTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Layer/FilterListTest.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Catalog\Test\Unit\Model\Layer; @@ -72,9 +73,13 @@ public function testGetFilters($method, $value, $expectedClass) $this->objectManagerMock->expects($this->at(1)) ->method('create') - ->with($expectedClass, [ - 'data' => ['attribute_model' => $this->attributeMock], - 'layer' => $this->layerMock]) + ->with( + $expectedClass, + [ + 'data' => ['attribute_model' => $this->attributeMock], + 'layer' => $this->layerMock + ] + ) ->will($this->returnValue('filter')); $this->attributeMock->expects($this->once()) @@ -95,8 +100,8 @@ public function getFiltersDataProvider() { return [ [ - 'method' => 'getAttributeCode', - 'value' => FilterList::PRICE_FILTER, + 'method' => 'getFrontendInput', + 'value' => 'price', 'expectedClass' => 'PriceFilterClass', ], [ @@ -105,8 +110,8 @@ public function getFiltersDataProvider() 'expectedClass' => 'DecimalFilterClass', ], [ - 'method' => 'getAttributeCode', - 'value' => null, + 'method' => 'getFrontendInput', + 'value' => 'text', 'expectedClass' => 'AttributeFilterClass', ] ]; diff --git a/app/code/Magento/Catalog/composer.json b/app/code/Magento/Catalog/composer.json index fa8daaabe5710..8023634fa074d 100644 --- a/app/code/Magento/Catalog/composer.json +++ b/app/code/Magento/Catalog/composer.json @@ -31,8 +31,7 @@ "magento/module-ui": "*", "magento/module-url-rewrite": "*", "magento/module-widget": "*", - "magento/module-wishlist": "*", - "magento/module-authorization": "*" + "magento/module-wishlist": "*" }, "suggest": { "magento/module-cookie": "*", diff --git a/app/code/Magento/Catalog/etc/config.xml b/app/code/Magento/Catalog/etc/config.xml index 3a842166a3825..20511f4ff2295 100644 --- a/app/code/Magento/Catalog/etc/config.xml +++ b/app/code/Magento/Catalog/etc/config.xml @@ -23,9 +23,9 @@ </fields_masks> <frontend> <list_mode>grid-list</list_mode> - <grid_per_page_values>9,15,30</grid_per_page_values> + <grid_per_page_values>12,24,36</grid_per_page_values> <list_per_page_values>5,10,15,20,25</list_per_page_values> - <grid_per_page>9</grid_per_page> + <grid_per_page>12</grid_per_page> <list_per_page>10</list_per_page> <flat_catalog_category>0</flat_catalog_category> <default_sort_by>position</default_sort_by> diff --git a/app/code/Magento/Catalog/etc/db_schema.xml b/app/code/Magento/Catalog/etc/db_schema.xml index 6fef4ca6e9128..3d17db7a66666 100644 --- a/app/code/Magento/Catalog/etc/db_schema.xml +++ b/app/code/Magento/Catalog/etc/db_schema.xml @@ -812,7 +812,7 @@ <column xsi:type="smallint" name="disabled" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Is Disabled"/> <column xsi:type="int" name="record_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Record Id"/> + comment="Record ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="record_id"/> </constraint> @@ -1085,7 +1085,7 @@ <column xsi:type="int" name="value" padding="10" unsigned="true" nullable="false" identity="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="CAT_PRD_IDX_EAV_ENTT_ID_ATTR_ID_STORE_ID_VAL_SOURCE_ID"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1114,7 +1114,7 @@ <column xsi:type="decimal" name="value" scale="4" precision="12" unsigned="false" nullable="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="CAT_PRD_IDX_EAV_DEC_ENTT_ID_ATTR_ID_STORE_ID_VAL_SOURCE_ID"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1205,7 +1205,7 @@ <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" comment="Website ID"/> <column xsi:type="smallint" name="default_store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Default store id for website"/> + comment="Default store ID for website"/> <column xsi:type="date" name="website_date" comment="Website Date"/> <column xsi:type="float" name="rate" unsigned="false" nullable="true" default="1" comment="Rate"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -1452,7 +1452,7 @@ <column xsi:type="int" name="value" padding="10" unsigned="true" nullable="false" identity="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="CAT_PRD_IDX_EAV_IDX_ENTT_ID_ATTR_ID_STORE_ID_VAL_SOURCE_ID"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1481,7 +1481,7 @@ <column xsi:type="int" name="value" padding="10" unsigned="true" nullable="false" identity="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="CAT_PRD_IDX_EAV_TMP_ENTT_ID_ATTR_ID_STORE_ID_VAL_SOURCE_ID"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1510,7 +1510,7 @@ <column xsi:type="decimal" name="value" scale="4" precision="12" unsigned="false" nullable="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="CAT_PRD_IDX_EAV_DEC_IDX_ENTT_ID_ATTR_ID_STORE_ID_VAL_SOURCE_ID"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1539,7 +1539,7 @@ <column xsi:type="decimal" name="value" scale="4" precision="12" unsigned="false" nullable="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="CAT_PRD_IDX_EAV_DEC_TMP_ENTT_ID_ATTR_ID_STORE_ID_VAL_SOURCE_ID"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1681,7 +1681,7 @@ <column xsi:type="int" name="value" padding="10" unsigned="true" nullable="false" identity="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1710,7 +1710,7 @@ <column xsi:type="decimal" name="value" scale="4" precision="12" unsigned="false" nullable="false" comment="Value"/> <column xsi:type="int" name="source_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Original entity Id for attribute value"/> + default="0" comment="Original entity ID for attribute value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="entity_id"/> <column name="attribute_id"/> @@ -1801,14 +1801,14 @@ <table name="catalog_product_frontend_action" resource="default" engine="innodb" comment="Catalog Product Frontend Action Table"> <column xsi:type="bigint" name="action_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Product Action Id"/> + comment="Product Action ID"/> <column xsi:type="varchar" name="type_id" nullable="false" length="64" comment="Type of product action"/> <column xsi:type="int" name="visitor_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Visitor Id"/> + comment="Visitor ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="bigint" name="added_at" padding="20" unsigned="false" nullable="false" identity="false" comment="Added At"/> <constraint xsi:type="primary" referenceId="PRIMARY"> diff --git a/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/TierPrices.php b/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/TierPrices.php new file mode 100644 index 0000000000000..ed657ca9a9980 --- /dev/null +++ b/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/TierPrices.php @@ -0,0 +1,141 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogCustomerGraphQl\Model\Resolver; + +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Model\GroupManagement; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException; +use Magento\Framework\Exception\NoSuchEntityException; + +/** + * @inheritdoc + */ +class TierPrices implements ResolverInterface +{ + /** + * @var Collection + */ + private $collection; + + /** + * @var CustomerRepositoryInterface + */ + private $customerRepository; + + /** + * @var ValueFactory + */ + private $valueFactory; + + /** + * @var int + */ + private $customerGroupId = null; + + /** + * @var array + */ + private $productIds = []; + + /** + * @param CollectionFactory $collectionFactory + * @param ValueFactory $valueFactory + * @param CustomerRepositoryInterface $customerRepository + */ + public function __construct( + CollectionFactory $collectionFactory, + ValueFactory $valueFactory, + CustomerRepositoryInterface $customerRepository + ) { + $this->collection = $collectionFactory->create(); + $this->valueFactory = $valueFactory; + $this->customerRepository = $customerRepository; + } + + /** + * @inheritdoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!isset($value['model'])) { + throw new LocalizedException(__('"model" value should be specified')); + } + + if (null === $this->customerGroupId) { + $this->customerGroupId = $this->getCustomerGroupId($context); + } + + /** @var Product $product */ + $product = $value['model']; + $productId = $product->getId(); + $this->productIds[] = $productId; + $that = $this; + + return $this->valueFactory->create( + function () use ($that, $productId, $context) { + $tierPrices = []; + if (empty($that->productIds)) { + return []; + } + if (!$that->collection->isLoaded()) { + $that->collection->addIdFilter($that->productIds); + $that->collection->addTierPriceDataByGroupId($that->customerGroupId); + } + /** @var \Magento\Catalog\Model\Product $item */ + foreach ($that->collection as $item) { + if ($item->getId() === $productId) { + // Try to extract all requested fields from the loaded collection data + foreach ($item->getTierPrices() as $tierPrice) { + $tierPrices[] = $tierPrice->getData(); + } + } + } + return $tierPrices; + } + ); + } + + /** + * Get the customer group Id. + * + * @param \Magento\GraphQl\Model\Query\ContextInterface $context + * + * @return int + */ + private function getCustomerGroupId(\Magento\GraphQl\Model\Query\ContextInterface $context) + { + $currentUserId = $context->getUserId(); + if (!$currentUserId) { + $customerGroupId = GroupManagement::NOT_LOGGED_IN_ID; + } else { + try { + $customer = $this->customerRepository->getById($currentUserId); + } catch (NoSuchEntityException $e) { + throw new GraphQlNoSuchEntityException( + __('Customer with id "%customer_id" does not exist.', ['customer_id' => $currentUserId]), + $e + ); + } + $customerGroupId = $customer->getGroupId(); + } + return $customerGroupId; + } +} diff --git a/app/code/Magento/CatalogCustomerGraphQl/README.md b/app/code/Magento/CatalogCustomerGraphQl/README.md new file mode 100644 index 0000000000000..525a1a4f76433 --- /dev/null +++ b/app/code/Magento/CatalogCustomerGraphQl/README.md @@ -0,0 +1,3 @@ +# CatalogCustomerGraphQl + +**CatalogCustomerGraphQl** provides type and resolver information for GraphQL attributes that have dependences on the Catalog and Customer modules. \ No newline at end of file diff --git a/app/code/Magento/CatalogCustomerGraphQl/composer.json b/app/code/Magento/CatalogCustomerGraphQl/composer.json new file mode 100644 index 0000000000000..30bb86a7523ed --- /dev/null +++ b/app/code/Magento/CatalogCustomerGraphQl/composer.json @@ -0,0 +1,24 @@ +{ + "name": "magento/module-catalog-customer-graph-ql", + "description": "N/A", + "type": "magento2-module", + "require": { + "php": "~7.1.3||~7.2.0||~7.3.0", + "magento/module-catalog": "*", + "magento/module-customer": "*", + "magento/framework": "*", + "magento/module-graph-ql": "*" + }, + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\CatalogCustomerGraphQl\\": "" + } + } +} diff --git a/app/code/Magento/CatalogCustomerGraphQl/etc/module.xml b/app/code/Magento/CatalogCustomerGraphQl/etc/module.xml new file mode 100644 index 0000000000000..a1b2e29579721 --- /dev/null +++ b/app/code/Magento/CatalogCustomerGraphQl/etc/module.xml @@ -0,0 +1,16 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_CatalogCustomerGraphQl" > + <sequence> + <module name="Magento_Catalog"/> + <module name="Magento_Customer"/> + <module name="Magento_GraphQl"/> + </sequence> + </module> +</config> diff --git a/app/code/Magento/CatalogCustomerGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogCustomerGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..8cf6c6f874cfb --- /dev/null +++ b/app/code/Magento/CatalogCustomerGraphQl/etc/schema.graphqls @@ -0,0 +1,14 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +interface ProductInterface { + tier_prices: [ProductTierPrices] @doc(description: "An array of ProductTierPrices objects.") @resolver(class: "Magento\\CatalogCustomerGraphQl\\Model\\Resolver\\TierPrices") +} + +type ProductTierPrices @doc(description: "The ProductTierPrices object defines a tier price, which is a quantity discount offered to a specific customer group.") { + customer_group_id: String @doc(description: "The ID of the customer group.") + qty: Float @doc(description: "The number of items that must be purchased to qualify for tier pricing.") + value: Float @doc(description: "The price of the fixed price item.") + percentage_value: Float @doc(description: "The percentage discount of the item.") + website_id: Float @doc(description: "The ID assigned to the website.") +} diff --git a/app/code/Magento/CatalogCustomerGraphQl/registration.php b/app/code/Magento/CatalogCustomerGraphQl/registration.php new file mode 100644 index 0000000000000..8176716d42ea0 --- /dev/null +++ b/app/code/Magento/CatalogCustomerGraphQl/registration.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Framework\Component\ComponentRegistrar; + +ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_CatalogCustomerGraphQl', __DIR__); diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/AttributeQuery.php b/app/code/Magento/CatalogGraphQl/DataProvider/AttributeQuery.php new file mode 100644 index 0000000000000..b0f085932bb8e --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/AttributeQuery.php @@ -0,0 +1,354 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider; + +use Magento\Eav\Model\Config; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Select; +use Magento\Framework\EntityManager\MetadataPool; + +/** + * Generic for build Select object to fetch eav attributes for provided entity type + */ +class AttributeQuery +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @var MetadataPool + */ + private $metadataPool; + + /** + * @var string + */ + private $entityType; + + /** + * List of attributes that need to be added/removed to fetch + * + * @var array + */ + private $linkedAttributes; + + /** + * @var array + */ + private const SUPPORTED_BACKEND_TYPES = [ + 'int', + 'decimal', + 'text', + 'varchar', + 'datetime', + ]; + + /** + * @var int[] + */ + private $entityTypeIdMap; + + /** + * @var Config + */ + private $eavConfig; + + /** + * @param string $entityType + * @param ResourceConnection $resourceConnection + * @param MetadataPool $metadataPool + * @param Config $eavConfig + * @param array $linkedAttributes + */ + public function __construct( + string $entityType, + ResourceConnection $resourceConnection, + MetadataPool $metadataPool, + Config $eavConfig, + array $linkedAttributes = [] + ) { + $this->resourceConnection = $resourceConnection; + $this->metadataPool = $metadataPool; + $this->entityType = $entityType; + $this->linkedAttributes = $linkedAttributes; + $this->eavConfig = $eavConfig; + } + + /** + * Form and return query to get eav entity $attributes for given $entityIds. + * + * If eav entities were not found, then data is fetching from $entityTableName. + * + * @param array $entityIds + * @param array $attributes + * @param int $storeId + * @return Select + * @throws \Zend_Db_Select_Exception + * @throws \Exception + */ + public function getQuery(array $entityIds, array $attributes, int $storeId): Select + { + /** @var \Magento\Framework\EntityManager\EntityMetadataInterface $metadata */ + $metadata = $this->metadataPool->getMetadata($this->entityType); + $entityTableName = $metadata->getEntityTable(); + + /** @var \Magento\Framework\DB\Adapter\AdapterInterface $connection */ + $connection = $this->resourceConnection->getConnection(); + $entityTableAttributes = \array_keys($connection->describeTable($entityTableName)); + + $attributeMetadataTable = $this->resourceConnection->getTableName('eav_attribute'); + $eavAttributes = $this->getEavAttributeCodes($attributes, $entityTableAttributes); + $entityTableAttributes = \array_intersect($attributes, $entityTableAttributes); + + $eavAttributesMetaData = $this->getAttributesMetaData($connection, $attributeMetadataTable, $eavAttributes); + + if ($eavAttributesMetaData) { + $select = $this->getEavAttributes( + $connection, + $metadata, + $entityTableAttributes, + $entityIds, + $eavAttributesMetaData, + $entityTableName, + $storeId + ); + } else { + $select = $this->getAttributesFromEntityTable( + $connection, + $entityTableAttributes, + $entityIds, + $entityTableName + ); + } + + return $select; + } + + /** + * Form and return query to get entity $entityTableAttributes for given $entityIds + * + * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection + * @param array $entityTableAttributes + * @param array $entityIds + * @param string $entityTableName + * @return Select + */ + private function getAttributesFromEntityTable( + \Magento\Framework\DB\Adapter\AdapterInterface $connection, + array $entityTableAttributes, + array $entityIds, + string $entityTableName + ): Select { + $select = $connection->select() + ->from(['e' => $entityTableName], $entityTableAttributes) + ->where('e.entity_id IN (?)', $entityIds); + + return $select; + } + + /** + * Return ids of eav attributes by $eavAttributeCodes. + * + * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection + * @param string $attributeMetadataTable + * @param array $eavAttributeCodes + * @return array + */ + private function getAttributesMetaData( + \Magento\Framework\DB\Adapter\AdapterInterface $connection, + string $attributeMetadataTable, + array $eavAttributeCodes + ): array { + $eavAttributeIdsSelect = $connection->select() + ->from(['a' => $attributeMetadataTable], ['attribute_id', 'backend_type', 'attribute_code']) + ->where('a.attribute_code IN (?)', $eavAttributeCodes) + ->where('a.entity_type_id = ?', $this->getEntityTypeId()); + + return $connection->fetchAssoc($eavAttributeIdsSelect); + } + + /** + * Form and return query to get eav entity $attributes for given $entityIds. + * + * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection + * @param \Magento\Framework\EntityManager\EntityMetadataInterface $metadata + * @param array $entityTableAttributes + * @param array $entityIds + * @param array $eavAttributesMetaData + * @param string $entityTableName + * @param int $storeId + * @return Select + * @throws \Zend_Db_Select_Exception + */ + private function getEavAttributes( + \Magento\Framework\DB\Adapter\AdapterInterface $connection, + \Magento\Framework\EntityManager\EntityMetadataInterface $metadata, + array $entityTableAttributes, + array $entityIds, + array $eavAttributesMetaData, + string $entityTableName, + int $storeId + ): Select { + $selects = []; + $attributeValueExpression = $connection->getCheckSql( + $connection->getIfNullSql('store_eav.value_id', -1) . ' > 0', + 'store_eav.value', + 'eav.value' + ); + $linkField = $metadata->getLinkField(); + $attributesPerTable = $this->getAttributeCodeTables($entityTableName, $eavAttributesMetaData); + foreach ($attributesPerTable as $attributeTable => $eavAttributes) { + $attributeCodeExpression = $this->buildAttributeCodeExpression($eavAttributes); + + $selects[] = $connection->select() + ->from(['e' => $entityTableName], $entityTableAttributes) + ->joinLeft( + ['eav' => $this->resourceConnection->getTableName($attributeTable)], + \sprintf('e.%1$s = eav.%1$s', $linkField) . + $connection->quoteInto(' AND eav.attribute_id IN (?)', \array_keys($eavAttributesMetaData)) . + $connection->quoteInto(' AND eav.store_id = ?', \Magento\Store\Model\Store::DEFAULT_STORE_ID), + [] + ) + ->joinLeft( + ['store_eav' => $this->resourceConnection->getTableName($attributeTable)], + \sprintf( + 'e.%1$s = store_eav.%1$s AND store_eav.attribute_id = ' . + 'eav.attribute_id and store_eav.store_id = %2$d', + $linkField, + $storeId + ), + [] + ) + ->where('e.entity_id IN (?)', $entityIds) + ->columns( + [ + 'attribute_code' => $attributeCodeExpression, + 'value' => $attributeValueExpression + ] + ); + } + + return $connection->select()->union($selects, Select::SQL_UNION_ALL); + } + + /** + * Build expression for attribute code field. + * + * An example: + * + * ``` + * CASE + * WHEN eav.attribute_id = '73' THEN 'name' + * WHEN eav.attribute_id = '121' THEN 'url_key' + * END + * ``` + * + * @param array $eavAttributes + * @return \Zend_Db_Expr + */ + private function buildAttributeCodeExpression(array $eavAttributes): \Zend_Db_Expr + { + $dbConnection = $this->resourceConnection->getConnection(); + $expressionParts = ['CASE']; + + foreach ($eavAttributes as $attribute) { + $expressionParts[]= + $dbConnection->quoteInto('WHEN eav.attribute_id = ?', $attribute['attribute_id'], \Zend_Db::INT_TYPE) . + $dbConnection->quoteInto(' THEN ?', $attribute['attribute_code'], 'string'); + } + + $expressionParts[]= 'END'; + + return new \Zend_Db_Expr(implode(' ', $expressionParts)); + } + + /** + * Get list of attribute tables. + * + * Returns result in the following format: * + * ``` + * $attributeAttributeCodeTables = [ + * 'm2_catalog_product_entity_varchar' => + * '45' => [ + * 'attribute_id' => 45, + * 'backend_type' => 'varchar', + * 'name' => attribute_code, + * ] + * ] + * ]; + * ``` + * + * @param string $entityTable + * @param array $eavAttributesMetaData + * @return array + */ + private function getAttributeCodeTables($entityTable, $eavAttributesMetaData): array + { + $attributeAttributeCodeTables = []; + $metaTypes = \array_unique(\array_column($eavAttributesMetaData, 'backend_type')); + + foreach ($metaTypes as $type) { + if (\in_array($type, self::SUPPORTED_BACKEND_TYPES, true)) { + $tableName = \sprintf('%s_%s', $entityTable, $type); + $attributeAttributeCodeTables[$tableName] = array_filter( + $eavAttributesMetaData, + function ($attribute) use ($type) { + return $attribute['backend_type'] === $type; + } + ); + } + } + + return $attributeAttributeCodeTables; + } + + /** + * Get EAV attribute codes + * Remove attributes from entity table and attributes from exclude list + * Add linked attributes to output + * + * @param array $attributes + * @param array $entityTableAttributes + * @return array + */ + private function getEavAttributeCodes($attributes, $entityTableAttributes): array + { + $attributes = \array_diff($attributes, $entityTableAttributes); + $unusedAttributeList = []; + $newAttributes = []; + foreach ($this->linkedAttributes as $attribute => $linkedAttributes) { + if (null === $linkedAttributes) { + $unusedAttributeList[] = $attribute; + } elseif (\is_array($linkedAttributes) && \in_array($attribute, $attributes, true)) { + $newAttributes[] = $linkedAttributes; + } + } + $attributes = \array_diff($attributes, $unusedAttributeList); + + return \array_unique(\array_merge($attributes, ...$newAttributes)); + } + + /** + * Retrieve entity type id + * + * @return int + * @throws \Exception + */ + private function getEntityTypeId(): int + { + if (!isset($this->entityTypeIdMap[$this->entityType])) { + $this->entityTypeIdMap[$this->entityType] = (int)$this->eavConfig->getEntityType( + $this->metadataPool->getMetadata($this->entityType)->getEavEntityType() + )->getId(); + } + + return $this->entityTypeIdMap[$this->entityType]; + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Category/Query/CategoryAttributeQuery.php b/app/code/Magento/CatalogGraphQl/DataProvider/Category/Query/CategoryAttributeQuery.php new file mode 100644 index 0000000000000..e3dfa38c78258 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Category/Query/CategoryAttributeQuery.php @@ -0,0 +1,60 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Category\Query; + +use Magento\Catalog\Api\Data\CategoryInterface; +use Magento\Framework\DB\Select; + +/** + * Provide category attributes for specified category ids and attributes + */ +class CategoryAttributeQuery +{ + /** + * @var \Magento\CatalogGraphQl\DataProvider\AttributeQueryFactory + */ + private $attributeQueryFactory; + + /** + * @var array + */ + private static $requiredAttributes = [ + 'entity_id', + ]; + + /** + * @param \Magento\CatalogGraphQl\DataProvider\AttributeQueryFactory $attributeQueryFactory + */ + public function __construct( + \Magento\CatalogGraphQl\DataProvider\AttributeQueryFactory $attributeQueryFactory + ) { + $this->attributeQueryFactory = $attributeQueryFactory; + } + + /** + * Form and return query to get eav attributes for given categories + * + * @param array $categoryIds + * @param array $categoryAttributes + * @param int $storeId + * @return Select + * @throws \Zend_Db_Select_Exception + */ + public function getQuery(array $categoryIds, array $categoryAttributes, int $storeId): Select + { + $categoryAttributes = \array_merge($categoryAttributes, self::$requiredAttributes); + + $attributeQuery = $this->attributeQueryFactory->create( + [ + 'entityType' => CategoryInterface::class + ] + ); + + return $attributeQuery->getQuery($categoryIds, $categoryAttributes, $storeId); + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/CategoryAttributesMapper.php b/app/code/Magento/CatalogGraphQl/DataProvider/CategoryAttributesMapper.php new file mode 100644 index 0000000000000..ea3c0b608d212 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/CategoryAttributesMapper.php @@ -0,0 +1,117 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider; + +use Magento\Framework\GraphQl\ConfigInterface; +use Magento\Framework\GraphQl\Config\Element\Type; +use Magento\Framework\GraphQl\Config\Element\InterfaceType; + +/** + * Map for category attributes. + */ +class CategoryAttributesMapper +{ + /** + * @var ConfigInterface + */ + private $graphqlConfig; + + /** + * @param ConfigInterface $graphqlConfig + */ + public function __construct( + ConfigInterface $graphqlConfig + ) { + $this->graphqlConfig = $graphqlConfig; + } + + /** + * Returns attribute values for given attribute codes. + * + * @param array $fetchResult + * @return array + */ + public function getAttributesValues(array $fetchResult): array + { + $attributes = []; + + foreach ($fetchResult as $row) { + if (!isset($attributes[$row['entity_id']])) { + $attributes[$row['entity_id']] = $row; + //TODO: do we need to introduce field mapping? + $attributes[$row['entity_id']]['id'] = $row['entity_id']; + } + if (isset($row['attribute_code'])) { + $attributes[$row['entity_id']][$row['attribute_code']] = $row['value']; + } + } + + return $this->formatAttributes($attributes); + } + + /** + * Format attributes that should be converted to array type + * + * @param array $attributes + * @return array + */ + private function formatAttributes(array $attributes): array + { + $arrayTypeAttributes = $this->getFieldsOfArrayType(); + + return $arrayTypeAttributes + ? array_map( + function ($data) use ($arrayTypeAttributes) { + foreach ($arrayTypeAttributes as $attributeCode) { + $data[$attributeCode] = $this->valueToArray($data[$attributeCode] ?? null); + } + return $data; + }, + $attributes + ) + : $attributes; + } + + /** + * Cast string to array + * + * @param string|null $value + * @return array + */ + private function valueToArray($value): array + { + return $value ? \explode(',', $value) : []; + } + + /** + * Get fields that should be converted to array type + * + * @return array + */ + private function getFieldsOfArrayType(): array + { + $categoryTreeSchema = $this->graphqlConfig->getConfigElement('CategoryTree'); + if (!$categoryTreeSchema instanceof Type) { + throw new \LogicException('CategoryTree type not defined in schema.'); + } + + $fields = []; + foreach ($categoryTreeSchema->getInterfaces() as $interface) { + /** @var InterfaceType $configElement */ + $configElement = $this->graphqlConfig->getConfigElement($interface['interface']); + + foreach ($configElement->getFields() as $field) { + if ($field->isList()) { + $fields[] = $field->getName(); + } + } + } + + return $fields; + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php new file mode 100644 index 0000000000000..7781473128754 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php @@ -0,0 +1,107 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation; + +use Magento\Framework\App\ResourceConnection; + +/** + * Fetch product attribute option data including attribute info + * Return data in format: + * [ + * attribute_code => [ + * attribute_code => code, + * attribute_label => attribute label, + * option_label => option label, + * options => [option_id => 'option label', ...], + * ] + * ... + * ] + */ +class AttributeOptionProvider +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct(ResourceConnection $resourceConnection) + { + $this->resourceConnection = $resourceConnection; + } + + /** + * Get option data. Return list of attributes with option data + * + * @param array $optionIds + * @return array + * @throws \Zend_Db_Statement_Exception + */ + public function getOptions(array $optionIds): array + { + if (!$optionIds) { + return []; + } + + $connection = $this->resourceConnection->getConnection(); + $select = $connection->select() + ->from( + ['a' => $this->resourceConnection->getTableName('eav_attribute')], + [ + 'attribute_id' => 'a.attribute_id', + 'attribute_code' => 'a.attribute_code', + 'attribute_label' => 'a.frontend_label', + ] + ) + ->joinInner( + ['options' => $this->resourceConnection->getTableName('eav_attribute_option')], + 'a.attribute_id = options.attribute_id', + [] + ) + ->joinInner( + ['option_value' => $this->resourceConnection->getTableName('eav_attribute_option_value')], + 'options.option_id = option_value.option_id', + [ + 'option_label' => 'option_value.value', + 'option_id' => 'option_value.option_id', + ] + ) + ->where('option_value.option_id IN (?)', $optionIds); + + return $this->formatResult($select); + } + + /** + * Format result + * + * @param \Magento\Framework\DB\Select $select + * @return array + * @throws \Zend_Db_Statement_Exception + */ + private function formatResult(\Magento\Framework\DB\Select $select): array + { + $statement = $this->resourceConnection->getConnection()->query($select); + + $result = []; + while ($option = $statement->fetch()) { + if (!isset($result[$option['attribute_code']])) { + $result[$option['attribute_code']] = [ + 'attribute_id' => $option['attribute_id'], + 'attribute_code' => $option['attribute_code'], + 'attribute_label' => $option['attribute_label'], + 'options' => [], + ]; + } + $result[$option['attribute_code']]['options'][$option['option_id']] = $option['option_label']; + } + + return $result; + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php new file mode 100644 index 0000000000000..b70c9f6165fc6 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php @@ -0,0 +1,157 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder; + +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\AttributeOptionProvider; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilderInterface; +use Magento\Framework\Api\Search\AggregationInterface; +use Magento\Framework\Api\Search\AggregationValueInterface; +use Magento\Framework\Api\Search\BucketInterface; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Formatter\LayerFormatter; + +/** + * @inheritdoc + */ +class Attribute implements LayerBuilderInterface +{ + /** + * @var string + * @see \Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Category::CATEGORY_BUCKET + */ + private const PRICE_BUCKET = 'price_bucket'; + + /** + * @var string + * @see \Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Price::PRICE_BUCKET + */ + private const CATEGORY_BUCKET = 'category_bucket'; + + /** + * @var AttributeOptionProvider + */ + private $attributeOptionProvider; + + /** + * @var LayerFormatter + */ + private $layerFormatter; + + /** + * @var array + */ + private $bucketNameFilter = [ + self::PRICE_BUCKET, + self::CATEGORY_BUCKET + ]; + + /** + * @param AttributeOptionProvider $attributeOptionProvider + * @param LayerFormatter $layerFormatter + * @param array $bucketNameFilter + */ + public function __construct( + AttributeOptionProvider $attributeOptionProvider, + LayerFormatter $layerFormatter, + $bucketNameFilter = [] + ) { + $this->attributeOptionProvider = $attributeOptionProvider; + $this->layerFormatter = $layerFormatter; + $this->bucketNameFilter = \array_merge($this->bucketNameFilter, $bucketNameFilter); + } + + /** + * @inheritdoc + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws \Zend_Db_Statement_Exception + */ + public function build(AggregationInterface $aggregation, ?int $storeId): array + { + $attributeOptions = $this->getAttributeOptions($aggregation); + + // build layer per attribute + $result = []; + foreach ($this->getAttributeBuckets($aggregation) as $bucket) { + $bucketName = $bucket->getName(); + $attributeCode = \preg_replace('~_bucket$~', '', $bucketName); + $attribute = $attributeOptions[$attributeCode] ?? []; + + $result[$bucketName] = $this->layerFormatter->buildLayer( + $attribute['attribute_label'] ?? $bucketName, + \count($bucket->getValues()), + $attribute['attribute_code'] ?? $bucketName + ); + + foreach ($bucket->getValues() as $value) { + $metrics = $value->getMetrics(); + $result[$bucketName]['options'][] = $this->layerFormatter->buildItem( + $attribute['options'][$metrics['value']] ?? $metrics['value'], + $metrics['value'], + $metrics['count'] + ); + } + } + + return $result; + } + + /** + * Get attribute buckets excluding specified bucket names + * + * @param AggregationInterface $aggregation + * @return \Generator|BucketInterface[] + */ + private function getAttributeBuckets(AggregationInterface $aggregation) + { + foreach ($aggregation->getBuckets() as $bucket) { + if (\in_array($bucket->getName(), $this->bucketNameFilter, true)) { + continue; + } + if ($this->isBucketEmpty($bucket)) { + continue; + } + yield $bucket; + } + } + + /** + * Check that bucket contains data + * + * @param BucketInterface|null $bucket + * @return bool + */ + private function isBucketEmpty(?BucketInterface $bucket): bool + { + return null === $bucket || !$bucket->getValues(); + } + + /** + * Get list of attributes with options + * + * @param AggregationInterface $aggregation + * @return array + * @throws \Zend_Db_Statement_Exception + */ + private function getAttributeOptions(AggregationInterface $aggregation): array + { + $attributeOptionIds = []; + foreach ($this->getAttributeBuckets($aggregation) as $bucket) { + $attributeOptionIds[] = \array_map( + function (AggregationValueInterface $value) { + return $value->getValue(); + }, + $bucket->getValues() + ); + } + + if (!$attributeOptionIds) { + return []; + } + + return $this->attributeOptionProvider->getOptions(\array_merge(...$attributeOptionIds)); + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php new file mode 100644 index 0000000000000..b0e67d72e25ba --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php @@ -0,0 +1,151 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder; + +use Magento\CatalogGraphQl\DataProvider\CategoryAttributesMapper; +use Magento\CatalogGraphQl\DataProvider\Category\Query\CategoryAttributeQuery; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilderInterface; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\RootCategoryProvider; +use Magento\Framework\Api\Search\AggregationInterface; +use Magento\Framework\Api\Search\AggregationValueInterface; +use Magento\Framework\Api\Search\BucketInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Formatter\LayerFormatter; + +/** + * @inheritdoc + */ +class Category implements LayerBuilderInterface +{ + /** + * @var string + */ + private const CATEGORY_BUCKET = 'category_bucket'; + + /** + * @var array + */ + private static $bucketMap = [ + self::CATEGORY_BUCKET => [ + 'request_name' => 'category_id', + 'label' => 'Category' + ], + ]; + + /** + * @var CategoryAttributeQuery + */ + private $categoryAttributeQuery; + + /** + * @var CategoryAttributesMapper + */ + private $attributesMapper; + + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @var RootCategoryProvider + */ + private $rootCategoryProvider; + + /** + * @var LayerFormatter + */ + private $layerFormatter; + + /** + * @param CategoryAttributeQuery $categoryAttributeQuery + * @param CategoryAttributesMapper $attributesMapper + * @param RootCategoryProvider $rootCategoryProvider + * @param ResourceConnection $resourceConnection + * @param LayerFormatter $layerFormatter + */ + public function __construct( + CategoryAttributeQuery $categoryAttributeQuery, + CategoryAttributesMapper $attributesMapper, + RootCategoryProvider $rootCategoryProvider, + ResourceConnection $resourceConnection, + LayerFormatter $layerFormatter + ) { + $this->categoryAttributeQuery = $categoryAttributeQuery; + $this->attributesMapper = $attributesMapper; + $this->resourceConnection = $resourceConnection; + $this->rootCategoryProvider = $rootCategoryProvider; + $this->layerFormatter = $layerFormatter; + } + + /** + * @inheritdoc + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Zend_Db_Select_Exception + */ + public function build(AggregationInterface $aggregation, ?int $storeId): array + { + $bucket = $aggregation->getBucket(self::CATEGORY_BUCKET); + if ($this->isBucketEmpty($bucket)) { + return []; + } + + $categoryIds = \array_map( + function (AggregationValueInterface $value) { + return (int)$value->getValue(); + }, + $bucket->getValues() + ); + + $categoryIds = \array_diff($categoryIds, [$this->rootCategoryProvider->getRootCategory($storeId)]); + $categoryLabels = \array_column( + $this->attributesMapper->getAttributesValues( + $this->resourceConnection->getConnection()->fetchAll( + $this->categoryAttributeQuery->getQuery($categoryIds, ['name'], $storeId) + ) + ), + 'name', + 'entity_id' + ); + + if (!$categoryLabels) { + return []; + } + + $result = $this->layerFormatter->buildLayer( + self::$bucketMap[self::CATEGORY_BUCKET]['label'], + \count($categoryIds), + self::$bucketMap[self::CATEGORY_BUCKET]['request_name'] + ); + + foreach ($bucket->getValues() as $value) { + $categoryId = $value->getValue(); + if (!\in_array($categoryId, $categoryIds, true)) { + continue ; + } + $result['options'][] = $this->layerFormatter->buildItem( + $categoryLabels[$categoryId] ?? $categoryId, + $categoryId, + $value->getMetrics()['count'] + ); + } + + return [$result]; + } + + /** + * Check that bucket contains data + * + * @param BucketInterface|null $bucket + * @return bool + */ + private function isBucketEmpty(?BucketInterface $bucket): bool + { + return null === $bucket || !$bucket->getValues(); + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php new file mode 100644 index 0000000000000..02b638edbdce8 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php @@ -0,0 +1,88 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder; + +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilderInterface; +use Magento\Framework\Api\Search\AggregationInterface; +use Magento\Framework\Api\Search\BucketInterface; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Formatter\LayerFormatter; + +/** + * @inheritdoc + */ +class Price implements LayerBuilderInterface +{ + /** + * @var string + */ + private const PRICE_BUCKET = 'price_bucket'; + + /** + * @var LayerFormatter + */ + private $layerFormatter; + + /** + * @var array + */ + private static $bucketMap = [ + self::PRICE_BUCKET => [ + 'request_name' => 'price', + 'label' => 'Price' + ], + ]; + + /** + * @param LayerFormatter $layerFormatter + */ + public function __construct( + LayerFormatter $layerFormatter + ) { + $this->layerFormatter = $layerFormatter; + } + + /** + * @inheritdoc + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function build(AggregationInterface $aggregation, ?int $storeId): array + { + $bucket = $aggregation->getBucket(self::PRICE_BUCKET); + if ($this->isBucketEmpty($bucket)) { + return []; + } + + $result = $this->layerFormatter->buildLayer( + self::$bucketMap[self::PRICE_BUCKET]['label'], + \count($bucket->getValues()), + self::$bucketMap[self::PRICE_BUCKET]['request_name'] + ); + + foreach ($bucket->getValues() as $value) { + $metrics = $value->getMetrics(); + $result['options'][] = $this->layerFormatter->buildItem( + \str_replace('_', '-', $metrics['value']), + $metrics['value'], + $metrics['count'] + ); + } + + return [$result]; + } + + /** + * Check that bucket contains data + * + * @param BucketInterface|null $bucket + * @return bool + */ + private function isBucketEmpty(?BucketInterface $bucket): bool + { + return null === $bucket || !$bucket->getValues(); + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Formatter/LayerFormatter.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Formatter/LayerFormatter.php new file mode 100644 index 0000000000000..48a1265b10fc3 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Formatter/LayerFormatter.php @@ -0,0 +1,48 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Formatter; + +/** + * Format Layered Navigation Items + */ +class LayerFormatter +{ + /** + * Format layer data + * + * @param string $layerName + * @param string $itemsCount + * @param string $requestName + * @return array + */ + public function buildLayer($layerName, $itemsCount, $requestName): array + { + return [ + 'label' => $layerName, + 'count' => $itemsCount, + 'attribute_code' => $requestName + ]; + } + + /** + * Format layer item data + * + * @param string $label + * @param string|int $value + * @param string|int $count + * @return array + */ + public function buildItem($label, $value, $count): array + { + return [ + 'label' => $label, + 'value' => $value, + 'count' => $count, + ]; + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php new file mode 100644 index 0000000000000..ff661236be62f --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php @@ -0,0 +1,43 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation; + +use Magento\Framework\Api\Search\AggregationInterface; + +/** + * @inheritdoc + */ +class LayerBuilder implements LayerBuilderInterface +{ + /** + * @var LayerBuilderInterface[] + */ + private $builders; + + /** + * @param LayerBuilderInterface[] $builders + */ + public function __construct(array $builders) + { + $this->builders = $builders; + } + + /** + * @inheritdoc + */ + public function build(AggregationInterface $aggregation, ?int $storeId): array + { + $layers = []; + foreach ($this->builders as $builder) { + $layers[] = $builder->build($aggregation, $storeId); + } + $layers = \array_merge(...$layers); + + return \array_filter($layers); + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilderInterface.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilderInterface.php new file mode 100644 index 0000000000000..bd55bc6938b39 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilderInterface.php @@ -0,0 +1,40 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation; + +use Magento\Framework\Api\Search\AggregationInterface; + +/** + * Build layer data from AggregationInterface + * Return data in the following format: + * + * [ + * [ + * 'name' => 'layer name', + * 'filter_items_count' => 'filter items count', + * 'request_var' => 'filter name in request', + * 'filter_items' => [ + * 'label' => 'item name', + * 'value_string' => 'item value, e.g. category ID', + * 'items_count' => 'product count', + * ], + * ], + * ... + * ]; + */ +interface LayerBuilderInterface +{ + /** + * Build layer data + * + * @param AggregationInterface $aggregation + * @param int|null $storeId + * @return array [[{layer data}], ...] + */ + public function build(AggregationInterface $aggregation, ?int $storeId): array; +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/RootCategoryProvider.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/RootCategoryProvider.php new file mode 100644 index 0000000000000..4b8a4a31b3c35 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/RootCategoryProvider.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation; + +use Magento\Framework\App\ResourceConnection; + +/** + * Fetch root category id for specified store id + */ +class RootCategoryProvider +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct( + ResourceConnection $resourceConnection + ) { + $this->resourceConnection = $resourceConnection; + } + + /** + * Get root category for specified store id + * + * @param int $storeId + * @return int + */ + public function getRootCategory(int $storeId): int + { + $connection = $this->resourceConnection->getConnection(); + + $select = $connection->select() + ->from( + ['store' => $this->resourceConnection->getTableName('store')], + [] + ) + ->join( + ['store_group' => $this->resourceConnection->getTableName('store_group')], + 'store.group_id = store_group.group_id', + ['root_category_id' => 'store_group.root_category_id'] + ) + ->where('store.store_id = ?', $storeId); + + return (int)$connection->fetchOne($select); + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php new file mode 100644 index 0000000000000..0e92bbbab4259 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php @@ -0,0 +1,208 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product; + +use Magento\Catalog\Api\Data\EavAttributeInterface; +use Magento\Framework\Api\FilterBuilder; +use Magento\Framework\Api\Search\FilterGroupBuilder; +use Magento\Framework\Api\Search\SearchCriteriaInterface; +use Magento\Framework\Api\SortOrder; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\Builder; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Framework\Api\SortOrderBuilder; + +/** + * Build search criteria + */ +class SearchCriteriaBuilder +{ + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var FilterBuilder + */ + private $filterBuilder; + + /** + * @var FilterGroupBuilder + */ + private $filterGroupBuilder; + + /** + * @var Builder + */ + private $builder; + /** + * @var Visibility + */ + private $visibility; + + /** + * @var SortOrderBuilder + */ + private $sortOrderBuilder; + + /** + * @param Builder $builder + * @param ScopeConfigInterface $scopeConfig + * @param FilterBuilder $filterBuilder + * @param FilterGroupBuilder $filterGroupBuilder + * @param Visibility $visibility + * @param SortOrderBuilder $sortOrderBuilder + */ + public function __construct( + Builder $builder, + ScopeConfigInterface $scopeConfig, + FilterBuilder $filterBuilder, + FilterGroupBuilder $filterGroupBuilder, + Visibility $visibility, + SortOrderBuilder $sortOrderBuilder + ) { + $this->scopeConfig = $scopeConfig; + $this->filterBuilder = $filterBuilder; + $this->filterGroupBuilder = $filterGroupBuilder; + $this->builder = $builder; + $this->visibility = $visibility; + $this->sortOrderBuilder = $sortOrderBuilder; + } + + /** + * Build search criteria + * + * @param array $args + * @param bool $includeAggregation + * @return SearchCriteriaInterface + */ + public function build(array $args, bool $includeAggregation): SearchCriteriaInterface + { + $searchCriteria = $this->builder->build('products', $args); + $isSearch = !empty($args['search']); + $this->updateRangeFilters($searchCriteria); + + if ($includeAggregation) { + $this->preparePriceAggregation($searchCriteria); + $requestName = 'graphql_product_search_with_aggregation'; + } else { + $requestName = 'graphql_product_search'; + } + $searchCriteria->setRequestName($requestName); + + if ($isSearch) { + $this->addFilter($searchCriteria, 'search_term', $args['search']); + } + + if (!$searchCriteria->getSortOrders()) { + $this->addDefaultSortOrder($searchCriteria, $isSearch); + } + + $this->addVisibilityFilter($searchCriteria, $isSearch, !empty($args['filter'])); + + $searchCriteria->setCurrentPage($args['currentPage']); + $searchCriteria->setPageSize($args['pageSize']); + + return $searchCriteria; + } + + /** + * Add filter by visibility + * + * @param SearchCriteriaInterface $searchCriteria + * @param bool $isSearch + * @param bool $isFilter + */ + private function addVisibilityFilter(SearchCriteriaInterface $searchCriteria, bool $isSearch, bool $isFilter): void + { + if ($isFilter && $isSearch) { + // Index already contains products filtered by visibility: catalog, search, both + return ; + } + $visibilityIds = $isSearch + ? $this->visibility->getVisibleInSearchIds() + : $this->visibility->getVisibleInCatalogIds(); + + $this->addFilter($searchCriteria, 'visibility', $visibilityIds); + } + + /** + * Prepare price aggregation algorithm + * + * @param SearchCriteriaInterface $searchCriteria + * @return void + */ + private function preparePriceAggregation(SearchCriteriaInterface $searchCriteria): void + { + $priceRangeCalculation = $this->scopeConfig->getValue( + \Magento\Catalog\Model\Layer\Filter\Dynamic\AlgorithmFactory::XML_PATH_RANGE_CALCULATION, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + if ($priceRangeCalculation) { + $this->addFilter($searchCriteria, 'price_dynamic_algorithm', $priceRangeCalculation); + } + } + + /** + * Add filter to search criteria + * + * @param SearchCriteriaInterface $searchCriteria + * @param string $field + * @param mixed $value + */ + private function addFilter(SearchCriteriaInterface $searchCriteria, string $field, $value): void + { + $filter = $this->filterBuilder + ->setField($field) + ->setValue($value) + ->create(); + $this->filterGroupBuilder->addFilter($filter); + $filterGroups = $searchCriteria->getFilterGroups(); + $filterGroups[] = $this->filterGroupBuilder->create(); + $searchCriteria->setFilterGroups($filterGroups); + } + + /** + * Sort by relevance DESC by default + * + * @param SearchCriteriaInterface $searchCriteria + * @param bool $isSearch + */ + private function addDefaultSortOrder(SearchCriteriaInterface $searchCriteria, $isSearch = false): void + { + $sortField = $isSearch ? 'relevance' : EavAttributeInterface::POSITION; + $sortDirection = $isSearch ? SortOrder::SORT_DESC : SortOrder::SORT_ASC; + $defaultSortOrder = $this->sortOrderBuilder + ->setField($sortField) + ->setDirection($sortDirection) + ->create(); + + $searchCriteria->setSortOrders([$defaultSortOrder]); + } + + /** + * Format range filters so replacement works + * + * Range filter fields in search request must replace value like '%field.from%' or '%field.to%' + * + * @param SearchCriteriaInterface $searchCriteria + */ + private function updateRangeFilters(SearchCriteriaInterface $searchCriteria): void + { + $filterGroups = $searchCriteria->getFilterGroups(); + foreach ($filterGroups as $filterGroup) { + $filters = $filterGroup->getFilters(); + foreach ($filters as $filter) { + if (in_array($filter->getConditionType(), ['from', 'to'])) { + $filter->setField($filter->getField() . '.' . $filter->getConditionType()); + } + } + } + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolver.php b/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolver.php new file mode 100644 index 0000000000000..3a532a1a6c760 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolver.php @@ -0,0 +1,29 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model; + +use \Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; + +/** + * Resolver for aggregation option type. + */ +class AggregationOptionTypeResolver implements TypeResolverInterface +{ + /** + * @inheritdoc + */ + public function resolveType(array $data) : string + { + return isset($data['value']) + && isset($data['label']) + && isset($data['count']) + && count($data) == 3 + ? 'AggregationOption' + : ''; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolverComposite.php b/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolverComposite.php new file mode 100644 index 0000000000000..eb0127c63784d --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolverComposite.php @@ -0,0 +1,45 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model; + +use \Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; + +/** + * Composite resolver for aggregation options. + */ +class AggregationOptionTypeResolverComposite implements TypeResolverInterface +{ + /** + * @var TypeResolverInterface[] + */ + private $typeResolvers; + + /** + * @param array $typeResolvers + */ + public function __construct(array $typeResolvers = []) + { + $this->typeResolvers = $typeResolvers; + } + + /** + * @inheritdoc + */ + public function resolveType(array $data) : string + { + /** @var TypeResolverInterface $typeResolver */ + foreach ($this->typeResolvers as $typeResolver) { + $resolvedType = $typeResolver->resolveType($data); + if ($resolvedType) { + return $resolvedType; + } + } + throw new GraphQlInputException(__('Cannot resolve aggregation option type')); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Config/FilterAttributeReader.php b/app/code/Magento/CatalogGraphQl/Model/Config/FilterAttributeReader.php new file mode 100644 index 0000000000000..4f3a88cc788df --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Config/FilterAttributeReader.php @@ -0,0 +1,134 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Config; + +use Magento\Framework\Config\ReaderInterface; +use Magento\Framework\GraphQl\Schema\Type\Entity\MapperInterface; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\Collection; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; + +/** + * Adds custom/eav attributes to product filter type in the GraphQL config. + * + * Product Attribute should satisfy the following criteria: + * - Attribute is searchable + * - "Visible in Advanced Search" is set to "Yes" + * - Attribute of type "Select" must have options + */ +class FilterAttributeReader implements ReaderInterface +{ + /** + * Entity type constant + */ + private const ENTITY_TYPE = 'filter_attributes'; + + /** + * Filter input types + */ + private const FILTER_EQUAL_TYPE = 'FilterEqualTypeInput'; + private const FILTER_RANGE_TYPE = 'FilterRangeTypeInput'; + private const FILTER_MATCH_TYPE = 'FilterMatchTypeInput'; + + /** + * @var MapperInterface + */ + private $mapper; + + /** + * @var CollectionFactory + */ + private $collectionFactory; + + /** + * @var array + */ + private $exactMatchAttributes = ['sku']; + + /** + * @param MapperInterface $mapper + * @param CollectionFactory $collectionFactory + * @param array $exactMatchAttributes + */ + public function __construct( + MapperInterface $mapper, + CollectionFactory $collectionFactory, + array $exactMatchAttributes = [] + ) { + $this->mapper = $mapper; + $this->collectionFactory = $collectionFactory; + $this->exactMatchAttributes = array_merge($this->exactMatchAttributes, $exactMatchAttributes); + } + + /** + * Read configuration scope + * + * @param string|null $scope + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function read($scope = null) : array + { + $typeNames = $this->mapper->getMappedTypes(self::ENTITY_TYPE); + $config = []; + + foreach ($this->getAttributeCollection() as $attribute) { + $attributeCode = $attribute->getAttributeCode(); + + foreach ($typeNames as $typeName) { + $config[$typeName]['fields'][$attributeCode] = [ + 'name' => $attributeCode, + 'type' => $this->getFilterType($attribute), + 'arguments' => [], + 'required' => false, + 'description' => sprintf('Attribute label: %s', $attribute->getDefaultFrontendLabel()) + ]; + } + } + + return $config; + } + + /** + * Map attribute type to filter type + * + * @param Attribute $attribute + * @return string + */ + private function getFilterType(Attribute $attribute): string + { + if (in_array($attribute->getAttributeCode(), $this->exactMatchAttributes)) { + return self::FILTER_EQUAL_TYPE; + } + + $filterTypeMap = [ + 'price' => self::FILTER_RANGE_TYPE, + 'date' => self::FILTER_RANGE_TYPE, + 'select' => self::FILTER_EQUAL_TYPE, + 'multiselect' => self::FILTER_EQUAL_TYPE, + 'boolean' => self::FILTER_EQUAL_TYPE, + 'text' => self::FILTER_MATCH_TYPE, + 'textarea' => self::FILTER_MATCH_TYPE, + ]; + + return $filterTypeMap[$attribute->getFrontendInput()] ?? self::FILTER_MATCH_TYPE; + } + + /** + * Create attribute collection + * + * @return Collection|\Magento\Catalog\Model\ResourceModel\Eav\Attribute[] + */ + private function getAttributeCollection() + { + return $this->collectionFactory->create() + ->addHasOptionsFilter() + ->addIsSearchableFilter() + ->addDisplayInAdvancedSearchFilter(); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Config/SortAttributeReader.php b/app/code/Magento/CatalogGraphQl/Model/Config/SortAttributeReader.php new file mode 100644 index 0000000000000..215b28be0579c --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Config/SortAttributeReader.php @@ -0,0 +1,79 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\CatalogGraphQl\Model\Config; + +use Magento\Framework\Config\ReaderInterface; +use Magento\Framework\GraphQl\Schema\Type\Entity\MapperInterface; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\Collection as AttributesCollection; + +/** + * Adds custom/eav attribute to catalog products sorting in the GraphQL config. + */ +class SortAttributeReader implements ReaderInterface +{ + /** + * Entity type constant + */ + private const ENTITY_TYPE = 'sort_attributes'; + + /** + * Fields type constant + */ + private const FIELD_TYPE = 'SortEnum'; + + /** + * @var MapperInterface + */ + private $mapper; + + /** + * @var AttributesCollection + */ + private $attributesCollection; + + /** + * @param MapperInterface $mapper + * @param AttributesCollection $attributesCollection + */ + public function __construct( + MapperInterface $mapper, + AttributesCollection $attributesCollection + ) { + $this->mapper = $mapper; + $this->attributesCollection = $attributesCollection; + } + + /** + * Read configuration scope + * + * @param string|null $scope + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function read($scope = null) : array + { + $map = $this->mapper->getMappedTypes(self::ENTITY_TYPE); + $config =[]; + $attributes = $this->attributesCollection->addSearchableAttributeFilter()->addFilter('used_for_sort_by', 1); + /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute */ + foreach ($attributes as $attribute) { + $attributeCode = $attribute->getAttributeCode(); + $attributeLabel = $attribute->getDefaultFrontendLabel(); + foreach ($map as $type) { + $config[$type]['fields'][$attributeCode] = [ + 'name' => $attributeCode, + 'type' => self::FIELD_TYPE, + 'arguments' => [], + 'required' => false, + 'description' => __('Attribute label: ') . $attributeLabel + ]; + } + } + + return $config; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php new file mode 100644 index 0000000000000..47a1d1f977f9b --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php @@ -0,0 +1,68 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Resolver; + +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilder; +use Magento\Store\Api\Data\StoreInterface; + +/** + * Layered navigation filters resolver, used for GraphQL request processing. + */ +class Aggregations implements ResolverInterface +{ + /** + * @var Layer\DataProvider\Filters + */ + private $filtersDataProvider; + + /** + * @var LayerBuilder + */ + private $layerBuilder; + + /** + * @param \Magento\CatalogGraphQl\Model\Resolver\Layer\DataProvider\Filters $filtersDataProvider + * @param LayerBuilder $layerBuilder + */ + public function __construct( + \Magento\CatalogGraphQl\Model\Resolver\Layer\DataProvider\Filters $filtersDataProvider, + LayerBuilder $layerBuilder + ) { + $this->filtersDataProvider = $filtersDataProvider; + $this->layerBuilder = $layerBuilder; + } + + /** + * @inheritdoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!isset($value['layer_type']) || !isset($value['search_result'])) { + return null; + } + + $aggregations = $value['search_result']->getSearchAggregation(); + + if ($aggregations) { + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + $storeId = (int)$store->getId(); + return $this->layerBuilder->build($aggregations, $storeId); + } else { + return []; + } + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php index e0580213ddea7..abc5ae7e1da7f 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php @@ -8,6 +8,9 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Category; use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\CatalogGraphQl\DataProvider\Product\SearchCriteriaBuilder; +use Magento\CatalogGraphQl\Model\Resolver\Products\Query\Search; +use Magento\Framework\App\ObjectManager; use Magento\Framework\GraphQl\Config\Element\Field; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; @@ -27,27 +30,46 @@ class Products implements ResolverInterface /** * @var Builder + * @deprecated */ private $searchCriteriaBuilder; /** * @var Filter + * @deprecated */ private $filterQuery; + /** + * @var Search + */ + private $searchQuery; + + /** + * @var SearchCriteriaBuilder + */ + private $searchApiCriteriaBuilder; + /** * @param ProductRepositoryInterface $productRepository * @param Builder $searchCriteriaBuilder * @param Filter $filterQuery + * @param Search $searchQuery + * @param SearchCriteriaBuilder $searchApiCriteriaBuilder */ public function __construct( ProductRepositoryInterface $productRepository, Builder $searchCriteriaBuilder, - Filter $filterQuery + Filter $filterQuery, + Search $searchQuery = null, + SearchCriteriaBuilder $searchApiCriteriaBuilder = null ) { $this->productRepository = $productRepository; $this->searchCriteriaBuilder = $searchCriteriaBuilder; $this->filterQuery = $filterQuery; + $this->searchQuery = $searchQuery ?? ObjectManager::getInstance()->get(Search::class); + $this->searchApiCriteriaBuilder = $searchApiCriteriaBuilder ?? + ObjectManager::getInstance()->get(SearchCriteriaBuilder::class); } /** @@ -60,21 +82,20 @@ public function resolve( array $value = null, array $args = null ) { - $args['filter'] = [ - 'category_id' => [ - 'eq' => $value['id'] - ] - ]; - $searchCriteria = $this->searchCriteriaBuilder->build($field->getName(), $args); if ($args['currentPage'] < 1) { throw new GraphQlInputException(__('currentPage value must be greater than 0.')); } if ($args['pageSize'] < 1) { throw new GraphQlInputException(__('pageSize value must be greater than 0.')); } - $searchCriteria->setCurrentPage($args['currentPage']); - $searchCriteria->setPageSize($args['pageSize']); - $searchResult = $this->filterQuery->getResult($searchCriteria, $info); + + $args['filter'] = [ + 'category_id' => [ + 'eq' => $value['id'] + ] + ]; + $searchCriteria = $this->searchApiCriteriaBuilder->build($args, false); + $searchResult = $this->searchQuery->getResult($searchCriteria, $info); //possible division by 0 if ($searchCriteria->getPageSize()) { diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/TierPrices.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/TierPrices.php deleted file mode 100644 index 726ef91c56880..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/TierPrices.php +++ /dev/null @@ -1,63 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\CatalogGraphQl\Model\Resolver\Product; - -use Magento\Framework\Exception\LocalizedException; -use Magento\Framework\GraphQl\Query\Resolver\ContextInterface; -use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; -use Magento\Catalog\Model\Product; -use Magento\Catalog\Model\Product\TierPrice; -use Magento\Framework\GraphQl\Config\Element\Field; -use Magento\Framework\GraphQl\Query\ResolverInterface; - -/** - * @inheritdoc - * - * Format a product's tier price information to conform to GraphQL schema representation - */ -class TierPrices implements ResolverInterface -{ - /** - * @inheritdoc - * - * Format product's tier price data to conform to GraphQL schema - * - * @param \Magento\Framework\GraphQl\Config\Element\Field $field - * @param ContextInterface $context - * @param ResolveInfo $info - * @param array|null $value - * @param array|null $args - * @throws \Exception - * @return null|array - */ - public function resolve( - Field $field, - $context, - ResolveInfo $info, - array $value = null, - array $args = null - ) { - if (!isset($value['model'])) { - throw new LocalizedException(__('"model" value should be specified')); - } - - /** @var Product $product */ - $product = $value['model']; - - $tierPrices = null; - if ($product->getTierPrices()) { - $tierPrices = []; - /** @var TierPrice $tierPrice */ - foreach ($product->getTierPrices() as $tierPrice) { - $tierPrices[] = $tierPrice->getData(); - } - } - - return $tierPrices; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php index a75a9d2cf50a0..691f93e4148bc 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php @@ -16,6 +16,7 @@ use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\SearchFilter; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Catalog\Model\Layer\Resolver; +use Magento\CatalogGraphQl\DataProvider\Product\SearchCriteriaBuilder; /** * Products field resolver, used for GraphQL request processing. @@ -24,6 +25,7 @@ class Products implements ResolverInterface { /** * @var Builder + * @deprecated */ private $searchCriteriaBuilder; @@ -34,30 +36,41 @@ class Products implements ResolverInterface /** * @var Filter + * @deprecated */ private $filterQuery; /** * @var SearchFilter + * @deprecated */ private $searchFilter; + /** + * @var SearchCriteriaBuilder + */ + private $searchApiCriteriaBuilder; + /** * @param Builder $searchCriteriaBuilder * @param Search $searchQuery * @param Filter $filterQuery * @param SearchFilter $searchFilter + * @param SearchCriteriaBuilder|null $searchApiCriteriaBuilder */ public function __construct( Builder $searchCriteriaBuilder, Search $searchQuery, Filter $filterQuery, - SearchFilter $searchFilter + SearchFilter $searchFilter, + SearchCriteriaBuilder $searchApiCriteriaBuilder = null ) { $this->searchCriteriaBuilder = $searchCriteriaBuilder; $this->searchQuery = $searchQuery; $this->filterQuery = $filterQuery; $this->searchFilter = $searchFilter; + $this->searchApiCriteriaBuilder = $searchApiCriteriaBuilder ?? + \Magento\Framework\App\ObjectManager::getInstance()->get(SearchCriteriaBuilder::class); } /** @@ -70,40 +83,29 @@ public function resolve( array $value = null, array $args = null ) { - $searchCriteria = $this->searchCriteriaBuilder->build($field->getName(), $args); if ($args['currentPage'] < 1) { throw new GraphQlInputException(__('currentPage value must be greater than 0.')); } if ($args['pageSize'] < 1) { throw new GraphQlInputException(__('pageSize value must be greater than 0.')); } - $searchCriteria->setCurrentPage($args['currentPage']); - $searchCriteria->setPageSize($args['pageSize']); if (!isset($args['search']) && !isset($args['filter'])) { throw new GraphQlInputException( __("'search' or 'filter' input argument is required.") ); - } elseif (isset($args['search'])) { - $layerType = Resolver::CATALOG_LAYER_SEARCH; - $this->searchFilter->add($args['search'], $searchCriteria); - $searchResult = $this->searchQuery->getResult($searchCriteria, $info); - } else { - $layerType = Resolver::CATALOG_LAYER_CATEGORY; - $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) { + //get product children fields queried + $productFields = (array)$info->getFieldSelection(1); + $includeAggregations = isset($productFields['filters']) || isset($productFields['aggregations']); + $searchCriteria = $this->searchApiCriteriaBuilder->build($args, $includeAggregations); + $searchResult = $this->searchQuery->getResult($searchCriteria, $info, $args); + + if ($searchResult->getCurrentPage() > $searchResult->getTotalPages() && $searchResult->getTotalCount() > 0) { throw new GraphQlInputException( __( 'currentPage value %1 specified is greater than the %2 page(s) available.', - [$currentPage, $maxPages] + [$searchResult->getCurrentPage(), $searchResult->getTotalPages()] ) ); } @@ -112,11 +114,12 @@ public function resolve( 'total_count' => $searchResult->getTotalCount(), 'items' => $searchResult->getProductsSearchResult(), 'page_info' => [ - 'page_size' => $searchCriteria->getPageSize(), - 'current_page' => $currentPage, - 'total_pages' => $maxPages + 'page_size' => $searchResult->getPageSize(), + 'current_page' => $searchResult->getCurrentPage(), + 'total_pages' => $searchResult->getTotalPages() ], - 'layer_type' => $layerType + 'search_result' => $searchResult, + 'layer_type' => isset($args['search']) ? Resolver::CATALOG_LAYER_SEARCH : Resolver::CATALOG_LAYER_CATEGORY, ]; return $data; 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 e5e0d1aea4285..2076ec6726988 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php @@ -8,6 +8,7 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider; use Magento\Catalog\Model\Product\Visibility; +use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionPostProcessor; use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; use Magento\Catalog\Api\Data\ProductSearchResultsInterfaceFactory; @@ -32,7 +33,12 @@ class Product /** * @var CollectionProcessorInterface */ - private $collectionProcessor; + private $collectionPreProcessor; + + /** + * @var CollectionPostProcessor + */ + private $collectionPostProcessor; /** * @var Visibility @@ -44,17 +50,20 @@ class Product * @param ProductSearchResultsInterfaceFactory $searchResultsFactory * @param Visibility $visibility * @param CollectionProcessorInterface $collectionProcessor + * @param CollectionPostProcessor $collectionPostProcessor */ public function __construct( CollectionFactory $collectionFactory, ProductSearchResultsInterfaceFactory $searchResultsFactory, Visibility $visibility, - CollectionProcessorInterface $collectionProcessor + CollectionProcessorInterface $collectionProcessor, + CollectionPostProcessor $collectionPostProcessor ) { $this->collectionFactory = $collectionFactory; $this->searchResultsFactory = $searchResultsFactory; $this->visibility = $visibility; - $this->collectionProcessor = $collectionProcessor; + $this->collectionPreProcessor = $collectionProcessor; + $this->collectionPostProcessor = $collectionPostProcessor; } /** @@ -75,7 +84,7 @@ public function getList( /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $collection */ $collection = $this->collectionFactory->create(); - $this->collectionProcessor->process($collection, $searchCriteria, $attributes); + $this->collectionPreProcessor->process($collection, $searchCriteria, $attributes); if (!$isChildSearch) { $visibilityIds = $isSearch @@ -83,18 +92,9 @@ public function getList( : $this->visibility->getVisibleInCatalogIds(); $collection->setVisibility($visibilityIds); } - $collection->load(); - // Methods that perform extra fetches post-load - if (in_array('media_gallery_entries', $attributes)) { - $collection->addMediaGalleryData(); - } - if (in_array('media_gallery', $attributes)) { - $collection->addMediaGalleryData(); - } - if (in_array('options', $attributes)) { - $collection->addOptionsToResult(); - } + $collection->load(); + $this->collectionPostProcessor->process($collection, $attributes); $searchResult = $this->searchResultsFactory->create(); $searchResult->setSearchCriteria($searchCriteria); diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionPostProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionPostProcessor.php new file mode 100644 index 0000000000000..fadf22e7643af --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionPostProcessor.php @@ -0,0 +1,42 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product; + +use Magento\Catalog\Model\ResourceModel\Product\Collection; + +/** + * Processing applied to the collection after load + */ +class CollectionPostProcessor +{ + /** + * Apply processing to loaded product collection + * + * @param Collection $collection + * @param array $attributeNames + * @return Collection + */ + public function process(Collection $collection, array $attributeNames): Collection + { + if (!$collection->isLoaded()) { + $collection->load(); + } + // Methods that perform extra fetches post-load + if (in_array('media_gallery_entries', $attributeNames)) { + $collection->addMediaGalleryData(); + } + if (in_array('media_gallery', $attributeNames)) { + $collection->addMediaGalleryData(); + } + if (in_array('options', $attributeNames)) { + $collection->addOptionsToResult(); + } + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php new file mode 100644 index 0000000000000..ff845f4796763 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php @@ -0,0 +1,144 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider; + +use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionPostProcessor; +use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierFactory; +use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierInterface; +use Magento\Framework\Api\Search\SearchResultInterface; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Catalog\Api\Data\ProductSearchResultsInterfaceFactory; +use Magento\Framework\Api\SearchResultsInterface; +use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessorInterface; + +/** + * Product field data provider for product search, used for GraphQL resolver processing. + */ +class ProductSearch +{ + /** + * @var CollectionFactory + */ + private $collectionFactory; + + /** + * @var ProductSearchResultsInterfaceFactory + */ + private $searchResultsFactory; + + /** + * @var CollectionProcessorInterface + */ + private $collectionPreProcessor; + + /** + * @var CollectionPostProcessor + */ + private $collectionPostProcessor; + + /** + * @var SearchResultApplierFactory; + */ + private $searchResultApplierFactory; + + /** + * @param CollectionFactory $collectionFactory + * @param ProductSearchResultsInterfaceFactory $searchResultsFactory + * @param CollectionProcessorInterface $collectionPreProcessor + * @param CollectionPostProcessor $collectionPostProcessor + * @param SearchResultApplierFactory $searchResultsApplierFactory + */ + public function __construct( + CollectionFactory $collectionFactory, + ProductSearchResultsInterfaceFactory $searchResultsFactory, + CollectionProcessorInterface $collectionPreProcessor, + CollectionPostProcessor $collectionPostProcessor, + SearchResultApplierFactory $searchResultsApplierFactory + ) { + $this->collectionFactory = $collectionFactory; + $this->searchResultsFactory = $searchResultsFactory; + $this->collectionPreProcessor = $collectionPreProcessor; + $this->collectionPostProcessor = $collectionPostProcessor; + $this->searchResultApplierFactory = $searchResultsApplierFactory; + } + + /** + * Get list of product data with full data set. Adds eav attributes to result set from passed in array + * + * @param SearchCriteriaInterface $searchCriteria + * @param SearchResultInterface $searchResult + * @param array $attributes + * @return SearchResultsInterface + */ + public function getList( + SearchCriteriaInterface $searchCriteria, + SearchResultInterface $searchResult, + array $attributes = [] + ): SearchResultsInterface { + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + + //Join search results + $this->getSearchResultsApplier($searchResult, $collection, $this->getSortOrderArray($searchCriteria))->apply(); + + $this->collectionPreProcessor->process($collection, $searchCriteria, $attributes); + $collection->load(); + $this->collectionPostProcessor->process($collection, $attributes); + + $searchResults = $this->searchResultsFactory->create(); + $searchResults->setSearchCriteria($searchCriteria); + $searchResults->setItems($collection->getItems()); + $searchResults->setTotalCount($searchResult->getTotalCount()); + return $searchResults; + } + + /** + * Create searchResultApplier + * + * @param SearchResultInterface $searchResult + * @param Collection $collection + * @param array $orders + * @return SearchResultApplierInterface + */ + private function getSearchResultsApplier( + SearchResultInterface $searchResult, + Collection $collection, + array $orders + ): SearchResultApplierInterface { + return $this->searchResultApplierFactory->create( + [ + 'collection' => $collection, + 'searchResult' => $searchResult, + 'orders' => $orders + ] + ); + } + + /** + * Format sort orders into associative array + * + * E.g. ['field1' => 'DESC', 'field2' => 'ASC", ...] + * + * @param SearchCriteriaInterface $searchCriteria + * @return array + */ + private function getSortOrderArray(SearchCriteriaInterface $searchCriteria) + { + $ordersArray = []; + $sortOrders = $searchCriteria->getSortOrders(); + if (is_array($sortOrders)) { + foreach ($sortOrders as $sortOrder) { + $ordersArray[$sortOrder->getField()] = $sortOrder->getDirection(); + } + } + + return $ordersArray; + } +} 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 a547f63b217fe..973b8fbcd6b0f 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php @@ -23,13 +23,15 @@ class ProductEntityAttributesForAst implements FieldEntityAttributesInterface private $config; /** + * Additional attributes that are not retrieved by getting fields from ProductInterface + * * @var array */ private $additionalAttributes = ['min_price', 'max_price', 'category_id']; /** * @param ConfigInterface $config - * @param array $additionalAttributes + * @param string[] $additionalAttributes */ public function __construct( ConfigInterface $config, @@ -40,7 +42,12 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc + * + * Gather all the product entity attributes that can be filtered by search criteria. + * Example format ['attributeNameInGraphQl' => ['type' => 'String'. 'fieldName' => 'attributeNameInSearchCriteria']] + * + * @return array */ public function getEntityAttributes() : array { @@ -55,14 +62,20 @@ public function getEntityAttributes() : array $configElement = $this->config->getConfigElement($interface['interface']); foreach ($configElement->getFields() as $field) { - $fields[$field->getName()] = 'String'; + $fields[$field->getName()] = [ + 'type' => 'String', + 'fieldName' => $field->getName(), + ]; } } - foreach ($this->additionalAttributes as $attribute) { - $fields[$attribute] = 'String'; + foreach ($this->additionalAttributes as $attributeName) { + $fields[$attributeName] = [ + 'type' => 'String', + 'fieldName' => $attributeName, + ]; } - return array_keys($fields); + return $fields; } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/FieldSelection.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/FieldSelection.php new file mode 100644 index 0000000000000..3912bab05ebbe --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/FieldSelection.php @@ -0,0 +1,93 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Resolver\Products\Query; + +use GraphQL\Language\AST\SelectionNode; +use Magento\Framework\GraphQl\Query\FieldTranslator; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; + +/** + * Extract requested fields from products query + */ +class FieldSelection +{ + /** + * @var FieldTranslator + */ + private $fieldTranslator; + + /** + * @param FieldTranslator $fieldTranslator + */ + public function __construct(FieldTranslator $fieldTranslator) + { + $this->fieldTranslator = $fieldTranslator; + } + + /** + * Get requested fields from products query + * + * @param ResolveInfo $resolveInfo + * @return string[] + */ + public function getProductsFieldSelection(ResolveInfo $resolveInfo): array + { + return $this->getProductFields($resolveInfo); + } + + /** + * Return field names for all requested product fields. + * + * @param ResolveInfo $info + * @return string[] + */ + private function getProductFields(ResolveInfo $info): array + { + $fieldNames = []; + foreach ($info->fieldNodes as $node) { + if ($node->name->value !== 'products') { + continue; + } + foreach ($node->selectionSet->selections as $selection) { + if ($selection->name->value !== 'items') { + continue; + } + $fieldNames[] = $this->collectProductFieldNames($selection, $fieldNames); + } + } + + $fieldNames = array_merge(...$fieldNames); + + return $fieldNames; + } + + /** + * Collect field names for each node in selection + * + * @param SelectionNode $selection + * @param array $fieldNames + * @return array + */ + private function collectProductFieldNames(SelectionNode $selection, array $fieldNames = []): array + { + foreach ($selection->selectionSet->selections as $itemSelection) { + if ($itemSelection->kind === 'InlineFragment') { + foreach ($itemSelection->selectionSet->selections as $inlineSelection) { + if ($inlineSelection->kind === 'InlineFragment') { + continue; + } + $fieldNames[] = $this->fieldTranslator->translate($inlineSelection->name->value); + } + continue; + } + $fieldNames[] = $this->fieldTranslator->translate($itemSelection->name->value); + } + + return $fieldNames; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php index 62e2f0c488c6c..cc25af44fdfbe 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php @@ -12,7 +12,6 @@ use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResult; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResultFactory; -use Magento\Framework\GraphQl\Query\FieldTranslator; /** * Retrieve filtered product data based off given search criteria in a format that GraphQL can interpret. @@ -30,31 +29,31 @@ class Filter private $productDataProvider; /** - * @var FieldTranslator + * @var \Magento\Catalog\Model\Layer\Resolver */ - private $fieldTranslator; + private $layerResolver; /** - * @var \Magento\Catalog\Model\Layer\Resolver + * FieldSelection */ - private $layerResolver; + private $fieldSelection; /** * @param SearchResultFactory $searchResultFactory * @param Product $productDataProvider * @param \Magento\Catalog\Model\Layer\Resolver $layerResolver - * @param FieldTranslator $fieldTranslator + * @param FieldSelection $fieldSelection */ public function __construct( SearchResultFactory $searchResultFactory, Product $productDataProvider, \Magento\Catalog\Model\Layer\Resolver $layerResolver, - FieldTranslator $fieldTranslator + FieldSelection $fieldSelection ) { $this->searchResultFactory = $searchResultFactory; $this->productDataProvider = $productDataProvider; - $this->fieldTranslator = $fieldTranslator; $this->layerResolver = $layerResolver; + $this->fieldSelection = $fieldSelection; } /** @@ -70,7 +69,7 @@ public function getResult( ResolveInfo $info, bool $isSearch = false ): SearchResult { - $fields = $this->getProductFields($info); + $fields = $this->fieldSelection->getProductsFieldSelection($info); $products = $this->productDataProvider->getList($searchCriteria, $fields, $isSearch); $productArray = []; /** @var \Magento\Catalog\Model\Product $product */ @@ -79,42 +78,11 @@ public function getResult( $productArray[$product->getId()]['model'] = $product; } - return $this->searchResultFactory->create($products->getTotalCount(), $productArray); - } - - /** - * Return field names for all requested product fields. - * - * @param ResolveInfo $info - * @return string[] - */ - private function getProductFields(ResolveInfo $info) : array - { - $fieldNames = []; - foreach ($info->fieldNodes as $node) { - if ($node->name->value !== 'products') { - continue; - } - foreach ($node->selectionSet->selections as $selection) { - if ($selection->name->value !== 'items') { - continue; - } - - foreach ($selection->selectionSet->selections as $itemSelection) { - if ($itemSelection->kind === 'InlineFragment') { - foreach ($itemSelection->selectionSet->selections as $inlineSelection) { - if ($inlineSelection->kind === 'InlineFragment') { - continue; - } - $fieldNames[] = $this->fieldTranslator->translate($inlineSelection->name->value); - } - continue; - } - $fieldNames[] = $this->fieldTranslator->translate($itemSelection->name->value); - } - } - } - - return $fieldNames; + return $this->searchResultFactory->create( + [ + 'totalCount' => $products->getTotalCount(), + 'productsSearchResult' => $productArray + ] + ); } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php index bc40c664425ff..ef83cc6132ecc 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php @@ -7,12 +7,13 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Products\Query; +use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\ProductSearch; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Framework\Api\Search\SearchCriteriaInterface; -use Magento\CatalogGraphQl\Model\Resolver\Products\SearchCriteria\Helper\Filter as FilterHelper; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResult; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResultFactory; use Magento\Search\Api\SearchInterface; +use Magento\Framework\Api\Search\SearchCriteriaInterfaceFactory; /** * Full text search for catalog using given search criteria. @@ -25,52 +26,52 @@ class Search private $search; /** - * @var FilterHelper + * @var SearchResultFactory */ - private $filterHelper; + private $searchResultFactory; /** - * @var Filter + * @var \Magento\Search\Model\Search\PageSizeProvider */ - private $filterQuery; + private $pageSizeProvider; /** - * @var SearchResultFactory + * @var SearchCriteriaInterfaceFactory */ - private $searchResultFactory; + private $searchCriteriaFactory; /** - * @var \Magento\Framework\EntityManager\MetadataPool + * @var FieldSelection */ - private $metadataPool; + private $fieldSelection; /** - * @var \Magento\Search\Model\Search\PageSizeProvider + * @var ProductSearch */ - private $pageSizeProvider; + private $productsProvider; /** * @param SearchInterface $search - * @param FilterHelper $filterHelper - * @param Filter $filterQuery * @param SearchResultFactory $searchResultFactory - * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool * @param \Magento\Search\Model\Search\PageSizeProvider $pageSize + * @param SearchCriteriaInterfaceFactory $searchCriteriaFactory + * @param FieldSelection $fieldSelection + * @param ProductSearch $productsProvider */ public function __construct( SearchInterface $search, - FilterHelper $filterHelper, - Filter $filterQuery, SearchResultFactory $searchResultFactory, - \Magento\Framework\EntityManager\MetadataPool $metadataPool, - \Magento\Search\Model\Search\PageSizeProvider $pageSize + \Magento\Search\Model\Search\PageSizeProvider $pageSize, + SearchCriteriaInterfaceFactory $searchCriteriaFactory, + FieldSelection $fieldSelection, + ProductSearch $productsProvider ) { $this->search = $search; - $this->filterHelper = $filterHelper; - $this->filterQuery = $filterQuery; $this->searchResultFactory = $searchResultFactory; - $this->metadataPool = $metadataPool; $this->pageSizeProvider = $pageSize; + $this->searchCriteriaFactory = $searchCriteriaFactory; + $this->fieldSelection = $fieldSelection; + $this->productsProvider = $productsProvider; } /** @@ -81,11 +82,12 @@ public function __construct( * @return SearchResult * @throws \Exception */ - public function getResult(SearchCriteriaInterface $searchCriteria, ResolveInfo $info) : SearchResult - { - $idField = $this->metadataPool->getMetadata( - \Magento\Catalog\Api\Data\ProductInterface::class - )->getIdentifierField(); + public function getResult( + SearchCriteriaInterface $searchCriteria, + ResolveInfo $info + ): SearchResult { + $queryFields = $this->fieldSelection->getProductsFieldSelection($info); + $realPageSize = $searchCriteria->getPageSize(); $realCurrentPage = $searchCriteria->getCurrentPage(); // Current page must be set to 0 and page size to max for search to grab all ID's as temporary workaround @@ -94,64 +96,39 @@ public function getResult(SearchCriteriaInterface $searchCriteria, ResolveInfo $ $searchCriteria->setCurrentPage(0); $itemsResults = $this->search->search($searchCriteria); - $ids = []; - $searchIds = []; - foreach ($itemsResults->getItems() as $item) { - $ids[$item->getId()] = null; - $searchIds[] = $item->getId(); - } - - $filter = $this->filterHelper->generate($idField, 'in', $searchIds); - $searchCriteria = $this->filterHelper->remove($searchCriteria, 'search_term'); - $searchCriteria = $this->filterHelper->add($searchCriteria, $filter); - $searchResult = $this->filterQuery->getResult($searchCriteria, $info, true); - - $searchCriteria->setPageSize($realPageSize); - $searchCriteria->setCurrentPage($realCurrentPage); - $paginatedProducts = $this->paginateList($searchResult, $searchCriteria); - - $products = []; - if (!isset($searchCriteria->getSortOrders()[0])) { - foreach ($paginatedProducts as $product) { - if (in_array($product[$idField], $searchIds)) { - $ids[$product[$idField]] = $product; - } - } - $products = array_filter($ids); - } else { - foreach ($paginatedProducts as $product) { - $productId = isset($product['entity_id']) ? $product['entity_id'] : $product[$idField]; - if (in_array($productId, $searchIds)) { - $products[] = $product; - } - } - } + //Create copy of search criteria without conditions (conditions will be applied by joining search result) + $searchCriteriaCopy = $this->searchCriteriaFactory->create() + ->setSortOrders($searchCriteria->getSortOrders()) + ->setPageSize($realPageSize) + ->setCurrentPage($realCurrentPage); - return $this->searchResultFactory->create($searchResult->getTotalCount(), $products); - } + $searchResults = $this->productsProvider->getList($searchCriteriaCopy, $itemsResults, $queryFields); - /** - * Paginate an array of Ids that get pulled back in search based off search criteria and total count. - * - * @param SearchResult $searchResult - * @param SearchCriteriaInterface $searchCriteria - * @return int[] - */ - private function paginateList(SearchResult $searchResult, SearchCriteriaInterface $searchCriteria) : array - { - $length = $searchCriteria->getPageSize(); - // Search starts pages from 0 - $offset = $length * ($searchCriteria->getCurrentPage() - 1); - - if ($searchCriteria->getPageSize()) { - $maxPages = ceil($searchResult->getTotalCount() / $searchCriteria->getPageSize()); + //possible division by 0 + if ($realPageSize) { + $maxPages = (int)ceil($searchResults->getTotalCount() / $realPageSize); } else { $maxPages = 0; } + $searchCriteria->setPageSize($realPageSize); + $searchCriteria->setCurrentPage($realCurrentPage); - if ($searchCriteria->getCurrentPage() > $maxPages && $searchResult->getTotalCount() > 0) { - $offset = (int)$maxPages; + $productArray = []; + /** @var \Magento\Catalog\Model\Product $product */ + foreach ($searchResults->getItems() as $product) { + $productArray[$product->getId()] = $product->getData(); + $productArray[$product->getId()]['model'] = $product; } - return array_slice($searchResult->getProductsSearchResult(), $offset, $length); + + return $this->searchResultFactory->create( + [ + 'totalCount' => $searchResults->getTotalCount(), + 'productsSearchResult' => $productArray, + 'searchAggregation' => $itemsResults->getAggregations(), + 'pageSize' => $realPageSize, + 'currentPage' => $realCurrentPage, + 'totalPages' => $maxPages, + ] + ); } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php index 6e229bdc38a31..e4a137413b4c5 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php @@ -7,31 +7,21 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Products; -use Magento\Framework\Api\SearchResultsInterface; +use Magento\Framework\Api\Search\AggregationInterface; /** * Container for a product search holding the item result and the array in the GraphQL-readable product type format. */ class SearchResult { - /** - * @var SearchResultsInterface - */ - private $totalCount; - - /** - * @var array - */ - private $productsSearchResult; + private $data; /** - * @param int $totalCount - * @param array $productsSearchResult + * @param array $data */ - public function __construct(int $totalCount, array $productsSearchResult) + public function __construct(array $data) { - $this->totalCount = $totalCount; - $this->productsSearchResult = $productsSearchResult; + $this->data = $data; } /** @@ -41,7 +31,7 @@ public function __construct(int $totalCount, array $productsSearchResult) */ public function getTotalCount() : int { - return $this->totalCount; + return $this->data['totalCount'] ?? 0; } /** @@ -51,6 +41,46 @@ public function getTotalCount() : int */ public function getProductsSearchResult() : array { - return $this->productsSearchResult; + return $this->data['productsSearchResult'] ?? []; + } + + /** + * Retrieve aggregated search results + * + * @return AggregationInterface|null + */ + public function getSearchAggregation(): ?AggregationInterface + { + return $this->data['searchAggregation'] ?? null; + } + + /** + * Retrieve the page size for the search + * + * @return int + */ + public function getPageSize(): int + { + return $this->data['pageSize'] ?? 0; + } + + /** + * Retrieve the current page for the search + * + * @return int + */ + public function getCurrentPage(): int + { + return $this->data['currentPage'] ?? 0; + } + + /** + * Retrieve total pages for the search + * + * @return int + */ + public function getTotalPages(): int + { + return $this->data['totalPages'] ?? 0; } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php index aec9362f47c3a..479e6a3f96235 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php @@ -30,15 +30,15 @@ public function __construct(ObjectManagerInterface $objectManager) /** * Instantiate SearchResult * - * @param int $totalCount - * @param array $productsSearchResult + * @param array $data * @return SearchResult */ - public function create(int $totalCount, array $productsSearchResult) : SearchResult - { + public function create( + array $data + ): SearchResult { return $this->objectManager->create( SearchResult::class, - ['totalCount' => $totalCount, 'productsSearchResult' => $productsSearchResult] + ['data' => $data] ); } } diff --git a/app/code/Magento/CatalogGraphQl/Plugin/Search/Request/ConfigReader.php b/app/code/Magento/CatalogGraphQl/Plugin/Search/Request/ConfigReader.php new file mode 100644 index 0000000000000..992ab50467c72 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Plugin/Search/Request/ConfigReader.php @@ -0,0 +1,286 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\CatalogGraphQl\Plugin\Search\Request; + +use Magento\Catalog\Api\Data\EavAttributeInterface; +use Magento\CatalogSearch\Model\Search\RequestGenerator; +use Magento\CatalogSearch\Model\Search\RequestGenerator\GeneratorResolver; +use Magento\Eav\Model\Entity\Attribute; +use Magento\Framework\Search\Request\FilterInterface; +use Magento\Framework\Search\Request\QueryInterface; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\Collection; + +/** + * Add search request configuration to config for give ability filter and search products during GraphQL request + * Add 2 request name with and without aggregation correspondingly: + * - graphql_product_search_with_aggregation + * - graphql_product_search + * + * @see Magento/CatalogGraphQl/etc/search_request.xml + */ +class ConfigReader +{ + /** Bucket name suffix */ + private const BUCKET_SUFFIX = '_bucket'; + + /** + * @var string + */ + private $requestNameWithAggregation = 'graphql_product_search_with_aggregation'; + + /** + * @var string + */ + private $requestName = 'graphql_product_search'; + + /** + * @var GeneratorResolver + */ + private $generatorResolver; + + /** + * @var CollectionFactory + */ + private $productAttributeCollectionFactory; + + /** + * @var array + */ + private $exactMatchAttributes = []; + + /** + * @param GeneratorResolver $generatorResolver + * @param CollectionFactory $productAttributeCollectionFactory + * @param array $exactMatchAttributes + */ + public function __construct( + GeneratorResolver $generatorResolver, + CollectionFactory $productAttributeCollectionFactory, + array $exactMatchAttributes = [] + ) { + $this->generatorResolver = $generatorResolver; + $this->productAttributeCollectionFactory = $productAttributeCollectionFactory; + $this->exactMatchAttributes = array_merge($this->exactMatchAttributes, $exactMatchAttributes); + } + + /** + * Merge reader's value with generated + * + * @param \Magento\Framework\Config\ReaderInterface $subject + * @param array $result + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterRead( + \Magento\Framework\Config\ReaderInterface $subject, + array $result + ) { + $searchRequestNameWithAggregation = $this->generateRequest(); + $searchRequest = $searchRequestNameWithAggregation; + $searchRequest['queries'][$this->requestName] = $searchRequest['queries'][$this->requestNameWithAggregation]; + unset($searchRequest['queries'][$this->requestNameWithAggregation], $searchRequest['aggregations']); + + return array_merge_recursive( + $result, + [ + $this->requestNameWithAggregation => $searchRequestNameWithAggregation, + $this->requestName => $searchRequest, + ] + ); + } + + /** + * Retrieve searchable attributes + * + * @return Attribute[] + */ + private function getSearchableAttributes(): array + { + $attributes = []; + /** @var Collection $productAttributes */ + $productAttributes = $this->productAttributeCollectionFactory->create(); + $productAttributes->addFieldToFilter( + ['is_searchable', 'is_visible_in_advanced_search', 'is_filterable', 'is_filterable_in_search'], + [1, 1, [1, 2], 1] + ); + + /** @var Attribute $attribute */ + foreach ($productAttributes->getItems() as $attribute) { + $attributes[$attribute->getAttributeCode()] = $attribute; + } + + return $attributes; + } + + /** + * Generate search request for search products via GraphQL + * + * @return array + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + private function generateRequest() + { + $request = []; + foreach ($this->getSearchableAttributes() as $attribute) { + if (\in_array($attribute->getAttributeCode(), ['price', 'visibility', 'category_ids'])) { + //some fields have special semantics + continue; + } + $queryName = $attribute->getAttributeCode() . '_query'; + $filterName = $attribute->getAttributeCode() . RequestGenerator::FILTER_SUFFIX; + $request['queries'][$this->requestNameWithAggregation]['queryReference'][] = [ + 'clause' => 'must', + 'ref' => $queryName, + ]; + + switch ($attribute->getBackendType()) { + case 'static': + case 'text': + case 'varchar': + if ($this->isExactMatchAttribute($attribute)) { + $request['queries'][$queryName] = $this->generateFilterQuery($queryName, $filterName); + $request['filters'][$filterName] = $this->generateTermFilter($filterName, $attribute); + } else { + $request['queries'][$queryName] = $this->generateMatchQuery($queryName, $attribute); + } + break; + case 'decimal': + case 'datetime': + case 'date': + $request['queries'][$queryName] = $this->generateFilterQuery($queryName, $filterName); + $request['filters'][$filterName] = $this->generateRangeFilter($filterName, $attribute); + break; + default: + $request['queries'][$queryName] = $this->generateFilterQuery($queryName, $filterName); + $request['filters'][$filterName] = $this->generateTermFilter($filterName, $attribute); + } + $generator = $this->generatorResolver->getGeneratorForType($attribute->getBackendType()); + + if ($attribute->getData(EavAttributeInterface::IS_FILTERABLE)) { + $bucketName = $attribute->getAttributeCode() . self::BUCKET_SUFFIX; + $request['aggregations'][$bucketName] = $generator->getAggregationData($attribute, $bucketName); + } + + $this->addSearchAttributeToFullTextSearch($attribute, $request); + } + + return $request; + } + + /** + * Add attribute with specified boost to "search" query used in full text search + * + * @param Attribute $attribute + * @param array $request + * @return void + */ + private function addSearchAttributeToFullTextSearch(Attribute $attribute, &$request): void + { + // Match search by custom price attribute isn't supported + if ($attribute->getFrontendInput() !== 'price') { + $request['queries']['search']['match'][] = [ + 'field' => $attribute->getAttributeCode(), + 'boost' => $attribute->getSearchWeight() ?: 1, + ]; + } + } + + /** + * Return array representation of range filter + * + * @param string $filterName + * @param Attribute $attribute + * @return array + */ + private function generateRangeFilter(string $filterName, Attribute $attribute) + { + return [ + 'field' => $attribute->getAttributeCode(), + 'name' => $filterName, + 'type' => FilterInterface::TYPE_RANGE, + 'from' => '$' . $attribute->getAttributeCode() . '.from$', + 'to' => '$' . $attribute->getAttributeCode() . '.to$', + ]; + } + + /** + * Return array representation of term filter + * + * @param string $filterName + * @param Attribute $attribute + * @return array + */ + private function generateTermFilter(string $filterName, Attribute $attribute) + { + return [ + 'type' => FilterInterface::TYPE_TERM, + 'name' => $filterName, + 'field' => $attribute->getAttributeCode(), + 'value' => '$' . $attribute->getAttributeCode() . '$', + ]; + } + + /** + * Return array representation of query based on filter + * + * @param string $queryName + * @param string $filterName + * @return array + */ + private function generateFilterQuery(string $queryName, string $filterName) + { + return [ + 'name' => $queryName, + 'type' => QueryInterface::TYPE_FILTER, + 'filterReference' => [ + [ + 'ref' => $filterName, + ], + ], + ]; + } + + /** + * Return array representation of match query + * + * @param string $queryName + * @param Attribute $attribute + * @return array + */ + private function generateMatchQuery(string $queryName, Attribute $attribute) + { + return [ + 'name' => $queryName, + 'type' => 'matchQuery', + 'value' => '$' . $attribute->getAttributeCode() . '$', + 'match' => [ + [ + 'field' => $attribute->getAttributeCode(), + 'boost' => $attribute->getSearchWeight() ?: 1, + ], + ], + ]; + } + + /** + * Check if attribute's filter should use exact match + * + * @param Attribute $attribute + * @return bool + */ + private function isExactMatchAttribute(Attribute $attribute) + { + if (in_array($attribute->getFrontendInput(), ['select', 'multiselect'])) { + return true; + } + if (in_array($attribute->getAttributeCode(), $this->exactMatchAttributes)) { + return true; + } + + return false; + } +} diff --git a/app/code/Magento/CatalogGraphQl/composer.json b/app/code/Magento/CatalogGraphQl/composer.json index 13fcbe9a7d357..1582f29c25951 100644 --- a/app/code/Magento/CatalogGraphQl/composer.json +++ b/app/code/Magento/CatalogGraphQl/composer.json @@ -10,6 +10,7 @@ "magento/module-search": "*", "magento/module-store": "*", "magento/module-eav-graph-ql": "*", + "magento/module-catalog-search": "*", "magento/framework": "*" }, "suggest": { diff --git a/app/code/Magento/CatalogGraphQl/etc/di.xml b/app/code/Magento/CatalogGraphQl/etc/di.xml index a5006355ed265..485ae792193e3 100644 --- a/app/code/Magento/CatalogGraphQl/etc/di.xml +++ b/app/code/Magento/CatalogGraphQl/etc/di.xml @@ -19,6 +19,8 @@ <argument name="readers" xsi:type="array"> <item name="productDynamicAttributeReader" xsi:type="object">Magento\CatalogGraphQl\Model\Config\AttributeReader</item> <item name="categoryDynamicAttributeReader" xsi:type="object">Magento\CatalogGraphQl\Model\Config\CategoryAttributeReader</item> + <item name="productSortDynamicAttributeReader" xsi:type="object">Magento\CatalogGraphQl\Model\Config\SortAttributeReader</item> + <item name="productFilterDynamicAttributeReader" xsi:type="object">Magento\CatalogGraphQl\Model\Config\FilterAttributeReader</item> </argument> </arguments> </virtualType> @@ -55,4 +57,16 @@ <argument name="searchCriteriaApplier" xsi:type="object">Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor</argument> </arguments> </type> + + <type name="Magento\CatalogGraphQl\Plugin\Search\Request\ConfigReader"> + <arguments> + <argument name="exactMatchAttributes" xsi:type="array"> + <item name="sku" xsi:type="string">sku</item> + </argument> + </arguments> + </type> + + <type name="Magento\Framework\Search\Request\Config\FilesystemReader"> + <plugin name="productAttributesDynamicFields" type="Magento\CatalogGraphQl\Plugin\Search\Request\ConfigReader" /> + </type> </config> diff --git a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml index 2292004f3cf01..fe3413dc3b218 100644 --- a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml @@ -28,6 +28,13 @@ </argument> </arguments> </type> + <type name="Magento\CatalogGraphQl\Model\AggregationOptionTypeResolverComposite"> + <arguments> + <argument name="typeResolvers" xsi:type="array"> + <item name="aggregation_option" xsi:type="object">Magento\CatalogGraphQl\Model\AggregationOptionTypeResolver</item> + </argument> + </arguments> + </type> <type name="Magento\Framework\GraphQl\Schema\Type\Entity\DefaultMapper"> <arguments> <argument name="map" xsi:type="array"> @@ -48,6 +55,12 @@ <item name="radio" xsi:type="string">CustomizableRadioOption</item> <item name="checkbox" xsi:type="string">CustomizableCheckboxOption</item> </item> + <item name="sort_attributes" xsi:type="array"> + <item name="product_sort_attributes" xsi:type="string">ProductAttributeSortInput</item> + </item> + <item name="filter_attributes" xsi:type="array"> + <item name="product_filter_attributes" xsi:type="string">ProductAttributeFilterInput</item> + </item> </argument> </arguments> </type> @@ -95,4 +108,14 @@ </argument> </arguments> </type> + + <type name="Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilder"> + <arguments> + <argument name="builders" xsi:type="array"> + <item name="price_bucket" xsi:type="object">Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Price</item> + <item name="category_bucket" xsi:type="object">Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Category</item> + <item name="attribute_bucket" xsi:type="object">Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Attribute</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls index d8a3b5e744d17..140aab37c9ceb 100644 --- a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls @@ -4,10 +4,10 @@ type Query { products ( search: String @doc(description: "Performs a full-text search using the specified key words."), - filter: ProductFilterInput @doc(description: "Identifies which product attributes to search for and return."), + filter: ProductAttributeFilterInput @doc(description: "Identifies which product attributes to search for and return."), 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.") + sort: ProductAttributeSortInput @doc(description: "Specifies which attributes 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.") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Identity") category ( @@ -58,13 +58,6 @@ interface ProductLinksInterface @typeResolver(class: "Magento\\CatalogGraphQl\\M position: Int @doc(description: "The position within the list of product links.") } -type ProductTierPrices @doc(description: "The ProductTierPrices object defines a tier price, which is a quantity discount offered to a specific customer group.") { - customer_group_id: String @doc(description: "The ID of the customer group.") - qty: Float @doc(description: "The number of items that must be purchased to qualify for tier pricing.") - value: Float @doc(description: "The price of the fixed price item.") - percentage_value: Float @doc(description: "The percentage discount of the item.") - website_id: Float @doc(description: "The ID assigned to the website.") -} interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "The ProductInterface contains attributes that are common to all types of products. Note that descriptions may not be available for custom and EAV attributes.") { id: Int @doc(description: "The ID number assigned to the product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\EntityIdToId") @@ -93,7 +86,6 @@ interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\ websites: [Website] @doc(description: "An array of websites in which the product is available.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Websites") product_links: [ProductLinksInterface] @doc(description: "An array of ProductLinks objects.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductLinks") media_gallery_entries: [MediaGalleryEntry] @deprecated(reason: "Use product's `media_gallery` instead") @doc(description: "An array of MediaGalleryEntry objects.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\MediaGalleryEntries") - tier_prices: [ProductTierPrices] @doc(description: "An array of ProductTierPrices objects.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\TierPrices") 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.") @@ -221,7 +213,7 @@ interface CategoryInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model 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.") + sort: ProductAttributeSortInput @doc(description: "Specifies which attributes 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.") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Identity") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Products") breadcrumbs: [Breadcrumb] @doc(description: "Breadcrumbs, parent categories info.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Breadcrumbs") } @@ -270,7 +262,8 @@ type Products @doc(description: "The Products object is the top-level object ret items: [ProductInterface] @doc(description: "An array of products that match the specified search criteria.") 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] @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\LayerFilters") @doc(description: "Layered navigation filters array.") + filters: [LayerFilter] @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\LayerFilters") @doc(description: "Layered navigation filters array.") @deprecated(reason: "Use aggregations instead") + aggregations: [Aggregation] @doc(description: "Layered navigation aggregations.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Aggregations") 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") } @@ -280,7 +273,11 @@ type CategoryProducts @doc(description: "The category products object returned i 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.") { +input ProductAttributeFilterInput @doc(description: "ProductAttributeFilterInput 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.") { + category_id: FilterEqualTypeInput @doc(description: "Filter product by category id") +} + +input ProductFilterInput @doc(description: "ProductFilterInput is deprecated, use @ProductAttributeFilterInput instead. 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.") { name: FilterTypeInput @doc(description: "The product name. Customers use this name to identify the product.") sku: FilterTypeInput @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.") description: FilterTypeInput @doc(description: "Detailed information about the product. The value can include simple HTML tags.") @@ -333,7 +330,7 @@ type ProductMediaGalleryEntriesVideoContent @doc(description: "ProductMediaGalle video_metadata: String @doc(description: "Optional data about the video.") } -input ProductSortInput @doc(description: "ProductSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order.") { +input ProductSortInput @doc(description: "ProductSortInput is deprecated, use @ProductAttributeSortInput instead. ProductSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order.") { name: SortEnum @doc(description: "The product name. Customers use this name to identify the product.") sku: SortEnum @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.") description: SortEnum @doc(description: "Detailed information about the product. The value can include simple HTML tags.") @@ -367,6 +364,12 @@ input ProductSortInput @doc(description: "ProductSortInput specifies the attribu gift_message_available: SortEnum @doc(description: "Indicates whether a gift message is available.") } +input ProductAttributeSortInput @doc(description: "ProductAttributeSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order. It's possible to sort products using searchable attributes with enabled 'Use in Filter Options' option") +{ + relevance: SortEnum @doc(description: "Sort by the search relevance score (default).") + position: SortEnum @doc(description: "Sort by the position assigned to each product.") +} + type MediaGalleryEntry @doc(description: "MediaGalleryEntry defines characteristics about images and videos associated with a specific product.") { id: Int @doc(description: "The identifier assigned to the object.") media_type: String @doc(description: "image or video.") @@ -380,22 +383,39 @@ type MediaGalleryEntry @doc(description: "MediaGalleryEntry defines characterist } type LayerFilter { - name: String @doc(description: "Layered navigation filter name.") - request_var: String @doc(description: "Request variable name for filter query.") - filter_items_count: Int @doc(description: "Count of filter items in filter group.") - filter_items: [LayerFilterItemInterface] @doc(description: "Array of filter items.") + name: String @doc(description: "Layered navigation filter name.") @deprecated(reason: "Use Aggregation.label instead.") + request_var: String @doc(description: "Request variable name for filter query.") @deprecated(reason: "Use Aggregation.attribute_code instead.") + filter_items_count: Int @doc(description: "Count of filter items in filter group.") @deprecated(reason: "Use Aggregation.count instead.") + filter_items: [LayerFilterItemInterface] @doc(description: "Array of filter items.") @deprecated(reason: "Use Aggregation.options instead.") } interface LayerFilterItemInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\LayerFilterItemTypeResolverComposite") { - label: String @doc(description: "Filter label.") - value_string: String @doc(description: "Value for filter request variable to be used in query.") - items_count: Int @doc(description: "Count of items by filter.") + label: String @doc(description: "Filter label.") @deprecated(reason: "Use AggregationOption.label instead.") + value_string: String @doc(description: "Value for filter request variable to be used in query.") @deprecated(reason: "Use AggregationOption.value instead.") + items_count: Int @doc(description: "Count of items by filter.") @deprecated(reason: "Use AggregationOption.count instead.") } type LayerFilterItem implements LayerFilterItemInterface { } +type Aggregation @doc(description: "A bucket that contains information for each filterable option (such as price, category ID, and custom attributes).") { + count: Int @doc(description: "The number of options in the aggregation group.") + label: String @doc(description: "The aggregation display name.") + attribute_code: String! @doc(description: "Attribute code of the aggregation group.") + options: [AggregationOption] @doc(description: "Array of options for the aggregation.") +} + +interface AggregationOptionInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\AggregationOptionTypeResolverComposite") { + count: Int @doc(description: "The number of items that match the aggregation option.") + label: String @doc(description: "Aggregation option display label.") + value: String! @doc(description: "The internal ID that represents the value of the option.") +} + +type AggregationOption implements AggregationOptionInterface { + +} + type SortField { value: String @doc(description: "Attribute code of sort field.") label: String @doc(description: "Label of sort field.") diff --git a/app/code/Magento/CatalogGraphQl/etc/search_request.xml b/app/code/Magento/CatalogGraphQl/etc/search_request.xml new file mode 100644 index 0000000000000..ab1eea9eb6fda --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/etc/search_request.xml @@ -0,0 +1,90 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<requests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:Search/etc/search_request.xsd"> + <!-- Request schema for product search including aggregation --> + <request query="graphql_product_search_with_aggregation" index="catalogsearch_fulltext"> + <dimensions> + <dimension name="scope" value="default"/> + </dimensions> + <queries> + <query xsi:type="boolQuery" name="graphql_product_search_with_aggregation" boost="1"> + <queryReference clause="should" ref="search" /> + <queryReference clause="must" ref="category"/> + <queryReference clause="must" ref="price"/> + <queryReference clause="must" ref="visibility"/> + </query> + <query xsi:type="matchQuery" value="$search_term$" name="search"> + <match field="sku"/> + <match field="*"/> + </query> + <query name="category" xsi:type="filteredQuery"> + <filterReference clause="must" ref="category_filter"/> + </query> + <query name="price" xsi:type="filteredQuery"> + <filterReference clause="must" ref="price_filter"/> + </query> + <query name="visibility" xsi:type="filteredQuery"> + <filterReference clause="must" ref="visibility_filter"/> + </query> + </queries> + <filters> + <filter xsi:type="termFilter" name="category_filter" field="category_ids" value="$category_id$"/> + <filter xsi:type="rangeFilter" name="price_filter" field="price" from="$price.from$" to="$price.to$"/> + <filter xsi:type="termFilter" name="visibility_filter" field="visibility" value="$visibility$"/> + </filters> + <aggregations> + <bucket name="price_bucket" field="price" xsi:type="dynamicBucket" method="$price_dynamic_algorithm$"> + <metrics> + <metric type="count"/> + </metrics> + </bucket> + <bucket name="category_bucket" field="category_ids" xsi:type="termBucket"> + <metrics> + <metric type="count"/> + </metrics> + </bucket> + </aggregations> + <from>0</from> + <size>10000</size> + </request> + <!-- Request schema for product search excluding aggregation --> + <request query="graphql_product_search" index="catalogsearch_fulltext"> + <dimensions> + <dimension name="scope" value="default"/> + </dimensions> + <queries> + <query xsi:type="boolQuery" name="graphql_product_search" boost="1"> + <queryReference clause="should" ref="search" /> + <queryReference clause="must" ref="category"/> + <queryReference clause="must" ref="price"/> + <queryReference clause="must" ref="visibility"/> + </query> + <query xsi:type="matchQuery" value="$search_term$" name="search"> + <match field="sku"/> + <match field="*"/> + </query> + <query name="category" xsi:type="filteredQuery"> + <filterReference clause="must" ref="category_filter"/> + </query> + <query name="price" xsi:type="filteredQuery"> + <filterReference clause="must" ref="price_filter"/> + </query> + <query name="visibility" xsi:type="filteredQuery"> + <filterReference clause="must" ref="visibility_filter"/> + </query> + </queries> + <filters> + <filter xsi:type="termFilter" name="category_filter" field="category_ids" value="$category_id$"/> + <filter xsi:type="rangeFilter" name="price_filter" field="price" from="$price.from$" to="$price.to$"/> + <filter xsi:type="termFilter" name="visibility_filter" field="visibility" value="$visibility$"/> + </filters> + <from>0</from> + <size>10000</size> + </request> +</requests> diff --git a/app/code/Magento/CatalogImportExport/Model/Export/Product.php b/app/code/Magento/CatalogImportExport/Model/Export/Product.php index 428c61c7fec0f..5baa4b4274be5 100644 --- a/app/code/Magento/CatalogImportExport/Model/Export/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Export/Product.php @@ -484,7 +484,9 @@ protected function initTypeModels() } if ($model->isSuitable()) { $this->_productTypeModels[$productTypeName] = $model; + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $this->_disabledAttrs = array_merge($this->_disabledAttrs, $model->getDisabledAttrs()); + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $this->_indexValueAttributes = array_merge( $this->_indexValueAttributes, $model->getIndexValueAttributes() @@ -526,7 +528,7 @@ protected function getMediaGallery(array $productIds) if (empty($productIds)) { return []; } - + $productEntityJoinField = $this->getProductEntityLinkField(); $select = $this->_connection->select()->from( @@ -710,6 +712,21 @@ public function _getHeaderColumns() return $this->_customHeadersMapping($this->rowCustomizer->addHeaderColumns($this->_headerColumns)); } + /** + * Return non-system attributes + + * @return array + */ + private function getNonSystemAttributes(): array + { + $attrKeys = []; + foreach ($this->filterAttributeCollection($this->getAttributeCollection()) as $attribute) { + $attrKeys[] = $attribute->getAttributeCode(); + } + + return array_diff($this->_getExportMainAttrCodes(), $this->_customHeadersMapping($attrKeys)); + } + /** * Set headers columns * @@ -722,6 +739,18 @@ public function _getHeaderColumns() */ protected function setHeaderColumns($customOptionsData, $stockItemRows) { + $exportAttributes = ( + array_key_exists("skip_attr", $this->_parameters) && count($this->_parameters["skip_attr"]) + ) ? + array_intersect( + $this->_getExportMainAttrCodes(), + array_merge( + $this->_customHeadersMapping($this->_getExportAttrCodes()), + $this->getNonSystemAttributes() + ) + ) : + $this->_getExportMainAttrCodes(); + if (!$this->_headerColumns) { $this->_headerColumns = array_merge( [ @@ -732,7 +761,7 @@ protected function setHeaderColumns($customOptionsData, $stockItemRows) self::COL_CATEGORY, self::COL_PRODUCT_WEBSITES, ], - $this->_getExportMainAttrCodes(), + $exportAttributes, [self::COL_ADDITIONAL_ATTRIBUTES], reset($stockItemRows) ? array_keys(end($stockItemRows)) : [], [ @@ -923,6 +952,7 @@ protected function getExportData() foreach ($rawData as $productId => $productData) { foreach ($productData as $storeId => $dataRow) { if ($storeId == Store::DEFAULT_STORE_ID && isset($stockItemRows[$productId])) { + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $dataRow = array_merge($dataRow, $stockItemRows[$productId]); } $this->appendMultirowData($dataRow, $multirawData); @@ -1330,7 +1360,7 @@ private function appendMultirowData(&$dataRow, $multiRawData) $dataRow[self::COL_SKU] = $sku; $dataRow[self::COL_ATTR_SET] = $attributeSet; $dataRow[self::COL_TYPE] = $type; - + return $dataRow; } diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index 4ff995c2a872c..1ae993ed99060 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -141,7 +141,7 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity const COL_PRODUCT_WEBSITES = '_product_websites'; /** - * Media gallery attribute code. + * Attribute code for media gallery. */ const MEDIA_GALLERY_ATTRIBUTE_CODE = 'media_gallery'; @@ -151,12 +151,12 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity const COL_MEDIA_IMAGE = '_media_image'; /** - * Inventory use config. + * Inventory use config label. */ const INVENTORY_USE_CONFIG = 'Use Config'; /** - * Inventory use config prefix. + * Prefix for inventory use config. */ const INVENTORY_USE_CONFIG_PREFIX = 'use_config_'; @@ -1886,6 +1886,7 @@ protected function _saveProducts() return $this; } + //phpcs:enable Generic.Metrics.NestingLevel /** * Prepare array with image states (visible or hidden from product page) @@ -2736,8 +2737,6 @@ protected function _saveValidatedBunches() try { $rowData = $source->current(); } catch (\InvalidArgumentException $e) { - $this->addRowError($e->getMessage(), $this->_processedRowsCount); - $this->_processedRowsCount++; $source->next(); continue; } 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 3b6caef66ce6c..d87c3d8477556 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php @@ -13,6 +13,7 @@ /** * Import entity abstract product type model * + * phpcs:disable Magento2.Classes.AbstractApi * @api * * @SuppressWarnings(PHPMD.TooManyFields) @@ -543,7 +544,7 @@ public function prepareAttributesWithDefaultValueForSave(array $rowData, $withDe } else { $resultAttrs[$attrCode] = $rowData[$attrCode]; } - } elseif (array_key_exists($attrCode, $rowData) && empty($rowData['_store'])) { + } elseif (array_key_exists($attrCode, $rowData)) { $resultAttrs[$attrCode] = $rowData[$attrCode]; } elseif ($withDefaultValue && null !== $attrParams['default_value'] && empty($rowData['_store'])) { $resultAttrs[$attrCode] = $attrParams['default_value']; diff --git a/app/code/Magento/CatalogImportExport/Test/Mftf/Test/AdminExportImportConfigurableProductWithImagesTest.xml b/app/code/Magento/CatalogImportExport/Test/Mftf/Test/AdminExportImportConfigurableProductWithImagesTest.xml new file mode 100644 index 0000000000000..88f6e6c9f9039 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Test/Mftf/Test/AdminExportImportConfigurableProductWithImagesTest.xml @@ -0,0 +1,216 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminExportImportConfigurableProductWithImagesTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="Export/Import products"/> + <title value="Check importing of configurable products with images present in filesystem"/> + <description value="Check importing of configurable products with images present in filesystem"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-11557"/> + <group value="configurable_product"/> + </annotations> + <before> + <!-- Create sample data: + 1. Create simple products --> + <createData entity="ApiCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct2" stepKey="createFirstSimpleProduct"/> + <createData entity="SimpleProduct2" stepKey="createSecondSimpleProduct"/> + + <!-- 2. Create Downloadable product --> + <createData entity="ApiDownloadableProduct" stepKey="createDownloadableProduct"/> + <createData entity="ApiDownloadableLink" stepKey="addFirstDownloadableLink"> + <requiredEntity createDataKey="createDownloadableProduct"/> + </createData> + <createData entity="ApiDownloadableLink" stepKey="addSecondDownloadableLink"> + <requiredEntity createDataKey="createDownloadableProduct"/> + </createData> + + <!-- 3. Create Grouped product --> + <createData entity="ApiGroupedProduct" stepKey="createGroupedProduct"/> + <createData entity="OneSimpleProductLink" stepKey="addProductOne"> + <requiredEntity createDataKey="createGroupedProduct"/> + <requiredEntity createDataKey="createFirstSimpleProduct"/> + </createData> + + <!-- 4. Create configurable product with images --> + <createData entity="CategoryExportImport" stepKey="createExportImportCategory"/> + <createData entity="ApiConfigurableExportImportProduct" stepKey="createExportImportConfigurableProduct"> + <requiredEntity createDataKey="createExportImportCategory"/> + </createData> + <createData entity="ApiProductAttributeMediaGalleryForExportImport" stepKey="createConfigurableProductWithImage"> + <requiredEntity createDataKey="createExportImportConfigurableProduct"/> + </createData> + <createData entity="ProductAttributeWithTwoOptionsForExportImport" stepKey="createExportImportConfigurableProductAttribute"/> + <createData entity="ProductAttributeOptionOneForExportImport" stepKey="createExportImportConfigurableProductAttributeFirstOption"> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + </createData> + <createData entity="ProductAttributeOptionTwoForExportImport" stepKey="createExportImportConfigurableProductAttributeSecondOption"> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="createConfigAddToAttributeSet"> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + </createData> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getConfigAttributeFirstOption"> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + </getData> + <getData entity="ProductAttributeOptionGetter" index="2" stepKey="getConfigAttributeSecondOption"> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + </getData> + <createData entity="ApiSimpleOneExportImport" stepKey="createConfigFirstChildProduct"> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeFirstOption"/> + </createData> + <createData entity="ApiProductAttributeMediaGalleryForExportImport" stepKey="addImageForFirstSimpleProduct"> + <requiredEntity createDataKey="createConfigFirstChildProduct"/> + </createData> + <createData entity="ApiSimpleTwoExportImport" stepKey="createConfigSecondChildProduct"> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeSecondOption"/> + </createData> + <createData entity="ApiProductAttributeMediaGalleryEntryTestImage" stepKey="addImageForSecondSimpleProduct"> + <requiredEntity createDataKey="createConfigSecondChildProduct"/> + </createData> + <createData entity="ConfigurableProductTwoOptions" stepKey="createExportImportConfigurableProductTwoOption"> + <requiredEntity createDataKey="createExportImportConfigurableProduct"/> + <requiredEntity createDataKey="createExportImportConfigurableProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeFirstOption"/> + <requiredEntity createDataKey="getConfigAttributeSecondOption"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="addFirstExportImportConfigurableProductChild"> + <requiredEntity createDataKey="createExportImportConfigurableProduct"/> + <requiredEntity createDataKey="createConfigFirstChildProduct"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="addSecondExportImportConfigurableProductChild"> + <requiredEntity createDataKey="createExportImportConfigurableProduct"/> + <requiredEntity createDataKey="createConfigSecondChildProduct"/> + </createData> + + <!-- 5. Create configurable product --> + <createData entity="ApiConfigurableProduct" stepKey="createConfigurableProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="productAttributeWithTwoOptions" stepKey="createConfigProductAttr"/> + <createData entity="productAttributeOption1" stepKey="createConfigProductAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttr"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="createConfigAddToAttrSet"> + <requiredEntity createDataKey="createConfigProductAttr"/> + </createData> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getConfigAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttr"/> + </getData> + <createData entity="ApiSimpleOne" stepKey="createConfigChildProduct"> + <requiredEntity createDataKey="createConfigProductAttr"/> + <requiredEntity createDataKey="getConfigAttributeOption"/> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ConfigurableProductTwoOptions" stepKey="createConfigProductOption"> + <requiredEntity createDataKey="createConfigurableProduct"/> + <requiredEntity createDataKey="createConfigProductAttr"/> + <requiredEntity createDataKey="getConfigAttributeOption"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="addConfigurableProductChild"> + <requiredEntity createDataKey="createConfigurableProduct"/> + <requiredEntity createDataKey="createConfigChildProduct"/> + </createData> + + <!-- Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <actionGroup ref="deleteAllExportedFiles" stepKey="clearExportedFilesList"/> + </before> + <after> + <!-- Delete created data --> + <deleteData createDataKey="createFirstSimpleProduct" stepKey="deleteFisrtSimpleProduct"/> + <deleteData createDataKey="createSecondSimpleProduct" stepKey="deleteSecondSimpleProduct"/> + <deleteData createDataKey="createDownloadableProduct" stepKey="deleteDownloadableProduct"/> + <deleteData createDataKey="createGroupedProduct" stepKey="deleteGroupedProduct"/> + <deleteData createDataKey="createExportImportConfigurableProduct" stepKey="deleteConfigProduct"/> + <deleteData createDataKey="createConfigFirstChildProduct" stepKey="deleteConfigFirstChildProduct"/> + <deleteData createDataKey="createConfigSecondChildProduct" stepKey="deleteConfigSecondChildProduct"/> + <deleteData createDataKey="createExportImportConfigurableProductAttribute" stepKey="deleteConfigProductAttribute"/> + <deleteData createDataKey="createConfigurableProduct" stepKey="deleteConfigurableProduct"/> + <deleteData createDataKey="createConfigChildProduct" stepKey="deleteConfigChildProduct"/> + <deleteData createDataKey="createConfigProductAttr" stepKey="deleteConfigProductAttr"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createExportImportCategory" stepKey="deleteExportImportCategory"/> + + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndex"/> + <actionGroup ref="resetProductGridToDefaultView" stepKey="resetProductGridColumnsInitial"/> + <!-- Admin logout--> + <actionGroup ref="logout" stepKey="adminLogout"/> + </after> + + <!-- Go to System > Export --> + <amOnPage url="{{AdminExportIndexPage.url}}" stepKey="goToExportIndexPage"/> + + <!-- Set Export Settings: Entity Type > Products, SKU > ConfProd's sku and press "Continue" --> + <actionGroup ref="exportProductsFilterByAttribute" stepKey="exportProductBySku"> + <argument name="attribute" value="sku"/> + <argument name="attributeData" value="$$createExportImportConfigurableProduct.sku$$"/> + </actionGroup> + + <!-- Run cron twice --> + <magentoCLI command="cron:run" stepKey="runCronFirstTime"/> + <magentoCLI command="cron:run" stepKey="runCronSecondTime"/> + + <!-- Save exported file: file successfully downloaded --> + <actionGroup ref="downloadFileByRowIndex" stepKey="downloadCreatedProducts"> + <argument name="rowIndex" value="0"/> + </actionGroup> + + <!-- Go to Catalog > Products. Find ConfProd and delete it --> + <actionGroup ref="deleteProductBySku" stepKey="deleteConfigurableProductBySku"> + <argument name="sku" value="$$createExportImportConfigurableProduct.sku$$"/> + </actionGroup> + + <!-- Go to System > Import. Set import settings: Entity Type > Product, Import Behavior > Add/Update, + Select File to Import > previously exported file and press "Check Data" --> + <actionGroup ref="AdminImportProductsActionGroup" stepKey="adminImportProduct"> + <argument name="behavior" value="Add/Update"/> + <argument name="importFile" value="export_import_configurable_product.csv"/> + <argument name="importNoticeMessage" value="Created: 1, Updated: 0, Deleted: 0"/> + </actionGroup> + + <!-- Go to Catalog > Products: Configurable product exists --> + <actionGroup ref="filterAndSelectProduct" stepKey="openConfigurableProduct"> + <argument name="productSku" value="$$createExportImportConfigurableProduct.sku$$"/> + </actionGroup> + + <!-- Go to "Configurations" section: configurations exist and have images --> + <seeNumberOfElements selector="{{AdminProductFormConfigurationsSection.currentVariationsRows}}" userInput="2" stepKey="seeNumberOfRows"/> + <see selector="{{AdminProductFormConfigurationsSection.currentVariationsNameCells}}" userInput="$$createConfigFirstChildProduct.name$$" stepKey="seeFirstProductNameInField"/> + <see selector="{{AdminProductFormConfigurationsSection.currentVariationsNameCells}}" userInput="$$createConfigSecondChildProduct.name$$" stepKey="seeSecondProductNameInField"/> + <see selector="{{AdminProductFormConfigurationsSection.currentVariationsSkuCells}}" userInput="$$createConfigFirstChildProduct.sku$$" stepKey="seeFirstProductSkuInField"/> + <see selector="{{AdminProductFormConfigurationsSection.currentVariationsSkuCells}}" userInput="$$createConfigSecondChildProduct.sku$$" stepKey="seeSecondProductSkuInField"/> + <see selector="{{AdminProductFormConfigurationsSection.currentVariationsPriceCells}}" userInput="$$createConfigFirstChildProduct.price$$" stepKey="seeFirstProductPriceInField"/> + <see selector="{{AdminProductFormConfigurationsSection.currentVariationsPriceCells}}" userInput="$$createConfigSecondChildProduct.price$$" stepKey="seeSecondProductPriceInField"/> + <seeElement selector="{{AdminProductFormConfigurationsSection.variationImageSource(MagentoLogo.fileName)}}" stepKey="seeFirstProductImageInField"/> + <seeElement selector="{{AdminProductFormConfigurationsSection.variationImageSource(TestImage.fileName)}}" stepKey="seeSecondProductImageInField"/> + + <!-- Go to "Images and Videos" section: assert image --> + <scrollTo selector="{{AdminProductFormConfigurationsSection.sectionHeader}}" stepKey="scrollToProductGalleryTab"/> + <actionGroup ref="assertProductImageAdminProductPage" stepKey="assertProductImageAdminProductPage"> + <argument name="image" value="MagentoLogo"/> + </actionGroup> + + <!-- Go to any ConfProd's configuration page: Product page open successfully --> + <click selector="{{AdminProductFormConfigurationsSection.variationProductLinkByName($$createConfigFirstChildProduct.name$$)}}" stepKey="clickOnFirstProductLink"/> + <switchToNextTab stepKey="switchToConfigChildProductPage"/> + <waitForPageLoad stepKey="waitForProductPageLoad"/> + <!-- Go to "Images and Videos" section: assert image --> + <scrollTo selector="{{AdminProductFormConfigurationsSection.sectionHeader}}" stepKey="scrollToChildProductGalleryTab"/> + <actionGroup ref="assertProductImageAdminProductPage" stepKey="assertChildProductImageAdminProductPage"> + <argument name="image" value="MagentoLogo"/> + </actionGroup> + <closeTab stepKey="closeConfigChildProductPage"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php index bd2fe896b8c0a..371d75bc922f3 100644 --- a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php @@ -72,7 +72,9 @@ protected function setUp() 'setAttributeSetFilter' ] ); - $attribute = $this->createPartialMock(\Magento\Eav\Model\Entity\Attribute::class, [ + $attribute = $this->createPartialMock( + \Magento\Eav\Model\Entity\Attribute::class, + [ 'getAttributeCode', 'getId', 'getIsVisible', @@ -85,7 +87,8 @@ protected function setUp() 'getDefaultValue', 'usesSource', 'getFrontendInput', - ]); + ] + ); $attribute->expects($this->any())->method('getIsVisible')->willReturn(true); $attribute->expects($this->any())->method('getIsGlobal')->willReturn(true); $attribute->expects($this->any())->method('getIsRequired')->willReturn(true); @@ -107,6 +110,7 @@ protected function setUp() ]; $attribute1 = clone $attribute; $attribute2 = clone $attribute; + $attribute3 = clone $attribute; $attribute1->expects($this->any())->method('getId')->willReturn('1'); $attribute1->expects($this->any())->method('getAttributeCode')->willReturn('attr_code'); @@ -118,6 +122,11 @@ protected function setUp() $attribute2->expects($this->any())->method('getFrontendInput')->willReturn('boolean'); $attribute2->expects($this->any())->method('isStatic')->willReturn(false); + $attribute3->expects($this->any())->method('getId')->willReturn('3'); + $attribute3->expects($this->any())->method('getAttributeCode')->willReturn('text_attribute'); + $attribute3->expects($this->any())->method('getFrontendInput')->willReturn('text'); + $attribute3->expects($this->any())->method('isStatic')->willReturn(false); + $this->entityModel->expects($this->any())->method('getEntityTypeId')->willReturn(3); $this->entityModel->expects($this->any())->method('getAttributeOptions')->willReturnOnConsecutiveCalls( ['option1', 'option2'], @@ -126,7 +135,9 @@ protected function setUp() $attrSetColFactory->expects($this->any())->method('create')->willReturn($attrSetCollection); $attrSetCollection->expects($this->any())->method('setEntityTypeFilter')->willReturn([$attributeSet]); $attrColFactory->expects($this->any())->method('create')->willReturn($attrCollection); - $attrCollection->expects($this->any())->method('setAttributeSetFilter')->willReturn([$attribute1, $attribute2]); + $attrCollection->expects($this->any()) + ->method('setAttributeSetFilter') + ->willReturn([$attribute1, $attribute2, $attribute3]); $attributeSet->expects($this->any())->method('getId')->willReturn(1); $attributeSet->expects($this->any())->method('getAttributeSetName')->willReturn('attribute_set_name'); @@ -157,9 +168,11 @@ protected function setUp() ], ] ) - ->willReturn([$attribute1, $attribute2]); + ->willReturn([$attribute1, $attribute2, $attribute3]); - $this->connection = $this->createPartialMock(\Magento\Framework\DB\Adapter\Pdo\Mysql::class, [ + $this->connection = $this->createPartialMock( + \Magento\Framework\DB\Adapter\Pdo\Mysql::class, + [ 'select', 'fetchAll', 'fetchPairs', @@ -167,13 +180,17 @@ protected function setUp() 'insertOnDuplicate', 'delete', 'quoteInto' - ]); - $this->select = $this->createPartialMock(\Magento\Framework\DB\Select::class, [ + ] + ); + $this->select = $this->createPartialMock( + \Magento\Framework\DB\Select::class, + [ 'from', 'where', 'joinLeft', 'getConnection', - ]); + ] + ); $this->select->expects($this->any())->method('from')->will($this->returnSelf()); $this->select->expects($this->any())->method('where')->will($this->returnSelf()); $this->select->expects($this->any())->method('joinLeft')->will($this->returnSelf()); @@ -189,10 +206,13 @@ protected function setUp() ->method('fetchAll') ->will($this->returnValue($entityAttributes)); - $this->resource = $this->createPartialMock(\Magento\Framework\App\ResourceConnection::class, [ + $this->resource = $this->createPartialMock( + \Magento\Framework\App\ResourceConnection::class, + [ 'getConnection', 'getTableName', - ]); + ] + ); $this->resource->expects($this->any())->method('getConnection')->will( $this->returnValue($this->connection) ); @@ -257,9 +277,13 @@ public function testIsRowValidSuccess() $rowNum = 1; $this->entityModel->expects($this->any())->method('getRowScope')->willReturn(null); $this->entityModel->expects($this->never())->method('addRowError'); - $this->setPropertyValue($this->simpleType, '_attributes', [ - $rowData[\Magento\CatalogImportExport\Model\Import\Product::COL_ATTR_SET] => [], - ]); + $this->setPropertyValue( + $this->simpleType, + '_attributes', + [ + $rowData[\Magento\CatalogImportExport\Model\Import\Product::COL_ATTR_SET] => [], + ] + ); $this->assertTrue($this->simpleType->isRowValid($rowData, $rowNum)); } @@ -278,13 +302,17 @@ public function testIsRowValidError() 'attr_code' ) ->willReturnSelf(); - $this->setPropertyValue($this->simpleType, '_attributes', [ - $rowData[\Magento\CatalogImportExport\Model\Import\Product::COL_ATTR_SET] => [ - 'attr_code' => [ - 'is_required' => true, + $this->setPropertyValue( + $this->simpleType, + '_attributes', + [ + $rowData[\Magento\CatalogImportExport\Model\Import\Product::COL_ATTR_SET] => [ + 'attr_code' => [ + 'is_required' => true, + ], ], - ], - ]); + ] + ); $this->assertFalse($this->simpleType->isRowValid($rowData, $rowNum)); } @@ -364,9 +392,14 @@ public function testPrepareAttributesWithDefaultValueForSave() { $rowData = [ '_attribute_set' => 'attributeSetName', - 'boolean_attribute' => 'Yes' + 'boolean_attribute' => 'Yes', + ]; + + $expected = [ + 'boolean_attribute' => 1, + 'text_attribute' => 'default_value' ]; $result = $this->simpleType->prepareAttributesWithDefaultValueForSave($rowData); - $this->assertEquals(['boolean_attribute' => 1], $result); + $this->assertEquals($expected, $result); } } diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php index edccad60231ec..3a214bd8cd7cb 100644 --- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php +++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php @@ -14,6 +14,7 @@ use Magento\Framework\DB\Select; use Magento\Framework\App\ObjectManager; use Magento\Framework\Stdlib\DateTime\DateTime; +use Zend_Db_Expr; /** * Stock item resource model @@ -183,16 +184,12 @@ public function updateSetOutOfStock(int $websiteId) 'is_in_stock = ' . Stock::STOCK_IN_STOCK, '(use_config_manage_stock = 1 AND 1 = ' . $this->stockConfiguration->getManageStock() . ')' . ' OR (use_config_manage_stock = 0 AND manage_stock = 1)', - '(use_config_min_qty = 1 AND qty <= ' . $this->stockConfiguration->getMinQty() . ')' - . ' OR (use_config_min_qty = 0 AND qty <= min_qty)', + '(' . $this->getBackordersExpr() .' = 0 AND qty <= ' . $this->getMinQtyExpr() . ')' + . ' OR (' . $this->getBackordersExpr() .' != 0 AND ' + . $this->getMinQtyExpr() . ' != 0 AND qty <= ' . $this->getMinQtyExpr() . ')', 'product_id IN (' . $select->assemble() . ')', ]; - $backordersWhere = '(use_config_backorders = 0 AND backorders = ' . Stock::BACKORDERS_NO . ')'; - if (Stock::BACKORDERS_NO == $this->stockConfiguration->getBackorders()) { - $where[] = $backordersWhere . ' OR use_config_backorders = 1'; - } else { - $where[] = $backordersWhere; - } + $connection->update($this->getMainTable(), $values, $where); $this->stockIndexerProcessor->markIndexerAsInvalid(); @@ -215,8 +212,8 @@ public function updateSetInStock(int $websiteId) $where = [ 'website_id = ' . $websiteId, 'stock_status_changed_auto = 1', - '(use_config_min_qty = 1 AND qty > ' . $this->stockConfiguration->getMinQty() . ')' - . ' OR (use_config_min_qty = 0 AND qty > min_qty)', + '(qty > ' . $this->getMinQtyExpr() . ')' + . ' OR (' . $this->getBackordersExpr() . ' != 0 AND ' . $this->getMinQtyExpr() . ' = 0)', // If infinite 'product_id IN (' . $select->assemble() . ')', ]; $manageStockWhere = '(use_config_manage_stock = 0 AND manage_stock = 1)'; @@ -304,12 +301,12 @@ public function getBackordersExpr(string $tableAlias = ''): \Zend_Db_Expr } /** - * Get Minimum Sale Quantity Expression + * Get Minimum Sale Quantity Expression. * * @param string $tableAlias - * @return \Zend_Db_Expr + * @return Zend_Db_Expr */ - public function getMinSaleQtyExpr(string $tableAlias = ''): \Zend_Db_Expr + public function getMinSaleQtyExpr(string $tableAlias = ''): Zend_Db_Expr { if ($tableAlias) { $tableAlias .= '.'; @@ -323,6 +320,26 @@ public function getMinSaleQtyExpr(string $tableAlias = ''): \Zend_Db_Expr return $itemMinSaleQty; } + /** + * Get Min Qty Expression + * + * @param string $tableAlias + * @return Zend_Db_Expr + */ + public function getMinQtyExpr(string $tableAlias = ''): Zend_Db_Expr + { + if ($tableAlias) { + $tableAlias .= '.'; + } + $itemBackorders = $this->getConnection()->getCheckSql( + $tableAlias . 'use_config_min_qty = 1', + $this->stockConfiguration->getMinQty(), + $tableAlias . 'min_qty' + ); + + return $itemBackorders; + } + /** * Build select for products with types from config * diff --git a/app/code/Magento/CatalogInventory/Model/StockStateProvider.php b/app/code/Magento/CatalogInventory/Model/StockStateProvider.php index 6851b05aa56a6..74271cdd97bf8 100644 --- a/app/code/Magento/CatalogInventory/Model/StockStateProvider.php +++ b/app/code/Magento/CatalogInventory/Model/StockStateProvider.php @@ -72,14 +72,31 @@ public function __construct( */ public function verifyStock(StockItemInterface $stockItem) { + // Manage stock, but qty is null if ($stockItem->getQty() === null && $stockItem->getManageStock()) { return false; } + + // Backorders are not allowed and qty reached min qty if ($stockItem->getBackorders() == StockItemInterface::BACKORDERS_NO && $stockItem->getQty() <= $stockItem->getMinQty() ) { return false; } + + $backordersAllowed = [Stock::BACKORDERS_YES_NONOTIFY, Stock::BACKORDERS_YES_NOTIFY]; + if (in_array($stockItem->getBackorders(), $backordersAllowed)) { + // Infinite - let it be In stock + if ($stockItem->getMinQty() == 0) { + return true; + } + + // qty reached min qty - let it stand Out Of Stock + if ($stockItem->getQty() <= $stockItem->getMinQty()) { + return false; + } + } + return true; } @@ -245,15 +262,17 @@ public function checkQty(StockItemInterface $stockItem, $qty) if (!$stockItem->getManageStock()) { return true; } + + $backordersAllowed = [Stock::BACKORDERS_YES_NONOTIFY, Stock::BACKORDERS_YES_NOTIFY]; + // Infinite check + if ($stockItem->getMinQty() == 0 && in_array($stockItem->getBackorders(), $backordersAllowed)) { + return true; + } + if ($stockItem->getQty() - $stockItem->getMinQty() - $qty < 0) { - switch ($stockItem->getBackorders()) { - case \Magento\CatalogInventory\Model\Stock::BACKORDERS_YES_NONOTIFY: - case \Magento\CatalogInventory\Model\Stock::BACKORDERS_YES_NOTIFY: - break; - default: - return false; - } + return false; } + return true; } diff --git a/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php b/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php index f49d41b5dd656..268f1846161d4 100644 --- a/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php +++ b/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php @@ -6,21 +6,36 @@ namespace Magento\CatalogInventory\Model\System\Config\Backend; +use Magento\CatalogInventory\Model\Stock; + /** - * Minimum product qty backend model + * Minimum product qty backend model. */ class Minqty extends \Magento\Framework\App\Config\Value { /** - * Validate minimum product qty value + * Validate minimum product qty value. * * @return $this */ public function beforeSave() { parent::beforeSave(); - $minQty = (int) $this->getValue() >= 0 ? (int) $this->getValue() : (int) $this->getOldValue(); + $minQty = (float)$this->getValue(); + + /** + * As described in the documentation if the Backorders Option is disabled + * it is recommended to set the Out Of Stock Threshold to a positive number. + * That's why to clarify the logic to the end user the code below prevent him to set a negative number so such + * a number will turn to zero. + * @see https://docs.magento.com/m2/ce/user_guide/catalog/inventory-backorders.html + */ + if ($this->getFieldsetDataValue("backorders") == Stock::BACKORDERS_NO && $minQty < 0) { + $minQty = 0; + } + $this->setValue((string) $minQty); + return $this; } } diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminCatalogInventoryConfigurationActionGroup.xml b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminCatalogInventoryConfigurationActionGroup.xml new file mode 100644 index 0000000000000..49956473132ec --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminCatalogInventoryConfigurationActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCatalogInventoryConfigurationMaxQtyAllowedInShoppingCartValidationActionGroup"> + <arguments> + <argument name="qty" type="string"/> + <argument name="errorMessage" type="string"/> + </arguments> + + <fillField selector="{{AdminInventoryProductStockOptionsConfigSection.maxSaleQty}}" userInput="{{qty}}" stepKey="setMaxSaleQtyValue"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveConfigButton"/> + <waitForElementVisible selector="{{AdminInventoryProductStockOptionsConfigSection.maxSaleQtyError}}" stepKey="waitValidationErrorMessageAppears"/> + <see selector="{{AdminInventoryProductStockOptionsConfigSection.maxSaleQtyError}}" userInput="{{errorMessage}}" stepKey="checkValidationErrorMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminProductActionGroup.xml b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminProductActionGroup.xml new file mode 100644 index 0000000000000..84dc6b93c885f --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminProductActionGroup.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminProductSetMaxQtyAllowedInShoppingCart"> + <arguments> + <argument name="qty" type="string"/> + </arguments> + <conditionalClick selector="{{AdminProductFormSection.advancedInventoryLink}}" dependentSelector="{{AdminProductFormAdvancedInventorySection.advancedInventoryModal}}" visible="false" stepKey="clickOnAdvancedInventoryLinkIfNeeded"/> + <waitForElementVisible selector="{{AdminProductFormAdvancedInventorySection.maxiQtyConfigSetting}}" stepKey="waitForAdvancedInventoryModalWindowOpen"/> + <uncheckOption selector="{{AdminProductFormAdvancedInventorySection.maxiQtyConfigSetting}}" stepKey="uncheckMaxQtyCheckBox"/> + <fillField selector="{{AdminProductFormAdvancedInventorySection.maxiQtyAllowedInCart}}" userInput="{{qty}}" stepKey="fillMaxAllowedQty"/> + <click selector="{{AdminSlideOutDialogSection.doneButton}}" stepKey="clickDone"/> + </actionGroup> + + <actionGroup name="AdminProductMaxQtyAllowedInShoppingCartValidationActionGroup" extends="AdminProductSetMaxQtyAllowedInShoppingCart"> + <arguments> + <argument name="qty" type="string"/> + <argument name="errorMessage" type="string"/> + </arguments> + + <waitForElementVisible selector="{{AdminProductFormAdvancedInventorySection.maxiQtyAllowedInCartError}}" after="clickDone" stepKey="waitProductValidationErrorMessageAppears"/> + <see selector="{{AdminProductFormAdvancedInventorySection.maxiQtyAllowedInCartError}}" userInput="{{errorMessage}}" after="waitProductValidationErrorMessageAppears" stepKey="checkProductValidationErrorMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml index e14c36446fc2b..cd5a8cf5bbac9 100644 --- a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml @@ -20,4 +20,17 @@ <data key="label">No</data> <data key="value">0</data> </entity> + <entity name="EnableCatalogInventoryConfigData"> + <!--Default Value --> + <data key="path">cataloginventory/options/can_subtract</data> + <data key="scope_id">0</data> + <data key="label">Yes</data> + <data key="value">1</data> + </entity> + <entity name="DisableCatalogInventoryConfigData"> + <data key="path">cataloginventory/options/can_subtract</data> + <data key="scope_id">0</data> + <data key="label">No</data> + <data key="value">0</data> + </entity> </entities> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryItemOptionsData.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryItemOptionsData.xml new file mode 100644 index 0000000000000..767d65f9facca --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryItemOptionsData.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="DefaultValueForMaxSaleQty" type="cataloginventory_item_options"> + <requiredEntity type="max_sale_qty">MaxSaleQtyDefaultValue</requiredEntity> + </entity> + <entity name="MaxSaleQtyDefaultValue" type="max_sale_qty"> + <data key="value">10000</data> + </entity> +</entities> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventryConfigData.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventryConfigData.xml deleted file mode 100644 index 3a49b821ead5f..0000000000000 --- a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventryConfigData.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- - /** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ ---> - -<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> - <entity name="EnableCatalogInventoryConfigData"> - <!--Default Value --> - <data key="path">cataloginventory/options/can_subtract</data> - <data key="scope_id">0</data> - <data key="label">Yes</data> - <data key="value">1</data> - </entity> - <entity name="DisableCatalogInventoryConfigData"> - <data key="path">cataloginventory/options/can_subtract</data> - <data key="scope_id">0</data> - <data key="label">No</data> - <data key="value">0</data> - </entity> -</entities> \ No newline at end of file diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/cataloginventory_item_options-meta.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/cataloginventory_item_options-meta.xml new file mode 100644 index 0000000000000..7672cb7478f1a --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/cataloginventory_item_options-meta.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CatalogInventoryProductStockOptionsConfiguration" dataType="cataloginventory_item_options" type="create" + auth="adminFormKey" url="/admin/system_config/save/section/cataloginventory/" method="POST" successRegex="/messages-message-success/"> + <object key="groups" dataType="cataloginventory_item_options"> + <object key="item_options" dataType="cataloginventory_item_options"> + <object key="fields" dataType="cataloginventory_item_options"> + <object key="max_sale_qty" dataType="max_sale_qty"> + <field key="value">integer</field> + </object> + </object> + </object> + </object> + </operation> +</operations> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminInventoryProductStockOptionsConfigPage.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminInventoryProductStockOptionsConfigPage.xml new file mode 100644 index 0000000000000..3d8c3ef3cf9f8 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminInventoryProductStockOptionsConfigPage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminInventoryProductStockOptionsConfigPage" url="admin/system_config/edit/section/cataloginventory/#cataloginventory_item_options-link" area="admin" module="Magento_Config"> + <section name="AdminInventoryProductStockOptionsConfigSection"/> + </page> +</pages> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminProductCreatePage.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminProductCreatePage.xml new file mode 100644 index 0000000000000..5835e7564c172 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminProductCreatePage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminProductCreatePage" url="catalog/product/new/set/{{set}}/type/{{type}}/" area="admin" module="Magento_Catalog" parameterized="true"> + <section name="AdminProductFormAdvancedInventorySection"/> + </page> +</pages> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminInventoryProductStockOptionsConfigSection.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminInventoryProductStockOptionsConfigSection.xml new file mode 100644 index 0000000000000..ef7fe30f4970b --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminInventoryProductStockOptionsConfigSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminInventoryProductStockOptionsConfigSection"> + <element name="maxSaleQtyInherit" type="checkbox" selector="#cataloginventory_item_options_max_sale_qty_inherit" timeout="30"/> + <element name="maxSaleQty" type="input" selector="#cataloginventory_item_options_max_sale_qty"/> + <element name="maxSaleQtyError" type="input" selector="#cataloginventory_item_options_max_sale_qty-error"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml similarity index 92% rename from app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml rename to app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml index 4196a86fe25db..7ff9c2d70755f 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml @@ -30,5 +30,7 @@ <element name="advancedInventoryStockStatus" type="select" selector="//div[@class='modal-inner-wrap']//select[@name='product[quantity_and_stock_status][is_in_stock]']"/> <element name="outOfStockThreshold" type="select" selector="//*[@name='product[stock_data][min_qty]']" timeout="30"/> <element name="minQtyConfigSetting" type="checkbox" selector="//input[@name='product[stock_data][use_config_min_qty]']" timeout="30"/> + <element name="advancedInventoryModal" type="block" selector=".product_form_product_form_advanced_inventory_modal[data-role=modal]"/> + <element name="maxiQtyAllowedInCartError" type="text" selector="[name='product[stock_data][max_sale_qty]'] + label.admin__field-error"/> </section> </sections> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormSection.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormSection.xml new file mode 100644 index 0000000000000..f4b79b17b3fc3 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminProductFormSection"> + <element name="advancedInventoryLink" type="button" selector="button[data-index='advanced_inventory_button']" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Test/AdminCreateProductWithZeroMaximumQtyAllowedInShoppingCartTest.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Test/AdminCreateProductWithZeroMaximumQtyAllowedInShoppingCartTest.xml new file mode 100644 index 0000000000000..f7cf0a4deba4b --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Test/AdminCreateProductWithZeroMaximumQtyAllowedInShoppingCartTest.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreateProductWithZeroMaximumQtyAllowedInShoppingCartTest"> + <annotations> + <features value="CatalogInventory"/> + <stories value="Sales restrictions"/> + <title value="Verify that product maximum qty allowed in shopping cart can't be set to zero or less"/> + <description value="Verify that product maximum qty allowed in shopping cart can't be set to zero or less"/> + <severity value="MAJOR"/> + <useCaseId value="MC-17606"/> + <testCaseId value="MC-17636"/> + <group value="catalog"/> + <group value="catalogInventory"/> + </annotations> + <before> + <createData entity="DefaultValueForMaxSaleQty" stepKey="setDefaultValueForMaxSaleQty"/> + <createData entity="SimpleProduct2" stepKey="createdProduct"/> + <actionGroup ref="LoginAsAdmin" stepKey="login"/> + </before> + <after> + <createData entity="DefaultValueForMaxSaleQty" stepKey="setDefaultValueForMaxSaleQty"/> + <deleteData createDataKey="createdProduct" stepKey="deleteProduct"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Go to Inventory configuration page --> + <amOnPage url="{{AdminInventoryProductStockOptionsConfigPage.url}}" stepKey="openInventoryConfigPage"/> + <uncheckOption selector="{{AdminInventoryProductStockOptionsConfigSection.maxSaleQtyInherit}}" stepKey="uncheckUseDefaultValueForMaxSaleQty"/> + <!-- Validate zero value --> + <actionGroup ref="AdminCatalogInventoryConfigurationMaxQtyAllowedInShoppingCartValidationActionGroup" stepKey="validateZeroValue"> + <argument name="qty" value="0"/> + <argument name="errorMessage" value="Please enter a number greater than 0 in this field."/> + </actionGroup> + <!-- Validate negative value --> + <actionGroup ref="AdminCatalogInventoryConfigurationMaxQtyAllowedInShoppingCartValidationActionGroup" stepKey="validateNegativeValue"> + <argument name="qty" value="-1"/> + <argument name="errorMessage" value="Please enter a number greater than 0 in this field."/> + </actionGroup> + <!-- Validate alphabetical value --> + <actionGroup ref="AdminCatalogInventoryConfigurationMaxQtyAllowedInShoppingCartValidationActionGroup" stepKey="validateAlphabeticalValue"> + <argument name="qty" value="abc"/> + <argument name="errorMessage" value="Please enter a valid number in this field."/> + </actionGroup> + <!-- Fill correct value --> + <fillField selector="{{AdminInventoryProductStockOptionsConfigSection.maxSaleQty}}" userInput="100" stepKey="setMaxSaleQtyValueToCorrectNumber"/> + <actionGroup ref="AdminSaveConfigActionGroup" stepKey="saveConfigWithCorrectNumber"/> + + <!-- Go to product page --> + <amOnPage url="{{AdminProductEditPage.url($$createdProduct.id$$)}}" stepKey="openAdminProductEditPage"/> + <!-- Validate zero value --> + <actionGroup ref="AdminProductMaxQtyAllowedInShoppingCartValidationActionGroup" stepKey="productValidateZeroValue"> + <argument name="qty" value="0"/> + <argument name="errorMessage" value="Please enter a number greater than 0 in this field."/> + </actionGroup> + <!-- Validate negative value --> + <actionGroup ref="AdminProductMaxQtyAllowedInShoppingCartValidationActionGroup" stepKey="productValidateNegativeValue"> + <argument name="qty" value="-1"/> + <argument name="errorMessage" value="Please enter a number greater than 0 in this field."/> + </actionGroup> + <!-- Validate alphabetical value --> + <actionGroup ref="AdminProductMaxQtyAllowedInShoppingCartValidationActionGroup" stepKey="productValidateAlphabeticalValue"> + <argument name="qty" value="abc"/> + <argument name="errorMessage" value="Please enter a valid number in this field."/> + </actionGroup> + <!-- Fill correct value --> + <actionGroup ref="AdminProductSetMaxQtyAllowedInShoppingCart" stepKey="setProductMaxQtyAllowedInShoppingCartToCorrectNumber"> + <argument name="qty" value="50"/> + </actionGroup> + <waitForElementNotVisible selector="{{AdminProductFormAdvancedInventorySection.advancedInventoryModal}}" stepKey="waitForModalFormToDisappear"/> + <actionGroup ref="saveProductForm" stepKey="saveProduct"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockStateProviderTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockStateProviderTest.php new file mode 100644 index 0000000000000..942d77063a8e3 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockStateProviderTest.php @@ -0,0 +1,223 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogInventory\Test\Unit\Model; + +use PHPUnit\Framework\TestCase; +use Magento\CatalogInventory\Model\StockStateProvider; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Model\Stock; + +/** + * StockRegistry test. + */ +class StockStateProviderTest extends TestCase +{ + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @var StockStateProvider + */ + private $model; + + /** + * @var StockItemInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private $stockItem; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = new ObjectManager($this); + + $this->stockItem = $this->getMockBuilder(StockItemInterface::class) + ->getMock(); + + $this->model = $this->objectManager->getObject(StockStateProvider::class); + } + + /** + * Tests verifyStock method. + * + * @param int $qty + * @param int $backOrders + * @param int $minQty + * @param int $manageStock + * @param int $expected + * + * @return void + * + * @dataProvider stockItemDataProvider + * @covers \Magento\CatalogInventory\Model\StockStateProvider::verifyStock + */ + public function testVerifyStock( + ?int $qty, + ?int $backOrders, + ?int $minQty, + ?int $manageStock, + bool $expected + ): void { + $this->stockItem->method('getQty') + ->willReturn($qty); + $this->stockItem->method('getBackOrders') + ->willReturn($backOrders); + $this->stockItem->method('getMinQty') + ->willReturn($minQty); + $this->stockItem->method('getManageStock') + ->willReturn($manageStock); + + $result = $this->model->verifyStock($this->stockItem); + + self::assertEquals($expected, $result); + } + + /** + * StockItem data provider. + * + * @return array + */ + public function stockItemDataProvider(): array + { + return [ + 'qty_is_null_manage_stock_on' => [ + 'qty' => null, + 'backorders' => null, + 'min_qty' => null, + 'manage_stock' => 1, + 'expected' => false, + ], + 'qty_reached_threshold_without_backorders' => [ + 'qty' => 3, + 'backorders' => Stock::BACKORDERS_NO, + 'min_qty' => 3, + 'manage_stock' => 1, + 'expected' => false, + ], + 'backorders_are_ininite' => [ + 'qty' => -100, + 'backorders' => Stock::BACKORDERS_YES_NONOTIFY, + 'min_qty' => 0, + 'manage_stock' => 1, + 'expected' => true, + ], + 'limited_backorders_and_qty_reached_threshold' => [ + 'qty' => -100, + 'backorders' => Stock::BACKORDERS_YES_NONOTIFY, + 'min_qty' => -100, + 'manage_stock' => 1, + 'expected' => false, + ], + 'qty_not_yet_reached_threshold_1' => [ + 'qty' => -99, + 'backorders' => Stock::BACKORDERS_YES_NONOTIFY, + 'min_qty' => -100, + 'manage_stock' => 1, + 'expected' => true, + ], + 'qty_not_yet_reached_threshold_2' => [ + 'qty' => 1, + 'backorders' => Stock::BACKORDERS_NO, + 'min_qty' => 0, + 'manage_stock' => 1, + 'expected' => true, + ], + ]; + } + + /** + * Tests checkQty method. + * + * @return void + * + * @dataProvider stockItemAndQtyDataProvider + * @covers \Magento\CatalogInventory\Model\StockStateProvider::verifyStock + */ + public function testCheckQty( + bool $manageStock, + int $qty, + int $minQty, + int $backOrders, + int $orderQty, + bool $expected + ): void { + $this->stockItem->method('getManageStock') + ->willReturn($manageStock); + $this->stockItem->method('getQty') + ->willReturn($qty); + $this->stockItem->method('getMinQty') + ->willReturn($minQty); + $this->stockItem->method('getBackOrders') + ->willReturn($backOrders); + + $result = $this->model->checkQty($this->stockItem, $orderQty); + + self::assertEquals($expected, $result); + } + + /** + * StockItem and qty data provider. + * + * @return array + */ + public function stockItemAndQtyDataProvider(): array + { + return [ + 'disabled_manage_stock' => [ + 'manage_stock' => false, + 'qty' => 0, + 'min_qty' => 0, + 'backorders' => 0, + 'order_qty' => 0, + 'expected' => true, + ], + 'infinite_backorders' => [ + 'manage_stock' => true, + 'qty' => -100, + 'min_qty' => 0, + 'backorders' => Stock::BACKORDERS_YES_NONOTIFY, + 'order_qty' => 100, + 'expected' => true, + ], + 'qty_reached_threshold' => [ + 'manage_stock' => true, + 'qty' => -100, + 'min_qty' => -100, + 'backorders' => Stock::BACKORDERS_YES_NOTIFY, + 'order_qty' => 1, + 'expected' => false, + ], + 'qty_yet_not_reached_threshold' => [ + 'manage_stock' => true, + 'qty' => -100, + 'min_qty' => -100, + 'backorders' => Stock::BACKORDERS_YES_NOTIFY, + 'order_qty' => 1, + 'expected' => false, + ] + ]; + } + + /** + * Tests checkQty method when check is not applicable. + * + * @return void + */ + public function testCheckQtyWhenCheckIsNotApplicable(): void + { + $model = $this->objectManager->getObject(StockStateProvider::class, ['qtyCheckApplicable' => false]); + + $result = $model->checkQty($this->stockItem, 3); + + self::assertTrue($result); + } +} diff --git a/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml b/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml index 08ed0a8f49470..546f838b9b428 100644 --- a/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml +++ b/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml @@ -55,7 +55,7 @@ </field> <field id="max_sale_qty" translate="label" type="text" sortOrder="4" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Maximum Qty Allowed in Shopping Cart</label> - <validate>validate-number</validate> + <validate>validate-number validate-greater-than-zero</validate> </field> <field id="min_qty" translate="label" type="text" sortOrder="5" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Out-of-Stock Threshold</label> diff --git a/app/code/Magento/CatalogInventory/etc/db_schema.xml b/app/code/Magento/CatalogInventory/etc/db_schema.xml index 5ac7fedc5aa18..d903d09dd1f85 100644 --- a/app/code/Magento/CatalogInventory/etc/db_schema.xml +++ b/app/code/Magento/CatalogInventory/etc/db_schema.xml @@ -9,9 +9,9 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="cataloginventory_stock" resource="default" engine="innodb" comment="Cataloginventory Stock"> <column xsi:type="smallint" name="stock_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Stock Id"/> + comment="Stock ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="varchar" name="stock_name" nullable="true" length="255" comment="Stock Name"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="stock_id"/> @@ -22,11 +22,11 @@ </table> <table name="cataloginventory_stock_item" resource="default" engine="innodb" comment="Cataloginventory Stock Item"> <column xsi:type="int" name="item_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Item Id"/> + comment="Item ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product Id"/> + default="0" comment="Product ID"/> <column xsi:type="smallint" name="stock_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Stock Id"/> + default="0" comment="Stock ID"/> <column xsi:type="decimal" name="qty" scale="4" precision="12" unsigned="false" nullable="true" comment="Qty"/> <column xsi:type="decimal" name="min_qty" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Min Qty"/> @@ -94,11 +94,11 @@ <table name="cataloginventory_stock_status" resource="default" engine="innodb" comment="Cataloginventory Stock Status"> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="smallint" name="stock_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Stock Id"/> + comment="Stock ID"/> <column xsi:type="decimal" name="qty" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Qty"/> <column xsi:type="smallint" name="stock_status" padding="5" unsigned="true" nullable="false" identity="false" @@ -121,11 +121,11 @@ <table name="cataloginventory_stock_status_idx" resource="default" engine="innodb" comment="Cataloginventory Stock Status Indexer Idx"> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="smallint" name="stock_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Stock Id"/> + comment="Stock ID"/> <column xsi:type="decimal" name="qty" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Qty"/> <column xsi:type="smallint" name="stock_status" padding="5" unsigned="true" nullable="false" identity="false" @@ -145,11 +145,11 @@ <table name="cataloginventory_stock_status_tmp" resource="default" engine="memory" comment="Cataloginventory Stock Status Indexer Tmp"> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="smallint" name="stock_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Stock Id"/> + comment="Stock ID"/> <column xsi:type="decimal" name="qty" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Qty"/> <column xsi:type="smallint" name="stock_status" padding="5" unsigned="true" nullable="false" identity="false" @@ -169,11 +169,11 @@ <table name="cataloginventory_stock_status_replica" resource="default" engine="innodb" comment="Cataloginventory Stock Status"> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="smallint" name="stock_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Stock Id"/> + comment="Stock ID"/> <column xsi:type="decimal" name="qty" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Qty"/> <column xsi:type="smallint" name="stock_status" padding="5" unsigned="true" nullable="false" identity="false" 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 fc0690157fb37..b813aa5d356cb 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 @@ -304,6 +304,7 @@ <settings> <scopeLabel>[GLOBAL]</scopeLabel> <validation> + <rule name="validate-number" xsi:type="boolean">true</rule> <rule name="validate-greater-than-zero" xsi:type="boolean">true</rule> </validation> <label translate="true">Maximum Qty Allowed in Shopping Cart</label> diff --git a/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php b/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php index 4f58293d53359..6d499b93e411f 100644 --- a/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php +++ b/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php @@ -12,6 +12,7 @@ use Magento\Framework\Registry; use Magento\Framework\Stdlib\DateTime\Filter\Date; use Magento\Framework\App\Request\DataPersistorInterface; +use Magento\Framework\Stdlib\DateTime\TimezoneInterface; /** * Save action for catalog rule @@ -25,19 +26,27 @@ class Save extends \Magento\CatalogRule\Controller\Adminhtml\Promo\Catalog imple */ protected $dataPersistor; + /** + * @var TimezoneInterface + */ + private $localeDate; + /** * @param Context $context * @param Registry $coreRegistry * @param Date $dateFilter * @param DataPersistorInterface $dataPersistor + * @param TimezoneInterface $localeDate */ public function __construct( Context $context, Registry $coreRegistry, Date $dateFilter, - DataPersistorInterface $dataPersistor + DataPersistorInterface $dataPersistor, + TimezoneInterface $localeDate ) { $this->dataPersistor = $dataPersistor; + $this->localeDate = $localeDate; parent::__construct($context, $coreRegistry, $dateFilter); } @@ -46,16 +55,15 @@ public function __construct( * * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|void * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) */ public function execute() { if ($this->getRequest()->getPostValue()) { - /** @var \Magento\CatalogRule\Api\CatalogRuleRepositoryInterface $ruleRepository */ $ruleRepository = $this->_objectManager->get( \Magento\CatalogRule\Api\CatalogRuleRepositoryInterface::class ); - /** @var \Magento\CatalogRule\Model\Rule $model */ $model = $this->_objectManager->create(\Magento\CatalogRule\Model\Rule::class); @@ -65,7 +73,9 @@ public function execute() ['request' => $this->getRequest()] ); $data = $this->getRequest()->getPostValue(); - + if (!$this->getRequest()->getParam('from_date')) { + $data['from_date'] = $this->localeDate->formatDate(); + } $filterValues = ['from_date' => $this->_dateFilter]; if ($this->getRequest()->getParam('to_date')) { $filterValues['to_date'] = $this->_dateFilter; diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php index e589c8595ce2c..944710773123f 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php @@ -101,7 +101,9 @@ public function execute(Rule $rule, $batchCount, $useAdditionalTable = false) $scopeTz = new \DateTimeZone( $this->localeDate->getConfigTimezone(ScopeInterface::SCOPE_WEBSITE, $websiteId) ); - $fromTime = (new \DateTime($rule->getFromDate(), $scopeTz))->getTimestamp(); + $fromTime = $rule->getFromDate() + ? (new \DateTime($rule->getFromDate(), $scopeTz))->getTimestamp() + : 0; $toTime = $rule->getToDate() ? (new \DateTime($rule->getToDate(), $scopeTz))->getTimestamp() + IndexBuilder::SECONDS_IN_DAY - 1 : 0; diff --git a/app/code/Magento/CatalogRule/etc/db_schema.xml b/app/code/Magento/CatalogRule/etc/db_schema.xml index 59082e93b04c2..b3692f280fec5 100644 --- a/app/code/Magento/CatalogRule/etc/db_schema.xml +++ b/app/code/Magento/CatalogRule/etc/db_schema.xml @@ -37,7 +37,7 @@ </table> <table name="catalogrule_product" resource="default" engine="innodb" comment="CatalogRule Product"> <column xsi:type="int" name="rule_product_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Rule Product Id"/> + comment="Rule Product ID"/> <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Rule ID"/> <column xsi:type="int" name="from_time" padding="10" unsigned="true" nullable="false" identity="false" @@ -46,7 +46,7 @@ comment="To time"/> <column xsi:type="int" name="customer_group_id" padding="11" unsigned="false" nullable="true" identity="false"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product Id"/> + default="0" comment="Product ID"/> <column xsi:type="varchar" name="action_operator" nullable="true" length="10" default="to_fixed" comment="Action Operator"/> <column xsi:type="decimal" name="action_amount" scale="6" precision="20" unsigned="false" nullable="false" @@ -56,7 +56,7 @@ <column xsi:type="int" name="sort_order" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Sort Order"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_product_id"/> </constraint> @@ -91,11 +91,11 @@ <column xsi:type="date" name="rule_date" nullable="false" comment="Rule Date"/> <column xsi:type="int" name="customer_group_id" padding="11" unsigned="false" nullable="true" identity="false"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product Id"/> + default="0" comment="Product ID"/> <column xsi:type="decimal" name="rule_price" scale="6" precision="20" unsigned="false" nullable="false" default="0" comment="Rule Price"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="date" name="latest_start_date" comment="Latest StartDate"/> <column xsi:type="date" name="earliest_end_date" comment="Earliest EndDate"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -121,9 +121,9 @@ <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Rule ID"/> <column xsi:type="int" name="customer_group_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Customer Group Id"/> + default="0" comment="Customer Group ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website Id"/> + default="0" comment="Website ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_id"/> <column name="customer_group_id"/> @@ -140,7 +140,7 @@ <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Rule ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_id"/> <column name="website_id"/> @@ -160,7 +160,7 @@ <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Rule ID"/> <column xsi:type="int" name="customer_group_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Customer Group Id"/> + comment="Customer Group ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_id"/> <column name="customer_group_id"/> @@ -177,7 +177,7 @@ </table> <table name="catalogrule_product_replica" resource="default" engine="innodb" comment="CatalogRule Product"> <column xsi:type="int" name="rule_product_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Rule Product Id"/> + comment="Rule Product ID"/> <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Rule ID"/> <column xsi:type="int" name="from_time" padding="10" unsigned="true" nullable="false" identity="false" @@ -186,7 +186,7 @@ comment="To time"/> <column xsi:type="int" name="customer_group_id" padding="11" unsigned="false" nullable="true" identity="false"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product Id"/> + default="0" comment="Product ID"/> <column xsi:type="varchar" name="action_operator" nullable="true" default="to_fixed" length="10" comment="Action Operator"/> <column xsi:type="decimal" name="action_amount" scale="6" precision="20" unsigned="false" nullable="false" @@ -196,7 +196,7 @@ <column xsi:type="int" name="sort_order" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Sort Order"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_product_id"/> </constraint> @@ -232,11 +232,11 @@ <column xsi:type="date" name="rule_date" nullable="false" comment="Rule Date"/> <column xsi:type="int" name="customer_group_id" padding="11" unsigned="false" nullable="true" identity="false"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product Id"/> + default="0" comment="Product ID"/> <column xsi:type="decimal" name="rule_price" scale="6" precision="20" unsigned="false" nullable="false" default="0" comment="Rule Price"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="date" name="latest_start_date" comment="Latest StartDate"/> <column xsi:type="date" name="earliest_end_date" comment="Earliest EndDate"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -263,9 +263,9 @@ <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Rule ID"/> <column xsi:type="int" name="customer_group_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Customer Group Id"/> + default="0" comment="Customer Group ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website Id"/> + default="0" comment="Website ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_id"/> <column name="customer_group_id"/> diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php index c758e773f43c1..a97d362c5de7f 100644 --- a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php +++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php @@ -165,7 +165,10 @@ private function processQueryWithField(FilterInterface $filter, $isNegation, $qu $this->customerSession->getCustomerGroupId() ); } elseif ($filter->getField() === 'category_ids') { - return 'category_ids_index.category_id = ' . (int) $filter->getValue(); + return $this->connection->quoteInto( + 'category_ids_index.category_id in (?)', + $filter->getValue() + ); } elseif ($attribute->isStatic()) { $alias = $this->aliasResolver->getAlias($filter); $resultQuery = str_replace( @@ -198,8 +201,9 @@ private function processQueryWithField(FilterInterface $filter, $isNegation, $qu ) ->joinLeft( ['current_store' => $table], - 'current_store.attribute_id = main_table.attribute_id AND current_store.store_id = ' - . $currentStoreId, + "current_store.{$linkIdField} = main_table.{$linkIdField} AND " + . "current_store.attribute_id = main_table.attribute_id AND current_store.store_id = " + . $currentStoreId, null ) ->columns([$filter->getField() => $ifNullCondition]) diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php index 21d8b7297da7d..912dec8666191 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php @@ -3,11 +3,14 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\CatalogSearch\Model\Indexer; use Magento\CatalogSearch\Model\Indexer\Fulltext\Action\FullFactory; +use Magento\CatalogSearch\Model\Indexer\Scope\State; use Magento\CatalogSearch\Model\Indexer\Scope\StateFactory; use Magento\CatalogSearch\Model\ResourceModel\Fulltext as FulltextResource; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Indexer\DimensionProviderInterface; use Magento\Store\Model\StoreDimensionProvider; use Magento\Indexer\Model\ProcessManager; @@ -79,6 +82,7 @@ class Fulltext implements * @param DimensionProviderInterface $dimensionProvider * @param array $data * @param ProcessManager $processManager + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( FullFactory $fullActionFactory, @@ -95,11 +99,9 @@ public function __construct( $this->fulltextResource = $fulltextResource; $this->data = $data; $this->indexSwitcher = $indexSwitcher; - $this->indexScopeState = $indexScopeStateFactory->create(); + $this->indexScopeState = ObjectManager::getInstance()->get(State::class); $this->dimensionProvider = $dimensionProvider; - $this->processManager = $processManager ?: \Magento\Framework\App\ObjectManager::getInstance()->get( - ProcessManager::class - ); + $this->processManager = $processManager ?: ObjectManager::getInstance()->get(ProcessManager::class); } /** @@ -127,9 +129,11 @@ public function executeByDimensions(array $dimensions, \Traversable $entityIds = throw new \InvalidArgumentException('Indexer "' . self::INDEXER_ID . '" support only Store dimension'); } $storeId = $dimensions[StoreDimensionProvider::DIMENSION_NAME]->getValue(); - $saveHandler = $this->indexerHandlerFactory->create([ - 'data' => $this->data - ]); + $saveHandler = $this->indexerHandlerFactory->create( + [ + 'data' => $this->data, + ] + ); if (null === $entityIds) { $this->indexScopeState->useTemporaryIndex(); diff --git a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php index e9fb1070fedd5..3b0c4dfb6df2f 100644 --- a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php +++ b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\CatalogSearch\Model\Layer\Filter; use Magento\Catalog\Model\Layer\Filter\AbstractFilter; @@ -12,6 +14,9 @@ */ class Decimal extends AbstractFilter { + /** Decimal delta for filter */ + private const DECIMAL_DELTA = 0.001; + /** * @var \Magento\Framework\Pricing\PriceCurrencyInterface */ @@ -70,11 +75,17 @@ public function apply(\Magento\Framework\App\RequestInterface $request) list($from, $to) = explode('-', $filter); + // When the range is 10-20 we only need to get products that are in the 10-19.99 range. + $toValue = $to; + if (!empty($toValue) && $from !== $toValue) { + $toValue -= self::DECIMAL_DELTA; + } + $this->getLayer() ->getProductCollection() ->addFieldToFilter( $this->getAttributeModel()->getAttributeCode(), - ['from' => $from, 'to' => $to] + ['from' => $from, 'to' => $toValue] ); $this->getLayer()->getState()->addFilter( @@ -111,7 +122,7 @@ protected function _getItemsData() $from = ''; } if ($to == '*') { - $to = null; + $to = ''; } $label = $this->renderRangeLabel(empty($from) ? 0 : $from, $to); $value = $from . '-' . $to; @@ -138,7 +149,7 @@ protected function _getItemsData() protected function renderRangeLabel($fromPrice, $toPrice) { $formattedFromPrice = $this->priceCurrency->format($fromPrice); - if ($toPrice === null) { + if ($toPrice === '') { return __('%1 and above', $formattedFromPrice); } else { if ($fromPrice != $toPrice) { diff --git a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php index a19f53469ae01..66d9281ed38e2 100644 --- a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php +++ b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\CatalogSearch\Model\Layer\Filter; use Magento\Catalog\Model\Layer\Filter\AbstractFilter; @@ -11,6 +13,7 @@ * Layer price filter based on Search API * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class Price extends AbstractFilter { @@ -138,7 +141,7 @@ public function apply(\Magento\Framework\App\RequestInterface $request) list($from, $to) = $filter; $this->getLayer()->getProductCollection()->addFieldToFilter( - 'price', + $this->getAttributeModel()->getAttributeCode(), ['from' => $from, 'to' => empty($to) || $from == $to ? $to : $to - self::PRICE_DELTA] ); diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php index 1946dd35b8d37..edc65490a3c67 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php @@ -391,7 +391,9 @@ private function getSearchResultApplier(SearchResultInterface $searchResult): Se 'collection' => $this, 'searchResult' => $searchResult, /** This variable sets by serOrder method, but doesn't have a getter method. */ - 'orders' => $this->_orders + 'orders' => $this->_orders, + 'size' => $this->getPageSize(), + 'currentPage' => (int)$this->_curPage, ] ); } diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php index 4f84f3868c6a3..14305359a71b3 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php @@ -485,12 +485,12 @@ private function getSearchCriteriaResolver(): SearchCriteriaResolverInterface { return $this->searchCriteriaResolverFactory->create( [ - 'builder' => $this->getSearchCriteriaBuilder(), - 'collection' => $this, - 'searchRequestName' => $this->searchRequestName, - 'currentPage' => $this->_curPage, - 'size' => $this->getPageSize(), - 'orders' => $this->searchOrders, + 'builder' => $this->getSearchCriteriaBuilder(), + 'collection' => $this, + 'searchRequestName' => $this->searchRequestName, + 'currentPage' => (int)$this->_curPage, + 'size' => $this->getPageSize(), + 'orders' => $this->searchOrders, ] ); } @@ -505,10 +505,12 @@ private function getSearchResultApplier(SearchResultInterface $searchResult): Se { return $this->searchResultApplierFactory->create( [ - 'collection' => $this, - 'searchResult' => $searchResult, - /** This variable sets by serOrder method, but doesn't have a getter method. */ - 'orders' => $this->_orders, + 'collection' => $this, + 'searchResult' => $searchResult, + /** This variable sets by serOrder method, but doesn't have a getter method. */ + 'orders' => $this->_orders, + 'size' => $this->getPageSize(), + 'currentPage' => (int)$this->_curPage, ] ); } diff --git a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php index 8f8ba39ebd329..5ac252677ff79 100644 --- a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php +++ b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\CatalogSearch\Model\Search; use Magento\Catalog\Api\Data\EavAttributeInterface; @@ -78,6 +80,7 @@ private function generateRequest($attributeType, $container, $useFulltext) { $request = []; foreach ($this->getSearchableAttributes() as $attribute) { + /** @var $attribute Attribute */ if ($attribute->getData($attributeType)) { if (!in_array($attribute->getAttributeCode(), ['price', 'category_ids'], true)) { $queryName = $attribute->getAttributeCode() . '_query'; @@ -97,12 +100,14 @@ private function generateRequest($attributeType, $container, $useFulltext) ], ]; $bucketName = $attribute->getAttributeCode() . self::BUCKET_SUFFIX; - $generator = $this->generatorResolver->getGeneratorForType($attribute->getBackendType()); + $generatorType = $attribute->getFrontendInput() === 'price' + ? $attribute->getFrontendInput() + : $attribute->getBackendType(); + $generator = $this->generatorResolver->getGeneratorForType($generatorType); $request['filters'][$filterName] = $generator->getFilterData($attribute, $filterName); $request['aggregations'][$bucketName] = $generator->getAggregationData($attribute, $bucketName); } } - /** @var $attribute Attribute */ if (!$attribute->getIsSearchable() || in_array($attribute->getAttributeCode(), ['price'], true)) { // Some fields have their own specific handlers continue; diff --git a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php index b3d39a48fe9fc..73d011cc532db 100644 --- a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php +++ b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogSearch\Model\Search\RequestGenerator; diff --git a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Price.php b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Price.php new file mode 100644 index 0000000000000..949806d14f45a --- /dev/null +++ b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Price.php @@ -0,0 +1,46 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogSearch\Model\Search\RequestGenerator; + +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Framework\Search\Request\BucketInterface; +use Magento\Framework\Search\Request\FilterInterface; + +/** + * Catalog search range request generator. + */ +class Price implements GeneratorInterface +{ + /** + * @inheritdoc + */ + public function getFilterData(Attribute $attribute, $filterName): array + { + return [ + 'type' => FilterInterface::TYPE_RANGE, + 'name' => $filterName, + 'field' => $attribute->getAttributeCode(), + 'from' => '$' . $attribute->getAttributeCode() . '.from$', + 'to' => '$' . $attribute->getAttributeCode() . '.to$', + ]; + } + + /** + * @inheritdoc + */ + public function getAggregationData(Attribute $attribute, $bucketName): array + { + return [ + 'type' => BucketInterface::TYPE_DYNAMIC, + 'name' => $bucketName, + 'field' => $attribute->getAttributeCode(), + 'method' => '$price_dynamic_algorithm$', + 'metric' => [['type' => 'count']], + ]; + } +} diff --git a/app/code/Magento/CatalogSearch/Test/Mftf/Test/LayerNavigationOfCatalogSearchTest.xml b/app/code/Magento/CatalogSearch/Test/Mftf/Test/LayerNavigationOfCatalogSearchTest.xml new file mode 100644 index 0000000000000..210b474af2e02 --- /dev/null +++ b/app/code/Magento/CatalogSearch/Test/Mftf/Test/LayerNavigationOfCatalogSearchTest.xml @@ -0,0 +1,88 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="LayerNavigationOfCatalogSearchTest"> + <annotations> + <stories value="Search terms"/> + <title value="Layer Navigation of Catalog Search Should Equalize Price Range As Default Configuration"/> + <description value="Make sure filter of custom attribute with type of price displays on storefront Catalog page and price range should respect the configuration in Admin site"/> + <testCaseId value="MC-16979"/> + <useCaseId value="MC-16650"/> + <severity value="MAJOR"/> + <group value="CatalogSearch"/> + </annotations> + <before> + <magentoCLI command="config:set catalog/layered_navigation/price_range_calculation auto" stepKey="setAutoPriceRange"/> + <createData stepKey="createPriceAttribute" entity="productAttributeTypeOfPrice"/> + <createData stepKey="assignPriceAttributeGroup" entity="AddToDefaultSet"> + <requiredEntity createDataKey="createPriceAttribute"/> + </createData> + <createData entity="SimpleSubCategory" stepKey="subCategory"/> + <createData entity="SimpleProduct" stepKey="simpleProduct1"> + <requiredEntity createDataKey="subCategory"/> + </createData> + <createData entity="SimpleProduct" stepKey="simpleProduct2"> + <requiredEntity createDataKey="subCategory"/> + </createData> + <actionGroup ref = "LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <deleteData stepKey="deleteSimpleSubCategory" createDataKey="subCategory"/> + <deleteData stepKey="deleteSimpleProduct1" createDataKey="simpleProduct1"/> + <deleteData stepKey="deleteSimpleProduct2" createDataKey="simpleProduct2"/> + <deleteData createDataKey="createPriceAttribute" stepKey="deleteAttribute"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!--Update value for price attribute of Product 1--> + <comment userInput="Update value for price attribute of Product 1" stepKey="comment1"/> + <actionGroup ref="navigateToCreatedProductEditPage" stepKey="navigateToCreatedProductEditPage1"> + <argument name="product" value="$$simpleProduct1$$"/> + </actionGroup> + <grabTextFrom selector="{{AdminProductFormSection.attributeLabelByText($$createPriceAttribute.attribute[frontend_labels][0][label]$$)}}" stepKey="grabAttributeLabel"/> + <fillField selector="{{AdminProductAttributeSection.customAttribute($$createPriceAttribute.attribute_code$$)}}" userInput="30" stepKey="fillCustomPrice1"/> + <click selector="{{AdminProductFormSection.save}}" stepKey="clickSaveButton1"/> + <waitForPageLoad stepKey="waitForSimpleProductSaved1"/> + <!--Update value for price attribute of Product 2--> + <comment userInput="Update value for price attribute of Product 1" stepKey="comment2"/> + <actionGroup ref="navigateToCreatedProductEditPage" stepKey="navigateToCreatedProductEditPage2"> + <argument name="product" value="$$simpleProduct2$$"/> + </actionGroup> + <fillField selector="{{AdminProductAttributeSection.customAttribute($$createPriceAttribute.attribute_code$$)}}" userInput="70" stepKey="fillCustomPrice2"/> + <click selector="{{AdminProductFormSection.save}}" stepKey="clickSaveButton2"/> + <waitForPageLoad stepKey="waitForSimpleProductSaved2"/> + <!--Navigate to category on Storefront--> + <comment userInput="Navigate to category on Storefront" stepKey="comment3"/> + <amOnPage url="{{StorefrontCategoryPage.url($$subCategory.name$$)}}" stepKey="goToCategoryStorefront"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <see userInput="{$grabAttributeLabel}" selector="{{StorefrontCategoryFilterSection.CustomPriceAttribute}}" stepKey="seePriceLayerNavigationOnStorefront"/> + </test> +</tests> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php index 7e3de7534e8c4..a79ffcc33cabe 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php @@ -129,7 +129,7 @@ protected function setUp() ->getMock(); $this->connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) ->disableOriginalConstructor() - ->setMethods(['select', 'getIfNullSql', 'quote']) + ->setMethods(['select', 'getIfNullSql', 'quote', 'quoteInto']) ->getMockForAbstractClass(); $this->select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) ->disableOriginalConstructor() @@ -222,9 +222,10 @@ public function testProcessPrice() public function processCategoryIdsDataProvider() { return [ - ['5', 'category_ids_index.category_id = 5'], - [3, 'category_ids_index.category_id = 3'], - ["' and 1 = 0", 'category_ids_index.category_id = 0'], + ['5', "category_ids_index.category_id in ('5')"], + [3, "category_ids_index.category_id in (3)"], + ["' and 1 = 0", "category_ids_index.category_id in ('\' and 1 = 0')"], + [['5', '10'], "category_ids_index.category_id in ('5', '10')"] ]; } @@ -251,6 +252,12 @@ public function testProcessCategoryIds($categoryId, $expectedResult) ->with(\Magento\Catalog\Model\Product::ENTITY, 'category_ids') ->will($this->returnValue($this->attribute)); + $this->connection + ->expects($this->once()) + ->method('quoteInto') + ->with('category_ids_index.category_id in (?)', $categoryId) + ->willReturn($expectedResult); + $actualResult = $this->target->process($this->filter, $isNegation, $query); $this->assertSame($expectedResult, $this->removeWhitespaces($actualResult)); } diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php index abad58a6876d3..f783f75a170e3 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogSearch\Test\Unit\Model\Layer\Filter; @@ -208,6 +209,12 @@ public function testApply() $priceId = '15-50'; $requestVar = 'test_request_var'; + $this->target->setAttributeModel($this->attribute); + $attributeCode = 'price'; + $this->attribute->expects($this->any()) + ->method('getAttributeCode') + ->will($this->returnValue($attributeCode)); + $this->target->setRequestVar($requestVar); $this->request->expects($this->exactly(1)) ->method('getParam') diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php index 683070c286239..10010188c26c9 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php @@ -14,6 +14,7 @@ use Magento\CatalogSearch\Test\Unit\Model\ResourceModel\BaseCollection; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierFactory; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\TotalRecordsResolverFactory; +use PHPUnit\Framework\MockObject\MockObject; /** * Tests Magento\CatalogSearch\Model\ResourceModel\Advanced\Collection @@ -35,32 +36,37 @@ class CollectionTest extends BaseCollection private $advancedCollection; /** - * @var \Magento\Framework\Api\FilterBuilder|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Api\FilterBuilder|MockObject */ private $filterBuilder; /** - * @var \Magento\Framework\Api\Search\SearchCriteriaBuilder|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Api\Search\SearchCriteriaBuilder|MockObject */ private $criteriaBuilder; /** - * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorageFactory|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorageFactory|MockObject */ private $temporaryStorageFactory; /** - * @var \Magento\Search\Api\SearchInterface|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Search\Api\SearchInterface|MockObject */ private $search; /** - * @var \Magento\Eav\Model\Config|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Eav\Model\Config|MockObject */ private $eavConfig; /** - * setUp method for CollectionTest + * @var SearchResultApplierFactory|MockObject + */ + private $searchResultApplierFactory; + + /** + * @inheritdoc */ protected function setUp() { @@ -97,17 +103,10 @@ protected function setUp() ->method('create') ->willReturn($searchCriteriaResolver); - $searchResultApplier = $this->getMockBuilder(SearchResultApplierInterface::class) - ->disableOriginalConstructor() - ->setMethods(['apply']) - ->getMockForAbstractClass(); - $searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class) + $this->searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $searchResultApplierFactory->expects($this->any()) - ->method('create') - ->willReturn($searchResultApplier); $totalRecordsResolver = $this->getMockBuilder(TotalRecordsResolverInterface::class) ->disableOriginalConstructor() @@ -134,12 +133,15 @@ protected function setUp() 'productLimitationFactory' => $productLimitationFactoryMock, 'collectionProvider' => null, 'searchCriteriaResolverFactory' => $searchCriteriaResolverFactory, - 'searchResultApplierFactory' => $searchResultApplierFactory, + 'searchResultApplierFactory' => $this->searchResultApplierFactory, 'totalRecordsResolverFactory' => $totalRecordsResolverFactory ] ); } + /** + * Test to Load data with filter in place + */ public function testLoadWithFilterNoFilters() { $this->advancedCollection->loadWithFilter(); @@ -150,6 +152,7 @@ public function testLoadWithFilterNoFilters() */ public function testLike() { + $pageSize = 10; $attributeCode = 'description'; $attributeCodeId = 42; $attribute = $this->createMock(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class); @@ -168,6 +171,23 @@ public function testLike() $searchResult = $this->createMock(\Magento\Framework\Api\Search\SearchResultInterface::class); $this->search->expects($this->once())->method('search')->willReturn($searchResult); + $this->advancedCollection->setPageSize($pageSize); + $this->advancedCollection->setCurPage(0); + + $searchResultApplier = $this->createMock(SearchResultApplierInterface::class); + $this->searchResultApplierFactory->expects($this->once()) + ->method('create') + ->with( + [ + 'collection' => $this->advancedCollection, + 'searchResult' => $searchResult, + 'orders' => [], + 'size' => $pageSize, + 'currentPage' => 0, + ] + ) + ->willReturn($searchResultApplier); + // addFieldsToFilter will load filters, // then loadWithFilter will trigger _renderFiltersBefore code in Advanced/Collection $this->assertSame( @@ -177,7 +197,7 @@ public function testLike() } /** - * @return \PHPUnit_Framework_MockObject_MockObject + * @return MockObject */ protected function getCriteriaBuilder() { @@ -185,6 +205,7 @@ protected function getCriteriaBuilder() ->setMethods(['addFilter', 'create', 'setRequestName']) ->disableOriginalConstructor() ->getMock(); + return $criteriaBuilder; } } diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php index 9170b81dc3182..9b4010cfae453 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php @@ -5,6 +5,7 @@ */ namespace Magento\CatalogSearch\Test\Unit\Model\ResourceModel\Fulltext; +use Magento\Catalog\Model\ResourceModel\Product\Collection\ProductLimitationFactory; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchCriteriaResolverFactory; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchCriteriaResolverInterface; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierFactory; @@ -12,11 +13,12 @@ use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierInterface; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\TotalRecordsResolverInterface; use Magento\CatalogSearch\Test\Unit\Model\ResourceModel\BaseCollection; +use PHPUnit\Framework\MockObject\MockObject; use Magento\Framework\Search\Adapter\Mysql\TemporaryStorageFactory; -use PHPUnit_Framework_MockObject_MockObject as MockObject; -use Magento\Catalog\Model\ResourceModel\Product\Collection\ProductLimitationFactory; /** + * Test class for Fulltext Collection + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class CollectionTest extends BaseCollection @@ -27,12 +29,12 @@ class CollectionTest extends BaseCollection private $objectManager; /** - * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorage|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorage|MockObject */ private $temporaryStorage; /** - * @var \Magento\Search\Api\SearchInterface|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Search\Api\SearchInterface|MockObject */ private $search; @@ -61,6 +63,11 @@ class CollectionTest extends BaseCollection */ private $filterBuilder; + /** + * @var SearchResultApplierFactory|MockObject + */ + private $searchResultApplierFactory; + /** * @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection */ @@ -72,7 +79,7 @@ class CollectionTest extends BaseCollection private $filter; /** - * setUp method for CollectionTest + * @inheritdoc */ protected function setUp() { @@ -115,17 +122,10 @@ protected function setUp() ->method('create') ->willReturn($searchCriteriaResolver); - $searchResultApplier = $this->getMockBuilder(SearchResultApplierInterface::class) - ->disableOriginalConstructor() - ->setMethods(['apply']) - ->getMockForAbstractClass(); - $searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class) + $this->searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $searchResultApplierFactory->expects($this->any()) - ->method('create') - ->willReturn($searchResultApplier); $totalRecordsResolver = $this->getMockBuilder(TotalRecordsResolverInterface::class) ->disableOriginalConstructor() @@ -148,7 +148,7 @@ protected function setUp() 'temporaryStorageFactory' => $temporaryStorageFactory, 'productLimitationFactory' => $productLimitationFactoryMock, 'searchCriteriaResolverFactory' => $searchCriteriaResolverFactory, - 'searchResultApplierFactory' => $searchResultApplierFactory, + 'searchResultApplierFactory' => $this->searchResultApplierFactory, 'totalRecordsResolverFactory' => $totalRecordsResolverFactory, ] ); @@ -161,6 +161,9 @@ protected function setUp() $this->model->setFilterBuilder($this->filterBuilder); } + /** + * @inheritdoc + */ protected function tearDown() { $reflectionProperty = new \ReflectionProperty(\Magento\Framework\App\ObjectManager::class, '_instance'); @@ -168,16 +171,49 @@ protected function tearDown() $reflectionProperty->setValue(null); } + /** + * Test to Return field faceted data from faceted search result + */ public function testGetFacetedDataWithEmptyAggregations() { + $pageSize = 10; + $searchResult = $this->getMockBuilder(\Magento\Framework\Api\Search\SearchResultInterface::class) ->getMockForAbstractClass(); $this->search->expects($this->once()) ->method('search') ->willReturn($searchResult); + + $searchResultApplier = $this->getMockBuilder(SearchResultApplierInterface::class) + ->disableOriginalConstructor() + ->setMethods(['apply']) + ->getMockForAbstractClass(); + $this->searchResultApplierFactory->expects($this->any()) + ->method('create') + ->willReturn($searchResultApplier); + + $this->model->setPageSize($pageSize); + $this->model->setCurPage(0); + + $this->searchResultApplierFactory->expects($this->once()) + ->method('create') + ->with( + [ + 'collection' => $this->model, + 'searchResult' => $searchResult, + 'orders' => [], + 'size' => $pageSize, + 'currentPage' => 0, + ] + ) + ->willReturn($searchResultApplier); + $this->model->getFacetedData('field'); } + /** + * Test to Apply attribute filter to facet collection + */ public function testAddFieldToFilter() { $this->filter = $this->createFilter(); @@ -220,6 +256,7 @@ protected function getCriteriaBuilder() protected function getFilterBuilder() { $filterBuilder = $this->createMock(\Magento\Framework\Api\FilterBuilder::class); + return $filterBuilder; } @@ -241,6 +278,7 @@ protected function addFiltersToFilterBuilder(MockObject $filterBuilder, array $f ->with($value) ->willReturnSelf(); } + return $filterBuilder; } @@ -252,6 +290,7 @@ protected function createFilter() $filter = $this->getMockBuilder(\Magento\Framework\Api\Filter::class) ->disableOriginalConstructor() ->getMock(); + return $filter; } } diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php index 8157c1fa8fa82..350344372612a 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogSearch\Test\Unit\Model\Search\RequestGenerator; @@ -11,6 +12,9 @@ use Magento\Framework\Search\Request\BucketInterface; use Magento\Framework\Search\Request\FilterInterface; +/** + * Test catalog search range request generator. + */ class DecimalTest extends \PHPUnit\Framework\TestCase { /** @var Decimal */ diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/PriceTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/PriceTest.php new file mode 100644 index 0000000000000..3635430197591 --- /dev/null +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/PriceTest.php @@ -0,0 +1,82 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogSearch\Test\Unit\Model\Search\RequestGenerator; + +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\CatalogSearch\Model\Search\RequestGenerator\Price; +use Magento\Framework\Search\Request\BucketInterface; +use Magento\Framework\Search\Request\FilterInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Test catalog search range request generator. + */ +class PriceTest extends \PHPUnit\Framework\TestCase +{ + /** @var Price */ + private $price; + + /** @var Attribute|\PHPUnit_Framework_MockObject_MockObject */ + private $attribute; + + /** @var \Magento\Framework\App\Config\ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $scopeConfigMock; + + protected function setUp() + { + $this->attribute = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->setMethods(['getAttributeCode']) + ->getMockForAbstractClass(); + $this->scopeConfigMock = $this->getMockBuilder(ScopeConfigInterface::class) + ->setMethods(['getValue']) + ->getMockForAbstractClass(); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->price = $objectManager->getObject( + Price::class, + ['scopeConfig' => $this->scopeConfigMock] + ); + } + + public function testGetFilterData() + { + $filterName = 'test_filter_name'; + $attributeCode = 'test_attribute_code'; + $expected = [ + 'type' => FilterInterface::TYPE_RANGE, + 'name' => $filterName, + 'field' => $attributeCode, + 'from' => '$' . $attributeCode . '.from$', + 'to' => '$' . $attributeCode . '.to$', + ]; + $this->attribute->expects($this->atLeastOnce()) + ->method('getAttributeCode') + ->willReturn($attributeCode); + $actual = $this->price->getFilterData($this->attribute, $filterName); + $this->assertEquals($expected, $actual); + } + + public function testGetAggregationData() + { + $bucketName = 'test_bucket_name'; + $attributeCode = 'test_attribute_code'; + $method = 'price_dynamic_algorithm'; + $expected = [ + 'type' => BucketInterface::TYPE_DYNAMIC, + 'name' => $bucketName, + 'field' => $attributeCode, + 'method' => '$'. $method . '$', + 'metric' => [['type' => 'count']], + ]; + $this->attribute->expects($this->atLeastOnce()) + ->method('getAttributeCode') + ->willReturn($attributeCode); + $actual = $this->price->getAggregationData($this->attribute, $bucketName); + $this->assertEquals($expected, $actual); + } +} diff --git a/app/code/Magento/CatalogSearch/etc/di.xml b/app/code/Magento/CatalogSearch/etc/di.xml index 28d5035308dee..da0a60dad1f77 100644 --- a/app/code/Magento/CatalogSearch/etc/di.xml +++ b/app/code/Magento/CatalogSearch/etc/di.xml @@ -281,6 +281,7 @@ <argument name="defaultGenerator" xsi:type="object">\Magento\CatalogSearch\Model\Search\RequestGenerator\General</argument> <argument name="generators" xsi:type="array"> <item name="decimal" xsi:type="object">Magento\CatalogSearch\Model\Search\RequestGenerator\Decimal</item> + <item name="price" xsi:type="object">Magento\CatalogSearch\Model\Search\RequestGenerator\Price</item> </argument> </arguments> </type> diff --git a/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/UpdateUrlKeySearchable.php b/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/UpdateUrlKeySearchable.php new file mode 100644 index 0000000000000..75f88a8573069 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/UpdateUrlKeySearchable.php @@ -0,0 +1,79 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\CatalogUrlRewrite\Setup\Patch\Data; + +use Magento\Framework\Setup\ModuleDataSetupInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Catalog\Setup\CategorySetupFactory; + +/** + * Update url_key to be searchable + */ +class UpdateUrlKeySearchable implements DataPatchInterface +{ + /** + * @var ModuleDataSetupInterface + */ + private $moduleDataSetup; + + /** + * @var CategorySetupFactory + */ + private $categorySetupFactory; + + /** + * @param ModuleDataSetupInterface $moduleDataSetup + * @param CategorySetupFactory $categorySetupFactory + */ + public function __construct( + ModuleDataSetupInterface $moduleDataSetup, + CategorySetupFactory $categorySetupFactory + ) { + $this->moduleDataSetup = $moduleDataSetup; + $this->categorySetupFactory = $categorySetupFactory; + } + + /** + * @inheritdoc + */ + public function apply() + { + /** @var CategorySetup $categorySetup */ + $categorySetup = $this->categorySetupFactory->create(['setup' => $this->moduleDataSetup]); + + $categorySetup->updateAttribute( + \Magento\Catalog\Model\Product::ENTITY, + 'url_key', + 'is_searchable', + true + ); + + $categorySetup->updateAttribute( + \Magento\Catalog\Model\Category::ENTITY, + 'url_key', + 'is_searchable', + true + ); + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return [CreateUrlAttributes::class]; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/CategoryUrlSuffix.php b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/CategoryUrlSuffix.php new file mode 100644 index 0000000000000..59708d90c23b7 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/CategoryUrlSuffix.php @@ -0,0 +1,82 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogUrlRewriteGraphQl\Model\Resolver; + +use Magento\Store\Api\Data\StoreInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Returns the url suffix for category + */ +class CategoryUrlSuffix implements ResolverInterface +{ + /** + * System setting for the url suffix for categories + * + * @var string + */ + private static $xml_path_category_url_suffix = 'catalog/seo/category_url_suffix'; + + /** + * Cache for product rewrite suffix + * + * @var array + */ + private $categoryUrlSuffix = []; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @param ScopeConfigInterface $scopeConfig + */ + public function __construct(ScopeConfigInterface $scopeConfig) + { + $this->scopeConfig = $scopeConfig; + } + + /** + * @inheritdoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): string { + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + $storeId = (int)$store->getId(); + return $this->getCategoryUrlSuffix($storeId); + } + + /** + * Retrieve category url suffix by store + * + * @param int $storeId + * @return string + */ + private function getCategoryUrlSuffix(int $storeId): string + { + if (!isset($this->categoryUrlSuffix[$storeId])) { + $this->categoryUrlSuffix[$storeId] = $this->scopeConfig->getValue( + self::$xml_path_category_url_suffix, + ScopeInterface::SCOPE_STORE, + $storeId + ); + } + return $this->categoryUrlSuffix[$storeId]; + } +} diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/ProductUrlSuffix.php b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/ProductUrlSuffix.php new file mode 100644 index 0000000000000..9a0193ba36367 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/ProductUrlSuffix.php @@ -0,0 +1,82 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogUrlRewriteGraphQl\Model\Resolver; + +use Magento\Store\Api\Data\StoreInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Returns the url suffix for product + */ +class ProductUrlSuffix implements ResolverInterface +{ + /** + * System setting for the url suffix for products + * + * @var string + */ + private static $xml_path_product_url_suffix = 'catalog/seo/product_url_suffix'; + + /** + * Cache for product rewrite suffix + * + * @var array + */ + private $productUrlSuffix = []; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @param ScopeConfigInterface $scopeConfig + */ + public function __construct(ScopeConfigInterface $scopeConfig) + { + $this->scopeConfig = $scopeConfig; + } + + /** + * @inheritdoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): string { + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + $storeId = (int)$store->getId(); + return $this->getProductUrlSuffix($storeId); + } + + /** + * Retrieve product url suffix by store + * + * @param int $storeId + * @return string + */ + private function getProductUrlSuffix(int $storeId): string + { + if (!isset($this->productUrlSuffix[$storeId])) { + $this->productUrlSuffix[$storeId] = $this->scopeConfig->getValue( + self::$xml_path_product_url_suffix, + ScopeInterface::SCOPE_STORE, + $storeId + ); + } + return $this->productUrlSuffix[$storeId]; + } +} diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json index e276da0cc6fd8..202c573c2ae04 100644 --- a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json @@ -4,6 +4,7 @@ "type": "magento2-module", "require": { "php": "~7.1.3||~7.2.0||~7.3.0", + "magento/module-store": "*", "magento/module-catalog": "*", "magento/framework": "*" }, diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml index 20e6b7e9c0053..e99f89477e807 100644 --- a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml @@ -14,4 +14,12 @@ </argument> </arguments> </type> + + <type name="Magento\CatalogGraphQl\Plugin\Search\Request\ConfigReader"> + <arguments> + <argument name="exactMatchAttributes" xsi:type="array"> + <item name="url_key" xsi:type="string">url_key</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls index 89108e578d673..82facf6959f3c 100644 --- a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls @@ -3,15 +3,24 @@ interface ProductInterface { url_key: String @doc(description: "The part of the URL that identifies the product") + url_suffix: String @doc(description: "The part of the product URL that is appended after the url key") @resolver(class: "Magento\\CatalogUrlRewriteGraphQl\\Model\\Resolver\\ProductUrlSuffix") url_path: String @deprecated(reason: "Use product's `canonical_url` or url rewrites instead") url_rewrites: [UrlRewrite] @doc(description: "URL rewrites list") @resolver(class: "Magento\\UrlRewriteGraphQl\\Model\\Resolver\\UrlRewrite") } +interface CategoryInterface { + url_suffix: String @doc(description: "The part of the category URL that is appended after the url key") @resolver(class: "Magento\\CatalogUrlRewriteGraphQl\\Model\\Resolver\\CategoryUrlSuffix") +} + input ProductFilterInput { url_key: FilterTypeInput @doc(description: "The part of the URL that identifies the product") url_path: FilterTypeInput @deprecated(reason: "Use product's `canonical_url` or url rewrites instead") } +input ProductAttributeFilterInput { + url_key: FilterEqualTypeInput @doc(description: "The part of the URL that identifies the product") +} + input ProductSortInput { url_key: SortEnum @doc(description: "The part of the URL that identifies the product") url_path: SortEnum @deprecated(reason: "Use product's `canonical_url` or url rewrites instead") diff --git a/app/code/Magento/CatalogWidget/composer.json b/app/code/Magento/CatalogWidget/composer.json index 6722d0df93752..8c1bd220a0f32 100644 --- a/app/code/Magento/CatalogWidget/composer.json +++ b/app/code/Magento/CatalogWidget/composer.json @@ -14,7 +14,8 @@ "magento/module-rule": "*", "magento/module-store": "*", "magento/module-widget": "*", - "magento/module-wishlist": "*" + "magento/module-wishlist": "*", + "magento/module-theme": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml index e74f5c24fb4f6..fe5887bbf6f7c 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml @@ -16,9 +16,7 @@ <argument name="shipping" type="string"/> </arguments> - <waitForElementVisible selector="{{CheckoutCartSummarySection.shipping}}" stepKey="waitForElementToBeVisible" after="assertSubtotal"/> - <reloadPage stepKey="reloadPage" after="waitForElementToBeVisible" /> - <waitForPageLoad after="reloadPage" stepKey="WaitForPageLoaded" /> - <waitForText userInput="{{shipping}}" selector="{{CheckoutCartSummarySection.shipping}}" time="30" stepKey="assertShipping" after="WaitForPageLoaded"/> + <waitForElementVisible selector="{{CheckoutCartSummarySection.shipping}}" time="60" after="assertSubtotal" stepKey="waitForShippingElementToBeVisible"/> + <waitForText userInput="{{shipping}}" selector="{{CheckoutCartSummarySection.shipping}}" time="30" after="waitForShippingElementToBeVisible" stepKey="assertShipping"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml index 09608eef7178a..e3090d6cb311b 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml @@ -20,6 +20,9 @@ <before> <magentoCLI command="config:set {{EnableFlatRateConfigData.path}} {{EnableFlatRateConfigData.value}}" stepKey="enableFlatRate"/> <magentoCLI command="config:set {{EnableFlatRateDefaultPriceConfigData.path}} {{EnableFlatRateDefaultPriceConfigData.value}}" stepKey="enableFlatRatePrice"/> + <magentoCLI command="config:set {{EnableFlatRateToAllAllowedCountriesConfigData.path}} {{EnableFlatRateToAllAllowedCountriesConfigData.value}}" stepKey="allowFlatRateToAllCountries"/> + <createData entity="FreeShippinMethodDefault" stepKey="disableFreeShipping"/> + <!-- Create Default Category --> <createData entity="_defaultCategory" stepKey="createCategory"/> diff --git a/app/code/Magento/Checkout/etc/frontend/sections.xml b/app/code/Magento/Checkout/etc/frontend/sections.xml index 90c2878f501cf..021cd930c74c0 100644 --- a/app/code/Magento/Checkout/etc/frontend/sections.xml +++ b/app/code/Magento/Checkout/etc/frontend/sections.xml @@ -9,6 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Customer:etc/sections.xsd"> <action name="checkout/cart/add"> <section name="cart"/> + <section name="directory-data"/> </action> <action name="checkout/cart/delete"> <section name="cart"/> @@ -41,7 +42,6 @@ </action> <action name="rest/*/V1/carts/*/payment-information"> <section name="cart"/> - <section name="checkout-data"/> <section name="last-ordered-items"/> </action> <action name="rest/*/V1/guest-carts/*/payment-information"> diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/minicart.js b/app/code/Magento/Checkout/view/frontend/web/js/view/minicart.js index d152f94397730..4adc1cd88c0ae 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/minicart.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/minicart.js @@ -103,8 +103,8 @@ define([ }); if ( - cartData().website_id !== window.checkout.websiteId && - cartData().website_id !== undefined + cartData().website_id !== window.checkout.websiteId && cartData().website_id !== undefined || + cartData().storeId !== window.checkout.storeId && cartData().storeId !== undefined ) { customerData.reload(['cart'], false); } diff --git a/app/code/Magento/CheckoutAgreements/etc/db_schema.xml b/app/code/Magento/CheckoutAgreements/etc/db_schema.xml index 09cd1c5b63965..43da8d7d27dd9 100644 --- a/app/code/Magento/CheckoutAgreements/etc/db_schema.xml +++ b/app/code/Magento/CheckoutAgreements/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="checkout_agreement" resource="default" engine="innodb" comment="Checkout Agreement"> <column xsi:type="int" name="agreement_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Agreement Id"/> + comment="Agreement ID"/> <column xsi:type="varchar" name="name" nullable="true" length="255" comment="Name"/> <column xsi:type="text" name="content" nullable="true" comment="Content"/> <column xsi:type="varchar" name="content_height" nullable="true" length="25" comment="Content Height"/> @@ -26,9 +26,9 @@ </table> <table name="checkout_agreement_store" resource="default" engine="innodb" comment="Checkout Agreement Store"> <column xsi:type="int" name="agreement_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Agreement Id"/> + comment="Agreement ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="agreement_id"/> <column name="store_id"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGBlockTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGBlockTest.xml index 5baf75d43c53f..03edc69e6d625 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGBlockTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGBlockTest.xml @@ -16,9 +16,6 @@ <description value="Admin should be able to add image to WYSIWYG content of Block"/> <severity value="CRITICAL"/> <testCaseId value="MAGETWO-84376"/> - <skip> - <issueId value="MC-17232"/> - </skip> </annotations> <before> <createData entity="_defaultCmsPage" stepKey="createCMSPage" /> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGCMSTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGCMSTest.xml index e63a6be51bcc0..205850f888797 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGCMSTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddImageToWYSIWYGCMSTest.xml @@ -16,9 +16,6 @@ <description value="Admin should be able to add image to WYSIWYG content of CMS Page"/> <severity value="CRITICAL"/> <testCaseId value="MAGETWO-85825"/> - <skip> - <issueId value="MC-17232"/> - </skip> </annotations> <before> <createData entity="_defaultCmsPage" stepKey="createCMSPage" /> diff --git a/app/code/Magento/Config/Model/Config/Backend/Encrypted.php b/app/code/Magento/Config/Model/Config/Backend/Encrypted.php index 62d6531978d8a..80ce061a0a17e 100644 --- a/app/code/Magento/Config/Model/Config/Backend/Encrypted.php +++ b/app/code/Magento/Config/Model/Config/Backend/Encrypted.php @@ -48,6 +48,9 @@ public function __construct( * Magic method called during class serialization * * @return string[] + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -59,6 +62,9 @@ public function __sleep() * Magic method called during class un-serialization * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Config/Model/Config/Backend/File/Pdf.php b/app/code/Magento/Config/Model/Config/Backend/File/Pdf.php new file mode 100644 index 0000000000000..8716fe5a23ad3 --- /dev/null +++ b/app/code/Magento/Config/Model/Config/Backend/File/Pdf.php @@ -0,0 +1,21 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Config\Model\Config\Backend\File; + +/** + * System config PDF field backend model. + */ +class Pdf extends \Magento\Config\Model\Config\Backend\File +{ + /** + * @inheritdoc + */ + protected function _getAllowedExtensions() + { + return ['pdf']; + } +} diff --git a/app/code/Magento/Config/Model/Config/Backend/Image/Pdf.php b/app/code/Magento/Config/Model/Config/Backend/Image/Pdf.php index 44131fe8a7966..a5a81a4dde75d 100644 --- a/app/code/Magento/Config/Model/Config/Backend/Image/Pdf.php +++ b/app/code/Magento/Config/Model/Config/Backend/Image/Pdf.php @@ -4,24 +4,24 @@ * See COPYING.txt for license details. */ -/** - * System config image field backend model for Zend PDF generator - * - * @author Magento Core Team <core@magentocommerce.com> - */ namespace Magento\Config\Model\Config\Backend\Image; /** + * System config PDF field backend model. + * * @api * @since 100.0.2 + * @see \Magento\Config\Model\Config\Backend\File\Pdf */ class Pdf extends \Magento\Config\Model\Config\Backend\Image { /** + * Returns the list of allowed file extensions. + * * @return string[] */ protected function _getAllowedExtensions() { - return ['tif', 'tiff', 'png', 'jpg', 'jpe', 'jpeg']; + return ['tif', 'tiff', 'png', 'jpg', 'jpe', 'jpeg', 'pdf']; } } diff --git a/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminExpandConfigTabActionGroup.xml b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminExpandConfigTabActionGroup.xml new file mode 100644 index 0000000000000..79505e0627865 --- /dev/null +++ b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminExpandConfigTabActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminExpandConfigTabActionGroup"> + <annotations> + <description>Goes to the 'Configuration' page and expands main level configuration tab passed via argument as Tab Name.</description> + </annotations> + <arguments> + <argument name="tabName" type="string"/> + </arguments> + + <scrollTo stepKey="scrollToTab" selector="{{AdminConfigSection.collapsibleTabByTitle(tabName)}}" x="0" y="-80"/> + <conditionalClick selector="{{AdminConfigSection.collapsibleTabByTitle(tabName)}}" dependentSelector="{{AdminConfigSection.expandedTabByTitle(tabName)}}" visible="false" stepKey="expandTab" /> + <waitForElement selector="{{AdminConfigSection.expandedTabByTitle(tabName)}}" stepKey="waitOpenedTab" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenConfigNavItemActionGroup.xml b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenConfigNavItemActionGroup.xml new file mode 100644 index 0000000000000..eaca27f86f49a --- /dev/null +++ b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenConfigNavItemActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenConfigNavItemActionGroup"> + <annotations> + <description>Clicks on config nav item selected by passed argument.</description> + </annotations> + <arguments> + <argument name="navItem" type="string"/> + </arguments> + + <scrollTo stepKey="scrollToNavItem" selector="{{AdminConfigSection.navItemByTitle(navItem)}}" x="0" y="-80"/> + <click selector="{{AdminConfigSection.navItemByTitle(navItem)}}" stepKey="openNavItem" /> + <waitForElement selector="{{AdminConfigSection.activeNavItemByTitle(navItem)}}" stepKey="waitActiveNavItem" /> + <waitForPageLoad stepKey="waitForPageLoad" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenStoreConfigDeveloperPageActionGroup.xml b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenStoreConfigDeveloperPageActionGroup.xml new file mode 100644 index 0000000000000..4c5d21a890973 --- /dev/null +++ b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenStoreConfigDeveloperPageActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenStoreConfigDeveloperPageActionGroup"> + <annotations> + <description>Go to admin store configuration developer page.</description> + </annotations> + + <amOnPage url="{{AdminConfigDeveloperPage.url}}" stepKey="openAdminStoreConfigDeveloperPage" /> + <waitForPageLoad stepKey="waitForPageLoad" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenStoreConfigPageActionGroup.xml b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenStoreConfigPageActionGroup.xml new file mode 100644 index 0000000000000..43343fd0851e4 --- /dev/null +++ b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminOpenStoreConfigPageActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenStoreConfigPageActionGroup"> + <annotations> + <description>Go to admin store configuration page.</description> + </annotations> + + <amOnPage url="{{AdminConfigPage.url}}" stepKey="openAdminStoreConfigPage" /> + <waitForPageLoad stepKey="waitForPageLoad" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminSaveConfigActionGroup.xml b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminSaveConfigActionGroup.xml index 6ed0cfe95cb94..bd23292d3ee6a 100644 --- a/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminSaveConfigActionGroup.xml +++ b/app/code/Magento/Config/Test/Mftf/ActionGroup/AdminSaveConfigActionGroup.xml @@ -10,7 +10,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminSaveConfigActionGroup"> <click selector="{{AdminConfigSection.saveButton}}" stepKey="clickSaveConfigBtn"/> - <waitForPageLoad stepKey="waitForPageLoad"/> - <see selector="{{AdminCategoryMessagesSection.SuccessMessage}}" userInput="You saved the configuration." stepKey="seeSuccessMessage"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitForSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the configuration." stepKey="seeSuccessMessage"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Config/Test/Mftf/ActionGroup/GeneralConfigurationActionGroup.xml b/app/code/Magento/Config/Test/Mftf/ActionGroup/GeneralConfigurationActionGroup.xml index 45d84a338a30b..d65376828e2c4 100644 --- a/app/code/Magento/Config/Test/Mftf/ActionGroup/GeneralConfigurationActionGroup.xml +++ b/app/code/Magento/Config/Test/Mftf/ActionGroup/GeneralConfigurationActionGroup.xml @@ -27,7 +27,6 @@ <amOnPage url="{{AdminConfigGeneralPage.url}}" stepKey="navigateToConfigGeneralPage"/> <waitForPageLoad stepKey="waitForConfigPageLoad"/> </actionGroup> - <actionGroup name="SelectTopDestinationsCountry"> <annotations> <description>Selects the provided Countries under 'Top destinations' on the 'General' section of the 'Configuration' page. Clicks on the Save button.</description> diff --git a/app/code/Magento/Config/Test/Mftf/Page/AdminConfigPage.xml b/app/code/Magento/Config/Test/Mftf/Page/AdminConfigPage.xml index 7a62dfff8323b..8fafdc202bf09 100644 --- a/app/code/Magento/Config/Test/Mftf/Page/AdminConfigPage.xml +++ b/app/code/Magento/Config/Test/Mftf/Page/AdminConfigPage.xml @@ -21,4 +21,7 @@ <page name="AdminConfigGeneralPage" url="admin/system_config/edit/section/general/" area="admin" module="Magento_Config"> <section name="GeneralSection"/> </page> + <page name="AdminConfigDeveloperPage" url="admin/system_config/edit/section/dev/" area="admin" module="Magento_Config"> + <section name="AdminConfigSection" /> + </page> </pages> diff --git a/app/code/Magento/Config/Test/Mftf/Section/AdminConfigSection.xml b/app/code/Magento/Config/Test/Mftf/Section/AdminConfigSection.xml index fd49c1482c133..ffe3f0076ca8d 100644 --- a/app/code/Magento/Config/Test/Mftf/Section/AdminConfigSection.xml +++ b/app/code/Magento/Config/Test/Mftf/Section/AdminConfigSection.xml @@ -7,6 +7,11 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminConfigSection"> + <element name="collapsibleTabByTitle" type="button" selector="//div[@id='system_config_tabs']//div[@data-role='title'][contains(.,'{{tabTitle}}')]" parameterized="true" /> + <element name="expandedTabByTitle" type="button" selector="//div[@id='system_config_tabs']//div[@data-role='title'][@aria-expanded='true'][contains(.,'{{tabTitle}}')]" parameterized="true" /> + <element name="notExpandedTabByTitle" type="button" selector="//div[@id='system_config_tabs']//div[@data-role='title'][@aria-expanded='false'][contains(.,'{{tabTitle}}')]" parameterized="true" /> + <element name="navItemByTitle" type="button" selector="//div[@id='system_config_tabs']//div[@role='tablist']//li[contains(@class, 'nav-item')][contains(.,'{{navItem}}')]" parameterized="true" /> + <element name="activeNavItemByTitle" type="button" selector="//div[@id='system_config_tabs']//div[@role='tablist']//li[contains(@class, 'nav-item')][contains(@class, '_active')][contains(.,'{{navItem}}')]" parameterized="true" /> <element name="saveButton" type="button" selector="#save"/> <element name="generalTab" type="text" selector="//div[@class='admin__page-nav-title title _collapsible']//strong[text()='General']"/> <element name="generalTabClosed" type="text" selector="//div[@class='admin__page-nav-title title _collapsible' and @aria-expanded='false' or @aria-expanded='0']//strong[text()='General']"/> diff --git a/app/code/Magento/Config/etc/db_schema.xml b/app/code/Magento/Config/etc/db_schema.xml index 8aeac802fbd91..46dd77959b9d4 100644 --- a/app/code/Magento/Config/etc/db_schema.xml +++ b/app/code/Magento/Config/etc/db_schema.xml @@ -9,10 +9,10 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="core_config_data" resource="default" engine="innodb" comment="Config Data"> <column xsi:type="int" name="config_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Config Id"/> + comment="Config ID"/> <column xsi:type="varchar" name="scope" nullable="false" length="8" default="default" comment="Config Scope"/> <column xsi:type="int" name="scope_id" padding="11" unsigned="false" nullable="false" identity="false" - default="0" comment="Config Scope Id"/> + default="0" comment="Config Scope ID"/> <column xsi:type="varchar" name="path" nullable="false" length="255" default="general" comment="Config Path"/> <column xsi:type="text" name="value" nullable="true" comment="Config Value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> diff --git a/app/code/Magento/Config/i18n/en_US.csv b/app/code/Magento/Config/i18n/en_US.csv index 9770bf4b94c27..ceb1efdc8b77d 100644 --- a/app/code/Magento/Config/i18n/en_US.csv +++ b/app/code/Magento/Config/i18n/en_US.csv @@ -118,3 +118,4 @@ Dashboard,Dashboard "Web Section","Web Section" "Store Email Addresses Section","Store Email Addresses Section" "Email to a Friend","Email to a Friend" +"Taiwan","Taiwan, Province of China" diff --git a/app/code/Magento/ConfigurableProduct/Model/Plugin/Frontend/UsedProductsCache.php b/app/code/Magento/ConfigurableProduct/Model/Plugin/Frontend/UsedProductsCache.php new file mode 100644 index 0000000000000..19a1b8d3ca17f --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/Plugin/Frontend/UsedProductsCache.php @@ -0,0 +1,190 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model\Plugin\Frontend; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\Data\ProductInterfaceFactory; +use Magento\Catalog\Model\Category; +use Magento\Catalog\Model\Product; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Customer\Model\Session; +use Magento\Framework\Cache\FrontendInterface; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Serialize\SerializerInterface; + +/** + * Cache of used products for configurable product + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ +class UsedProductsCache +{ + /** + * @var MetadataPool + */ + private $metadataPool; + + /** + * @var FrontendInterface + */ + private $cache; + + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @var ProductInterfaceFactory + */ + private $productFactory; + + /** + * @var Session + */ + private $customerSession; + + /** + * @param MetadataPool $metadataPool + * @param FrontendInterface $cache + * @param SerializerInterface $serializer + * @param ProductInterfaceFactory $productFactory + * @param Session $customerSession + */ + public function __construct( + MetadataPool $metadataPool, + FrontendInterface $cache, + SerializerInterface $serializer, + ProductInterfaceFactory $productFactory, + Session $customerSession + ) { + $this->metadataPool = $metadataPool; + $this->cache = $cache; + $this->serializer = $serializer; + $this->productFactory = $productFactory; + $this->customerSession = $customerSession; + } + + /** + * Retrieve used products for configurable product + * + * @param Configurable $subject + * @param callable $proceed + * @param Product $product + * @param array|null $requiredAttributeIds + * @return ProductInterface[] + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function aroundGetUsedProducts( + Configurable $subject, + callable $proceed, + $product, + $requiredAttributeIds = null + ) { + $cacheKey = $this->getCacheKey($product, $requiredAttributeIds); + $usedProducts = $this->readUsedProductsCacheData($cacheKey); + if ($usedProducts === null) { + $usedProducts = $proceed($product, $requiredAttributeIds); + $this->saveUsedProductsCacheData($product, $usedProducts, $cacheKey); + } + + return $usedProducts; + } + + /** + * Generate cache key for product + * + * @param Product $product + * @param array|null $requiredAttributeIds + * @return string + */ + private function getCacheKey($product, $requiredAttributeIds = null): string + { + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); + $keyParts = [ + 'getUsedProducts', + $product->getData($metadata->getLinkField()), + $product->getStoreId(), + $this->customerSession->getCustomerGroupId(), + ]; + if ($requiredAttributeIds !== null) { + sort($requiredAttributeIds); + $keyParts[] = implode('', $requiredAttributeIds); + } + $cacheKey = sha1(implode('_', $keyParts)); + + return $cacheKey; + } + + /** + * Read used products data from cache + * + * Looking for cache record stored under provided $cacheKey + * In case data exists turns it into array of products + * + * @param string $cacheKey + * @return ProductInterface[]|null + */ + private function readUsedProductsCacheData(string $cacheKey): ?array + { + $data = $this->cache->load($cacheKey); + if (!$data) { + return null; + } + + $items = $this->serializer->unserialize($data); + if (!$items) { + return null; + } + + $usedProducts = []; + foreach ($items as $item) { + /** @var Product $productItem */ + $productItem = $this->productFactory->create(); + $productItem->setData($item); + $usedProducts[] = $productItem; + } + + return $usedProducts; + } + + /** + * Save $subProducts to cache record identified with provided $cacheKey + * + * Cached data will be tagged with combined list of product tags and data specific tags i.e. 'price' etc. + * + * @param Product $product + * @param ProductInterface[] $subProducts + * @param string $cacheKey + * @return bool + */ + private function saveUsedProductsCacheData(Product $product, array $subProducts, string $cacheKey): bool + { + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); + $data = $this->serializer->serialize( + array_map( + function ($item) { + return $item->getData(); + }, + $subProducts + ) + ); + $tags = array_merge( + $product->getIdentities(), + [ + Category::CACHE_TAG, + Product::CACHE_TAG, + 'price', + Configurable::TYPE_CODE . '_' . $product->getData($metadata->getLinkField()), + ] + ); + $result = $this->cache->save($data, $cacheKey, $tags); + + return (bool) $result; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php index a849d964eaed5..c60953e33e9eb 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php @@ -1233,28 +1233,22 @@ public function isPossibleBuyFromList($product) * Returns array of sub-products for specified configurable product * * $requiredAttributeIds - one dimensional array, if provided - * * Result array contains all children for specified configurable product * - * @param \Magento\Catalog\Model\Product $product - * @param array $requiredAttributeIds + * @param \Magento\Catalog\Model\Product $product + * @param array $requiredAttributeIds * @return ProductInterface[] + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function getUsedProducts($product, $requiredAttributeIds = null) { - $metadata = $this->getMetadataPool()->getMetadata(ProductInterface::class); - $keyParts = [ - __METHOD__, - $product->getData($metadata->getLinkField()), - $product->getStoreId(), - $this->getCustomerSession()->getCustomerGroupId() - ]; - if ($requiredAttributeIds !== null) { - sort($requiredAttributeIds); - $keyParts[] = implode('', $requiredAttributeIds); + if (!$product->hasData($this->_usedProducts)) { + $collection = $this->getConfiguredUsedProductCollection($product, false); + $usedProducts = array_values($collection->getItems()); + $product->setData($this->_usedProducts, $usedProducts); } - $cacheKey = $this->getUsedProductsCacheKey($keyParts); - return $this->loadUsedProducts($product, $cacheKey); + + return $product->getData($this->_usedProducts); } /** diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Attribute.php b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Attribute.php index b013916cc221a..01549ffcd2755 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Attribute.php +++ b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Attribute.php @@ -113,11 +113,12 @@ public function afterSave() } /** - * Load configurable attribute by product and product's attribute + * Load configurable attribute by product and product's attribute. * * @param \Magento\Catalog\Model\Product $product * @param \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute * @throws LocalizedException + * @return void */ public function loadByProductAndAttribute($product, $attribute) { @@ -263,6 +264,9 @@ public function setProductId($value) /** * @inheritdoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -274,6 +278,9 @@ public function __sleep() /** * @inheritdoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute/Collection.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute/Collection.php index 8f2cc6ddb43ce..3aa90c7b3ce57 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute/Collection.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute/Collection.php @@ -302,7 +302,9 @@ protected function _loadLabels() } /** - * Load attribute options. + * Load related options' data. + * + * @return void */ protected function loadOptions() { @@ -355,6 +357,9 @@ protected function getIncludedOptions(array $usedProducts, AbstractAttribute $pr /** * @inheritdoc * @since 100.0.6 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -374,6 +379,9 @@ public function __sleep() /** * @inheritdoc * @since 100.0.6 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Data/ConfigurableProductData.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Data/ConfigurableProductData.xml index de6714a9b959e..3f21c98068d8a 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Data/ConfigurableProductData.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Data/ConfigurableProductData.xml @@ -78,4 +78,10 @@ <requiredEntity type="product_extension_attribute">EavStockItem</requiredEntity> <requiredEntity type="custom_attribute_array">CustomAttributeCategoryIds</requiredEntity> </entity> + <!-- Configurable product from file "export_import_configurable_product.csv"--> + <entity name="ApiConfigurableExportImportProduct" extends="ApiConfigurableProduct" type="product"> + <data key="sku">api-configurable-export-import-product</data> + <data key="name">API Configurable Export Import Product</data> + <data key="urlKey">api-configurable-export-import-product</data> + </entity> </entities> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml index 1defecbc7c285..336f95aa55576 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml @@ -39,6 +39,9 @@ <element name="variationLabel" type="text" selector="//div[@data-index='configurable-matrix']/label"/> <element name="stepsWizardTitle" type="text" selector="div.content:not([style='display: none;']) .steps-wizard-title"/> <element name="attributeEntityByName" type="text" selector="//div[@class='attribute-entity']//div[normalize-space(.)='{{attributeLabel}}']" parameterized="true"/> + <element name="fileUploaderInput" type="file" selector="//input[@type='file' and @class='file-uploader-input']" /> + <element name="variationImageSource" type="text" selector="[data-index='configurable-matrix'] [data-index='thumbnail_image_container'] img[src*='{{imageName}}']" parameterized="true"/> + <element name="variationProductLinkByName" type="text" selector="//div[@data-index='configurable-matrix']//*[@data-index='name_container']//a[contains(text(), '{{productName}}')]" parameterized="true"/> </section> <section name="AdminConfigurableProductFormSection"> <element name="productWeight" type="input" selector=".admin__control-text[name='product[weight]']"/> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml new file mode 100644 index 0000000000000..fa21d20eb4456 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml @@ -0,0 +1,181 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminSimpleProductTypeSwitchingToConfigurableProductTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="Product type switching"/> + <title value="Simple product type switching on editing to configurable product"/> + <description value="Simple product type switching on editing to configurable product"/> + <testCaseId value="MAGETWO-29633"/> + <useCaseId value="MAGETWO-44170"/> + <severity value="MAJOR"/> + <group value="catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create product--> + <comment userInput="Create product" stepKey="commentCreateProduct"/> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + <!--Create attribute with options--> + <comment userInput="Create attribute with options" stepKey="commentCreateAttributeWithOptions"/> + <createData entity="productAttributeWithTwoOptions" stepKey="createConfigProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createConfigProductAttributeOptionOne"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption2" stepKey="createConfigProductAttributeOptionTwo"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + </before> + <after> + <!--Delete product--> + <comment userInput="Delete product" stepKey="commentDeleteProduct"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteAttribute"/> + <actionGroup ref="deleteAllDuplicateProductUsingProductGrid" stepKey="deleteAllDuplicateProducts"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearProductFilters"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!--Add configurations to product--> + <comment userInput="Add configurations to product" stepKey="commentAddConfigs"/> + <amOnPage url="{{AdminProductEditPage.url($$createProduct.id$$)}}" stepKey="gotToSimpleProductPage"/> + <waitForPageLoad stepKey="waitForSimpleProductPageLoad"/> + <actionGroup ref="generateConfigurationsByAttributeCode" stepKey="setupConfigurations"> + <argument name="attributeCode" value="$$createConfigProductAttribute.attribute_code$$"/> + </actionGroup> + <actionGroup ref="saveConfiguredProduct" stepKey="saveConfigProductForm"/> + <!--Assert configurable product on Admin product page grid--> + <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertConfigProductOnAdmin"/> + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> + <actionGroup ref="filterProductGridBySku2" stepKey="filterProductGridBySku"> + <argument name="sku" value="$$createProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Name')}}" userInput="$$createProduct.name$$" stepKey="seeProductNameInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Type')}}" userInput="Configurable Product" stepKey="seeProductTypeInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('2', 'Name')}}" userInput="$$createProduct.name$$-option1" stepKey="seeProductNameInGrid1"/> + <see selector="{{AdminProductGridSection.productGridCell('3', 'Name')}}" userInput="$$createProduct.name$$-option2" stepKey="seeProductNameInGrid2"/> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearProductFilters"/> + <!--Assert configurable product on storefront--> + <comment userInput="Assert configurable product on storefront" stepKey="commentAssertConfigProductOnStorefront"/> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="openProductPage"/> + <waitForPageLoad stepKey="waitForStorefrontProductPageLoad"/> + <see userInput="IN STOCK" selector="{{StorefrontProductInfoMainSection.productStockStatus}}" stepKey="assertInStock"/> + <click selector="{{StorefrontProductInfoMainSection.productAttributeOptionsSelectButton}}" stepKey="clickAttributeDropDown"/> + <see userInput="option1" stepKey="verifyOption1Exists"/> + <see userInput="option2" stepKey="verifyOption2Exists"/> + </test> + <test name="AdminConfigurableProductTypeSwitchingToVirtualProductTest" extends="AdminSimpleProductTypeSwitchingToConfigurableProductTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="Product type switching"/> + <title value="Configurable product type switching on editing to virtual product"/> + <description value="Configurable product type switching on editing to virtual product"/> + <testCaseId value="MC-17952"/> + <useCaseId value="MAGETWO-44170"/> + <severity value="MAJOR"/> + <group value="catalog"/> + </annotations> + <!--Delete product configurations--> + <comment userInput="Delete product configuration" stepKey="commentDeleteConfigs"/> + <amOnPage url="{{AdminProductEditPage.url($$createProduct.id$$)}}" stepKey="gotToConfigProductPage"/> + <waitForPageLoad stepKey="waitForConfigurableProductPageLoad"/> + <conditionalClick selector="{{ AdminProductFormConfigurationsSection.sectionHeader}}" dependentSelector="{{AdminProductFormConfigurationsSection.createConfigurations}}" visible="false" stepKey="openConfigurationSection"/> + <click selector="{{AdminProductFormConfigurationsSection.actionsBtn('1')}}" stepKey="clickToExpandOption1Actions"/> + <click selector="{{AdminProductFormConfigurationsSection.removeProductBtn}}" stepKey="clickRemoveOption1"/> + <click selector="{{AdminProductFormConfigurationsSection.actionsBtn('1')}}" stepKey="clickToExpandOption2Actions"/> + <click selector="{{AdminProductFormConfigurationsSection.removeProductBtn}}" stepKey="clickRemoveOption2"/> + <fillField selector="{{AdminProductFormSection.productPrice}}" userInput="{{SimpleProduct2.price}}" stepKey="fillProductPrice"/> + <fillField selector="{{AdminProductFormSection.productQuantity}}" userInput="{{SimpleProduct2.quantity}}" stepKey="fillProductQty"/> + <clearField selector="{{AdminProductFormSection.productWeight}}" stepKey="clearWeightField"/> + <selectOption selector="{{AdminProductFormSection.productWeightSelect}}" userInput="This item has no weight" stepKey="selectNoWeight"/> + <actionGroup ref="saveProductForm" stepKey="saveVirtualProductForm"/> + <!--Assert virtual product on Admin product page grid--> + <comment userInput="Assert virtual product on Admin product page grid" stepKey="commentAssertVirtualProductOnAdmin"/> + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPageForVirtual"/> + <actionGroup ref="filterProductGridBySku2" stepKey="filterProductGridBySkuForVirtual"> + <argument name="sku" value="$$createProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Name')}}" userInput="$$createProduct.name$$" stepKey="seeVirtualProductNameInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Type')}}" userInput="Virtual Product" stepKey="seeVirtualProductTypeInGrid"/> + <!--Assert virtual product on storefront--> + <comment userInput="Assert virtual product on storefront" stepKey="commentAssertVirtualProductOnStorefront"/> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="openVirtualProductPage"/> + <waitForPageLoad stepKey="waitForStorefrontVirtualProductPageLoad"/> + <see userInput="IN STOCK" selector="{{StorefrontProductInfoMainSection.productStockStatus}}" stepKey="assertVirtualProductInStock"/> + </test> + <test name="AdminVirtualProductTypeSwitchingToConfigurableProductTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="Product type switching"/> + <title value="Virtual product type switching on editing to configurable product"/> + <description value="Virtual product type switching on editing to configurable product"/> + <testCaseId value="MC-17953"/> + <useCaseId value="MAGETWO-44170"/> + <severity value="MAJOR"/> + <group value="catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create product--> + <comment userInput="Create product" stepKey="commentCreateProduct"/> + <createData entity="VirtualProduct" stepKey="createProduct"/> + <!--Create attribute with options--> + <comment userInput="Create attribute with options" stepKey="commentCreateAttributeWithOptions"/> + <createData entity="productAttributeWithTwoOptions" stepKey="createConfigProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createConfigProductAttributeOptionOne"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption2" stepKey="createConfigProductAttributeOptionTwo"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + </before> + <after> + <!--Delete product--> + <comment userInput="Delete product" stepKey="commentDeleteProduct"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteAttribute"/> + <actionGroup ref="deleteAllDuplicateProductUsingProductGrid" stepKey="deleteAllDuplicateProducts"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearProductFilters"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!--Add configurations to product--> + <comment userInput="Add configurations to product" stepKey="commentAddConfigurations"/> + <amOnPage url="{{AdminProductEditPage.url($$createProduct.id$$)}}" stepKey="gotToConfigProductPage"/> + <waitForPageLoad stepKey="waitForConfigurableProductPageLoad"/> + <selectOption selector="{{AdminProductFormSection.productWeightSelect}}" userInput="This item has weight" stepKey="selectWeightForConfigurableProduct"/> + <actionGroup ref="generateConfigurationsByAttributeCode" stepKey="setupConfigurationsForProduct"> + <argument name="attributeCode" value="$$createConfigProductAttribute.attribute_code$$"/> + </actionGroup> + <actionGroup ref="saveConfiguredProduct" stepKey="saveNewConfigurableProductForm"/> + <!--Assert configurable product on Admin product page grid--> + <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertConfigurableProductOnAdmin"/> + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPageForConfigurable"/> + <actionGroup ref="filterProductGridBySku2" stepKey="filterProductGridBySkuForConfigurable"> + <argument name="sku" value="$$createProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Name')}}" userInput="$$createProduct.name$$" stepKey="seeConfigurableProductNameInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Type')}}" userInput="Configurable Product" stepKey="seeConfigurableProductTypeInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('2', 'Name')}}" userInput="$$createProduct.name$$-option1" stepKey="seeConfigurableProductNameInGrid1"/> + <see selector="{{AdminProductGridSection.productGridCell('3', 'Name')}}" userInput="$$createProduct.name$$-option2" stepKey="seeConfigurableProductNameInGrid2"/> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearConfigurableProductFilters"/> + <!--Assert configurable product on storefront--> + <comment userInput="Assert configurable product on storefront" stepKey="commentAssertConfigurableProductOnStorefront"/> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="openConfigurableProductPage"/> + <waitForPageLoad stepKey="waitForStorefrontConfigurableProductPageLoad"/> + <see userInput="IN STOCK" selector="{{StorefrontProductInfoMainSection.productStockStatus}}" stepKey="assertConfigurableProductInStock"/> + <click selector="{{StorefrontProductInfoMainSection.productAttributeOptionsSelectButton}}" stepKey="clickConfigurableAttributeDropDown"/> + <see userInput="option1" stepKey="verifyConfigurableProductOption1Exists"/> + <see userInput="option2" stepKey="verifyConfigurableProductOption2Exists"/> + </test> +</tests> diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/ConfigurableTest.php index c351d12fa813d..165e479d99348 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/ConfigurableTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/ConfigurableTest.php @@ -266,10 +266,12 @@ public function testSave() ->with('_cache_instance_used_product_attribute_ids') ->willReturn(true); $extensionAttributes = $this->getMockBuilder(ProductExtensionInterface::class) - ->setMethods([ - 'getConfigurableProductOptions', - 'getConfigurableProductLinks' - ]) + ->setMethods( + [ + 'getConfigurableProductOptions', + 'getConfigurableProductLinks' + ] + ) ->getMockForAbstractClass(); $this->entityMetadata->expects($this->any()) ->method('getLinkField') @@ -344,25 +346,13 @@ public function testCanUseAttribute() public function testGetUsedProducts() { - $productCollectionItemData = ['array']; + $productCollectionItem = $this->createMock(\Magento\Catalog\Model\Product::class); + $attributeCollection = $this->createMock(Collection::class); + $product = $this->createMock(\Magento\Catalog\Model\Product::class); + $productCollection = $this->createMock(ProductCollection::class); - $productCollectionItem = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - $attributeCollection = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); - $product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - $productCollection = $this->getMockBuilder(ProductCollection::class) - ->disableOriginalConstructor() - ->getMock(); - - $productCollectionItem->expects($this->once())->method('getData')->willReturn($productCollectionItemData); $attributeCollection->expects($this->any())->method('setProductFilter')->willReturnSelf(); $product->expects($this->atLeastOnce())->method('getStoreId')->willReturn(5); - $product->expects($this->once())->method('getIdentities')->willReturn(['123']); $product->expects($this->exactly(2)) ->method('hasData') @@ -388,59 +378,10 @@ public function testGetUsedProducts() $productCollection->expects($this->once())->method('setStoreId')->with(5)->willReturn([]); $productCollection->expects($this->once())->method('getItems')->willReturn([$productCollectionItem]); - $this->serializer->expects($this->once()) - ->method('serialize') - ->with([$productCollectionItemData]) - ->willReturn('result'); - $this->productCollectionFactory->expects($this->any())->method('create')->willReturn($productCollection); $this->model->getUsedProducts($product); } - public function testGetUsedProductsWithDataInCache() - { - $product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - $childProduct = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $dataKey = '_cache_instance_products'; - $usedProductsData = [['first']]; - $usedProducts = [$childProduct]; - - $product->expects($this->once()) - ->method('hasData') - ->with($dataKey) - ->willReturn(false); - $product->expects($this->once()) - ->method('setData') - ->with($dataKey, $usedProducts); - $product->expects($this->any()) - ->method('getData') - ->willReturnOnConsecutiveCalls(1, $usedProducts); - - $childProduct->expects($this->once()) - ->method('setData') - ->with($usedProductsData[0]); - - $this->productFactory->expects($this->once()) - ->method('create') - ->willReturn($childProduct); - - $this->cache->expects($this->once()) - ->method('load') - ->willReturn($usedProductsData); - - $this->serializer->expects($this->once()) - ->method('unserialize') - ->with($usedProductsData) - ->willReturn($usedProductsData); - - $this->assertEquals($usedProducts, $this->model->getUsedProducts($product)); - } - /** * @param int $productStore * @@ -878,12 +819,12 @@ public function testSetImageFromChildProduct() ->method('getLinkField') ->willReturn('link'); $productMock->expects($this->any())->method('hasData') - ->withConsecutive(['store_id'], ['_cache_instance_products']) - ->willReturnOnConsecutiveCalls(true, true); + ->withConsecutive(['_cache_instance_products']) + ->willReturnOnConsecutiveCalls(true); $productMock->expects($this->any())->method('getData') - ->withConsecutive(['image'], ['image'], ['link'], ['store_id'], ['_cache_instance_products']) - ->willReturnOnConsecutiveCalls('no_selection', 'no_selection', 1, 1, [$childProductMock]); + ->withConsecutive(['image'], ['image'], ['_cache_instance_products']) + ->willReturnOnConsecutiveCalls('no_selection', 'no_selection', [$childProductMock]); $childProductMock->expects($this->any())->method('getData')->with('image')->willReturn('image_data'); $productMock->expects($this->once())->method('setImage')->with('image_data')->willReturnSelf(); diff --git a/app/code/Magento/ConfigurableProduct/etc/di.xml b/app/code/Magento/ConfigurableProduct/etc/di.xml index b8f7ed67a9868..c8a278df92dc6 100644 --- a/app/code/Magento/ConfigurableProduct/etc/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/di.xml @@ -256,4 +256,12 @@ </argument> </arguments> </type> + <type name="Magento\ConfigurableProduct\Model\Plugin\Frontend\UsedProductsCache"> + <arguments> + <argument name="cache" xsi:type="object">Magento\Framework\App\Cache\Type\Collection</argument> + </arguments> + <arguments> + <argument name="serializer" xsi:type="object">Magento\Framework\Serialize\Serializer\Json</argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml b/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml index df96829b354c8..b2d50f54f5334 100644 --- a/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml @@ -13,4 +13,7 @@ <type name="Magento\Catalog\Model\Product"> <plugin name="product_identities_extender" type="Magento\ConfigurableProduct\Model\Plugin\Frontend\ProductIdentitiesExtender" /> </type> + <type name="Magento\ConfigurableProduct\Model\Product\Type\Configurable"> + <plugin name="used_products_cache" type="Magento\ConfigurableProduct\Model\Plugin\Frontend\UsedProductsCache" /> + </type> </config> diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/super/config.phtml b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/super/config.phtml index c11a1adc19896..240c5e65c79c3 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/super/config.phtml +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/super/config.phtml @@ -64,7 +64,7 @@ "productsProvider": "configurable_associated_product_listing.data_source", "productsMassAction": "configurable_associated_product_listing.configurable_associated_product_listing.product_columns.ids", "productsColumns": "configurable_associated_product_listing.configurable_associated_product_listing.product_columns", - "productsGridUrl": "<?= /* @noEscape */ $block->getUrl('catalog/product/associated_grid', ['componentJson' => true]) ?>", + "productsGridUrl": "<?= /* @noEscape */ $block->getUrl('catalog/product_associated/grid', ['componentJson' => true]) ?>", "configurableVariations": "configurableVariations" } } diff --git a/app/code/Magento/Cron/etc/adminhtml/system.xml b/app/code/Magento/Cron/etc/adminhtml/system.xml index 95d8d4c8a6966..c8753f1b0b56f 100644 --- a/app/code/Magento/Cron/etc/adminhtml/system.xml +++ b/app/code/Magento/Cron/etc/adminhtml/system.xml @@ -15,21 +15,27 @@ <label>Cron configuration options for group: </label> <field id="schedule_generate_every" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Generate Schedules Every</label> + <validate>validate-zero-or-greater validate-digits</validate> </field> <field id="schedule_ahead_for" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Schedule Ahead for</label> + <validate>validate-zero-or-greater validate-digits</validate> </field> <field id="schedule_lifetime" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Missed if Not Run Within</label> + <validate>validate-zero-or-greater validate-digits</validate> </field> <field id="history_cleanup_every" translate="label" type="text" sortOrder="40" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>History Cleanup Every</label> + <validate>validate-zero-or-greater validate-digits</validate> </field> <field id="history_success_lifetime" translate="label" type="text" sortOrder="50" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Success History Lifetime</label> + <validate>validate-zero-or-greater validate-digits</validate> </field> <field id="history_failure_lifetime" translate="label" type="text" sortOrder="60" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Failure History Lifetime</label> + <validate>validate-zero-or-greater validate-digits</validate> </field> <field id="use_separate_process" translate="label" type="select" sortOrder="70" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Use Separate Process</label> diff --git a/app/code/Magento/Cron/etc/db_schema.xml b/app/code/Magento/Cron/etc/db_schema.xml index b3061eefa6313..206b8f64f3ae7 100644 --- a/app/code/Magento/Cron/etc/db_schema.xml +++ b/app/code/Magento/Cron/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="cron_schedule" resource="default" engine="innodb" comment="Cron Schedule"> <column xsi:type="int" name="schedule_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Schedule Id"/> + comment="Schedule ID"/> <column xsi:type="varchar" name="job_code" nullable="false" length="255" default="0" comment="Job Code"/> <column xsi:type="varchar" name="status" nullable="false" length="7" default="pending" comment="Status"/> <column xsi:type="text" name="messages" nullable="true" comment="Messages"/> diff --git a/app/code/Magento/Customer/Block/Adminhtml/Edit/Tab/Cart.php b/app/code/Magento/Customer/Block/Adminhtml/Edit/Tab/Cart.php index db560f7de3ecb..3709f4914c477 100644 --- a/app/code/Magento/Customer/Block/Adminhtml/Edit/Tab/Cart.php +++ b/app/code/Magento/Customer/Block/Adminhtml/Edit/Tab/Cart.php @@ -75,7 +75,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ protected function _construct() { @@ -119,7 +119,7 @@ protected function _prepareCollection() } /** - * {@inheritdoc} + * @inheritdoc */ protected function _prepareColumns() { @@ -201,7 +201,7 @@ public function getCustomerId() } /** - * {@inheritdoc} + * @inheritdoc */ public function getGridUrl() { @@ -224,7 +224,13 @@ public function getGridParentHtml() */ public function getRowUrl($row) { - return $this->getUrl('catalog/product/edit', ['id' => $row->getProductId()]); + return $this->getUrl( + 'catalog/product/edit', + [ + 'id' => $row->getProductId(), + 'customerId' => $this->getCustomerId() + ] + ); } /** diff --git a/app/code/Magento/Customer/Block/SectionNamesProvider.php b/app/code/Magento/Customer/Block/SectionNamesProvider.php new file mode 100644 index 0000000000000..92029d1715d4b --- /dev/null +++ b/app/code/Magento/Customer/Block/SectionNamesProvider.php @@ -0,0 +1,39 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Customer\Block; + +use Magento\Customer\CustomerData\SectionPool; +use Magento\Framework\View\Element\Block\ArgumentInterface; + +/** + * ViewModel to get sections names array. + */ +class SectionNamesProvider implements ArgumentInterface +{ + /** + * @var SectionPool + */ + private $sectionPool; + + /** + * @param SectionPool $sectionPool + */ + public function __construct( + SectionPool $sectionPool + ) { + $this->sectionPool = $sectionPool; + } + + /** + * Return array of section names based on config. + * + * @return array + */ + public function getSectionNames() + { + return $this->sectionPool->getSectionNames(); + } +} diff --git a/app/code/Magento/Customer/Controller/Account/Confirmation.php b/app/code/Magento/Customer/Controller/Account/Confirmation.php index a3e2db0207630..59def8640328c 100644 --- a/app/code/Magento/Customer/Controller/Account/Confirmation.php +++ b/app/code/Magento/Customer/Controller/Account/Confirmation.php @@ -1,21 +1,26 @@ <?php /** - * * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ namespace Magento\Customer\Controller\Account; +use Magento\Customer\Api\AccountManagementInterface; +use Magento\Customer\Controller\AbstractAccount; +use Magento\Customer\Model\Session; use Magento\Customer\Model\Url; +use Magento\Framework\App\Action\HttpGetActionInterface as HttpGetActionInterface; +use Magento\Framework\App\Action\HttpPostActionInterface as HttpPostActionInterface; use Magento\Framework\App\Action\Context; -use Magento\Customer\Model\Session; use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\State\InvalidTransitionException; use Magento\Framework\View\Result\PageFactory; use Magento\Store\Model\StoreManagerInterface; -use Magento\Customer\Api\AccountManagementInterface; -use Magento\Framework\Exception\State\InvalidTransitionException; -class Confirmation extends \Magento\Customer\Controller\AbstractAccount +/** + * Class Confirmation. Send confirmation link to specified email + */ +class Confirmation extends AbstractAccount implements HttpGetActionInterface, HttpPostActionInterface { /** * @var \Magento\Store\Model\StoreManagerInterface @@ -91,11 +96,11 @@ public function execute() $email, $this->storeManager->getStore()->getWebsiteId() ); - $this->messageManager->addSuccess(__('Please check your email for confirmation key.')); + $this->messageManager->addSuccessMessage(__('Please check your email for confirmation key.')); } catch (InvalidTransitionException $e) { - $this->messageManager->addSuccess(__('This email does not require confirmation.')); + $this->messageManager->addSuccessMessage(__('This email does not require confirmation.')); } catch (\Exception $e) { - $this->messageManager->addException($e, __('Wrong email.')); + $this->messageManager->addExceptionMessage($e, __('Wrong email.')); $resultRedirect->setPath('*/*/*', ['email' => $email, '_secure' => true]); return $resultRedirect; } diff --git a/app/code/Magento/Customer/CustomerData/SectionPool.php b/app/code/Magento/Customer/CustomerData/SectionPool.php index efea1762d9de6..eef2854cf363e 100644 --- a/app/code/Magento/Customer/CustomerData/SectionPool.php +++ b/app/code/Magento/Customer/CustomerData/SectionPool.php @@ -62,6 +62,16 @@ public function getSectionsData(array $sectionNames = null, $forceNewTimestamp = return $sectionsData; } + /** + * Return array of section names. + * + * @return array + */ + public function getSectionNames() + { + return array_keys($this->sectionSourceMap); + } + /** * Get section sources by section names * diff --git a/app/code/Magento/Customer/Model/Attribute.php b/app/code/Magento/Customer/Model/Attribute.php index 98a97872f15f4..d05bf14fbc97d 100644 --- a/app/code/Magento/Customer/Model/Attribute.php +++ b/app/code/Magento/Customer/Model/Attribute.php @@ -202,6 +202,9 @@ public function canBeFilterableInGrid() /** * @inheritdoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -214,6 +217,9 @@ public function __sleep() /** * @inheritdoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Customer/Model/Customer.php b/app/code/Magento/Customer/Model/Customer.php index 1287dbe5df708..1f8f7d90f6d0d 100644 --- a/app/code/Magento/Customer/Model/Customer.php +++ b/app/code/Magento/Customer/Model/Customer.php @@ -394,7 +394,9 @@ public function getSharingConfig() public function authenticate($login, $password) { $this->loadByEmail($login); - if ($this->getConfirmation() && $this->isConfirmationRequired()) { + if ($this->getConfirmation() && + $this->accountConfirmation->isConfirmationRequired($this->getWebsiteId(), $this->getId(), $this->getEmail()) + ) { throw new EmailNotConfirmedException( __("This account isn't confirmed. Verify and try again.") ); @@ -415,8 +417,9 @@ public function authenticate($login, $password) /** * Load customer by email * - * @param string $customerEmail - * @return $this + * @param string $customerEmail + * @return $this + * @throws \Magento\Framework\Exception\LocalizedException */ public function loadByEmail($customerEmail) { @@ -427,8 +430,9 @@ public function loadByEmail($customerEmail) /** * Change customer password * - * @param string $newPassword - * @return $this + * @param string $newPassword + * @return $this + * @throws \Magento\Framework\Exception\LocalizedException */ public function changePassword($newPassword) { @@ -440,6 +444,7 @@ public function changePassword($newPassword) * Get full customer name * * @return string + * @throws \Magento\Framework\Exception\LocalizedException */ public function getName() { @@ -462,8 +467,9 @@ public function getName() /** * Add address to address collection * - * @param Address $address - * @return $this + * @param Address $address + * @return $this + * @throws \Magento\Framework\Exception\LocalizedException */ public function addAddress(Address $address) { @@ -487,6 +493,7 @@ public function getAddressById($addressId) * * @param int $addressId * @return Address + * @throws \Magento\Framework\Exception\LocalizedException */ public function getAddressItemById($addressId) { @@ -507,6 +514,7 @@ public function getAddressCollection() * Customer addresses collection * * @return \Magento\Customer\Model\ResourceModel\Address\Collection + * @throws \Magento\Framework\Exception\LocalizedException */ public function getAddressesCollection() { @@ -538,6 +546,7 @@ public function getAddresses() * Retrieve all customer attributes * * @return Attribute[] + * @throws \Magento\Framework\Exception\LocalizedException */ public function getAttributes() { @@ -592,6 +601,7 @@ public function hashPassword($password, $salt = true) * * @param string $password * @return boolean + * @throws \Exception */ public function validatePassword($password) { @@ -805,6 +815,7 @@ public function isConfirmationRequired() */ public function getRandomConfirmationKey() { + // phpcs:ignore Magento2.Security.InsecureFunction return md5(uniqid()); } diff --git a/app/code/Magento/Customer/Model/ResourceModel/Customer.php b/app/code/Magento/Customer/Model/ResourceModel/Customer.php index 94196df6fe093..1477287f79f4b 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/Customer.php +++ b/app/code/Magento/Customer/Model/ResourceModel/Customer.php @@ -6,6 +6,7 @@ namespace Magento\Customer\Model\ResourceModel; +use Magento\Customer\Model\AccountConfirmation; use Magento\Customer\Model\Customer\NotificationStorage; use Magento\Framework\App\ObjectManager; use Magento\Framework\Validator\Exception as ValidatorException; @@ -42,12 +43,19 @@ class Customer extends \Magento\Eav\Model\Entity\VersionControl\AbstractEntity */ protected $storeManager; + /** + * @var AccountConfirmation + */ + private $accountConfirmation; + /** * @var NotificationStorage */ private $notificationStorage; /** + * Customer constructor. + * * @param \Magento\Eav\Model\Entity\Context $context * @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot $entitySnapshot * @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite $entityRelationComposite @@ -56,6 +64,7 @@ class Customer extends \Magento\Eav\Model\Entity\VersionControl\AbstractEntity * @param \Magento\Framework\Stdlib\DateTime $dateTime * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param array $data + * @param AccountConfirmation $accountConfirmation */ public function __construct( \Magento\Eav\Model\Entity\Context $context, @@ -65,15 +74,19 @@ public function __construct( \Magento\Framework\Validator\Factory $validatorFactory, \Magento\Framework\Stdlib\DateTime $dateTime, \Magento\Store\Model\StoreManagerInterface $storeManager, - $data = [] + $data = [], + AccountConfirmation $accountConfirmation = null ) { parent::__construct($context, $entitySnapshot, $entityRelationComposite, $data); + $this->_scopeConfig = $scopeConfig; $this->_validatorFactory = $validatorFactory; $this->dateTime = $dateTime; - $this->storeManager = $storeManager; + $this->accountConfirmation = $accountConfirmation ?: ObjectManager::getInstance() + ->get(AccountConfirmation::class); $this->setType('customer'); $this->setConnection('customer_read', 'customer_write'); + $this->storeManager = $storeManager; } /** @@ -144,7 +157,13 @@ protected function _beforeSave(\Magento\Framework\DataObject $customer) } // set confirmation key logic - if (!$customer->getId() && $customer->isConfirmationRequired()) { + if (!$customer->getId() && + $this->accountConfirmation->isConfirmationRequired( + $customer->getWebsiteId(), + $customer->getId(), + $customer->getEmail() + ) + ) { $customer->setConfirmation($customer->getRandomConfirmationKey()); } // remove customer confirmation key from database, if empty diff --git a/app/code/Magento/Customer/Model/Session.php b/app/code/Magento/Customer/Model/Session.php index 5900fed218edf..047327a0b6c29 100644 --- a/app/code/Magento/Customer/Model/Session.php +++ b/app/code/Magento/Customer/Model/Session.php @@ -10,6 +10,7 @@ use Magento\Customer\Api\GroupManagementInterface; use Magento\Customer\Model\Config\Share; use Magento\Customer\Model\ResourceModel\Customer as ResourceCustomer; +use Magento\Framework\App\ObjectManager; /** * Customer session model @@ -17,6 +18,7 @@ * @api * @method string getNoReferer() * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) * @since 100.0.2 */ class Session extends \Magento\Framework\Session\SessionManager @@ -107,6 +109,8 @@ class Session extends \Magento\Framework\Session\SessionManager protected $response; /** + * Session constructor. + * * @param \Magento\Framework\App\Request\Http $request * @param \Magento\Framework\Session\SidResolverInterface $sidResolver * @param \Magento\Framework\Session\Config\ConfigInterface $sessionConfig @@ -118,7 +122,7 @@ class Session extends \Magento\Framework\Session\SessionManager * @param \Magento\Framework\App\State $appState * @param Share $configShare * @param \Magento\Framework\Url\Helper\Data $coreUrl - * @param \Magento\Customer\Model\Url $customerUrl + * @param Url $customerUrl * @param ResourceCustomer $customerResource * @param CustomerFactory $customerFactory * @param \Magento\Framework\UrlFactory $urlFactory @@ -128,6 +132,7 @@ class Session extends \Magento\Framework\Session\SessionManager * @param CustomerRepositoryInterface $customerRepository * @param GroupManagementInterface $groupManagement * @param \Magento\Framework\App\Response\Http $response + * @param AccountConfirmation $accountConfirmation * @throws \Magento\Framework\Exception\SessionException * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -152,7 +157,8 @@ public function __construct( \Magento\Framework\App\Http\Context $httpContext, CustomerRepositoryInterface $customerRepository, GroupManagementInterface $groupManagement, - \Magento\Framework\App\Response\Http $response + \Magento\Framework\App\Response\Http $response, + AccountConfirmation $accountConfirmation = null ) { $this->_coreUrl = $coreUrl; $this->_customerUrl = $customerUrl; @@ -177,6 +183,8 @@ public function __construct( ); $this->groupManagement = $groupManagement; $this->response = $response; + $this->accountConfirmation = $accountConfirmation ?: ObjectManager::getInstance() + ->get(AccountConfirmation::class); $this->_eventManager->dispatch('customer_session_init', ['customer_session' => $this]); } @@ -216,6 +224,8 @@ public function setCustomerData(CustomerData $customer) * Retrieve customer model object * * @return CustomerData + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function getCustomerData() { @@ -266,8 +276,14 @@ public function setCustomer(Customer $customerModel) \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID ); $this->setCustomerId($customerModel->getId()); - if (!$customerModel->isConfirmationRequired() && $customerModel->getConfirmation()) { - $customerModel->setConfirmation(null)->save(); + $accountConfirmationRequired = $this->accountConfirmation->isConfirmationRequired( + $customerModel->getWebsiteId(), + $customerModel->getId(), + $customerModel->getEmail() + ); + if (!$accountConfirmationRequired && $customerModel->getConfirmation() && $customerModel->getId()) { + $customerModel->setConfirmation(null); + $this->_customerResource->save($customerModel); } /** @@ -354,10 +370,11 @@ public function setCustomerGroupId($id) } /** - * Get customer group id - * If customer is not logged in system, 'not logged in' group id will be returned + * Get customer group id. If customer is not logged in system, 'not logged in' group id will be returned. * * @return int + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function getCustomerGroupId() { @@ -407,6 +424,8 @@ public function checkCustomerId($customerId) } /** + * Sets customer as logged in + * * @param Customer $customer * @return $this */ @@ -420,6 +439,8 @@ public function setCustomerAsLoggedIn($customer) } /** + * Sets customer data as logged in + * * @param CustomerData $customer * @return $this */ @@ -521,6 +542,8 @@ protected function _setAuthUrl($key, $url) * Logout without dispatching event * * @return $this + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException */ protected function _logout() { @@ -567,6 +590,8 @@ public function regenerateId() } /** + * Creates URL factory + * * @return \Magento\Framework\UrlInterface */ protected function _createUrl() diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontOpenCustomerAccountCreatePageActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontOpenCustomerAccountCreatePageActionGroup.xml index b013b1db1c8e7..31a988ac9da0d 100644 --- a/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontOpenCustomerAccountCreatePageActionGroup.xml +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontOpenCustomerAccountCreatePageActionGroup.xml @@ -16,4 +16,15 @@ <amOnPage url="{{StorefrontCustomerCreatePage.url}}" stepKey="goToCustomerAccountCreatePage"/> <waitForPageLoad stepKey="waitForPageLoaded"/> </actionGroup> + + <actionGroup name="StorefrontOpenCustomerAccountCreatePageUsingStoreCodeInUrlActionGroup"> + <annotations> + <description>Goes to the Storefront Customer Create page using Store code in URL option.</description> + </annotations> + <arguments> + <argument name="storeView" type="string" defaultValue="{{customStore.code}}"/> + </arguments> + + <amOnPage url="{{StorefrontStoreHomePage.url(storeView)}}{{StorefrontCustomerCreatePage.url}}" stepKey="goToCustomerAccountCreatePage"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/Page/AdminEditCustomerPage.xml b/app/code/Magento/Customer/Test/Mftf/Page/AdminEditCustomerPage.xml index 9bd382da8eb92..79fb18afaad53 100644 --- a/app/code/Magento/Customer/Test/Mftf/Page/AdminEditCustomerPage.xml +++ b/app/code/Magento/Customer/Test/Mftf/Page/AdminEditCustomerPage.xml @@ -12,6 +12,8 @@ <section name="AdminCustomerAddressesGridSection"/> <section name="AdminCustomerAddressesGridActionsSection"/> <section name="AdminCustomerAddressesSection"/> + <section name="AdminCustomerCartSection" /> + <section name="AdminCustomerInformationSection" /> <section name="AdminCustomerMainActionsSection"/> <section name="AdminEditCustomerAddressesSection" /> </page> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerCartSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerCartSection.xml new file mode 100644 index 0000000000000..5c8b8907db43a --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerCartSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCustomerCartSection"> + <element name="cartItem" type="button" selector="#customer_cart_grid_table tbody tr:nth-of-type({{row}}) .col-product_id" parameterized="true" timeout="5"/> + </section> +</sections> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerInformationSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerInformationSection.xml new file mode 100644 index 0000000000000..d680015230b9d --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerInformationSection.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCustomerInformationSection"> + <element name="customerView" type="button" selector="#tab_customer_edit_tab_view_content"/> + <element name="accountInformation" type="button" selector="#tab_customer"/> + <element name="addresses" type="button" selector="#tab_address"/> + <element name="orders" type="button" selector="#tab_orders_content"/> + <element name="shoppingCart" type="button" selector="#tab_cart_content"/> + <element name="newsletter" type="button" selector="#tab_newsletter_content"/> + <element name="billingAgreements" type="button" selector="#tab_customer_edit_tab_agreements_content"/> + <element name="productReviews" type="button" selector="#tab_reviews_content"/> + <element name="wishList" type="button" selector="#tab_wishlist_content"/> + </section> +</sections> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminProductBackRedirectNavigateFromCustomerViewCartProduct.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminProductBackRedirectNavigateFromCustomerViewCartProduct.xml new file mode 100644 index 0000000000000..9de2339f2e217 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminProductBackRedirectNavigateFromCustomerViewCartProduct.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminProductBackRedirectNavigateFromCustomerViewCartProduct"> + <annotations> + <features value="Customer"/> + <title value="Product back redirect navigate from customer view cart product"/> + <description value="Back button on product page is redirecting to customer page if opened form shopping cart"/> + <severity value="MINOR"/> + <group value="Customer"/> + </annotations> + <before> + <!-- Create new product--> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <!-- Create new customer--> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + + <!-- Login as admin--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + + <!-- Go to storefront as customer--> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="customerLogin"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + + <!-- Add product to cart --> + <actionGroup ref="AddSimpleProductToCart" stepKey="addProductToCart"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + + <!-- Navigate to customer edit page in admin --> + <amOnPage url="{{AdminCustomerPage.url}}edit/id/$$createCustomer.id$$/" stepKey="openCustomerEditPage"/> + <waitForPageLoad stepKey="waitForCustomerEditPage"/> + + <!-- Open shopping cart --> + <click selector="{{AdminCustomerInformationSection.shoppingCart}}" stepKey="clickShoppingCartButton"/> + <waitForPageLoad stepKey="waitForPageLoaded"/> + + <!-- Open product --> + <click selector="{{AdminCustomerCartSection.cartItem('1')}}" stepKey="openProduct"/> + + <!-- Go back to customer page --> + <click selector="{{AdminProductFormActionSection.backButton}}" stepKey="goBackToCustomerPage"/> + + <!-- Check current page is customer page --> + <seeInCurrentUrl stepKey="onCustomerAccountPage" url="{{AdminCustomerPage.url}}edit/id/$$createCustomer.id$$/"/> + + <after> + <!--Delete product--> + <deleteData stepKey="deleteProduct" createDataKey="createProduct"/> + + <!--Delete category--> + <deleteData stepKey="deleteCategory" createDataKey="createCategory"/> + + <!--Delete customer--> + <deleteData stepKey="deleteCustomer" createDataKey="createCustomer"/> + + <!-- Sign out--> + <actionGroup ref="SignOut" stepKey="signOut"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/GroupTest.php b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/GroupTest.php index b245702ce07f9..069ddc63d74d7 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/GroupTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/GroupTest.php @@ -127,7 +127,7 @@ public function testSaveWithReservedId() ] ) ->getMockForAbstractClass(); - $dbAdapter->expects($this->any())->method('describeTable')->willReturn([]); + $dbAdapter->expects($this->any())->method('describeTable')->willReturn(['customer_group_id' => []]); $dbAdapter->expects($this->any())->method('update')->willReturnSelf(); $dbAdapter->expects($this->once())->method('lastInsertId')->willReturn($expectedId); $selectMock = $this->getMockBuilder(\Magento\Framework\DB\Select::class) diff --git a/app/code/Magento/Customer/Test/Unit/Model/SessionTest.php b/app/code/Magento/Customer/Test/Unit/Model/SessionTest.php index 7efc61af800d3..8565790990df1 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/SessionTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/SessionTest.php @@ -66,7 +66,7 @@ protected function setUp() $this->urlFactoryMock = $this->createMock(\Magento\Framework\UrlFactory::class); $this->customerFactoryMock = $this->getMockBuilder(\Magento\Customer\Model\CustomerFactory::class) ->disableOriginalConstructor() - ->setMethods(['create']) + ->setMethods(['create', 'save']) ->getMock(); $this->customerRepositoryMock = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); $helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -192,15 +192,12 @@ protected function prepareLoginDataMock($customerId) $customerMock = $this->createPartialMock( \Magento\Customer\Model\Customer::class, - ['getId', 'isConfirmationRequired', 'getConfirmation', 'updateData', 'getGroupId'] + ['getId', 'getConfirmation', 'updateData', 'getGroupId'] ); - $customerMock->expects($this->once()) + $customerMock->expects($this->exactly(3)) ->method('getId') ->will($this->returnValue($customerId)); $customerMock->expects($this->once()) - ->method('isConfirmationRequired') - ->will($this->returnValue(true)); - $customerMock->expects($this->never()) ->method('getConfirmation') ->will($this->returnValue($customerId)); diff --git a/app/code/Magento/Customer/etc/db_schema.xml b/app/code/Magento/Customer/etc/db_schema.xml index c699db06d30dc..e07d7d8708a43 100644 --- a/app/code/Magento/Customer/etc/db_schema.xml +++ b/app/code/Magento/Customer/etc/db_schema.xml @@ -15,7 +15,7 @@ <column xsi:type="varchar" name="email" nullable="true" length="255" comment="Email"/> <column xsi:type="smallint" name="group_id" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Group ID"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" default="0" comment="Store ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" @@ -78,7 +78,7 @@ <table name="customer_address_entity" resource="default" engine="innodb" comment="Customer Address Entity"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="true" identity="false" comment="Parent ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" @@ -124,9 +124,9 @@ <table name="customer_address_entity_datetime" resource="default" engine="innodb" comment="Customer Address Entity Datetime"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="datetime" name="value" on_update="false" nullable="true" comment="Value"/> @@ -155,9 +155,9 @@ <table name="customer_address_entity_decimal" resource="default" engine="innodb" comment="Customer Address Entity Decimal"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="decimal" name="value" scale="4" precision="12" unsigned="false" nullable="false" default="0" @@ -424,14 +424,14 @@ <column xsi:type="varchar" name="customer_group_code" nullable="false" length="32" comment="Customer Group Code"/> <column xsi:type="int" name="tax_class_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Tax Class Id"/> + default="0" comment="Tax Class ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="customer_group_id"/> </constraint> </table> <table name="customer_eav_attribute" resource="default" engine="innodb" comment="Customer Eav Attribute"> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Attribute Id"/> + comment="Attribute ID"/> <column xsi:type="smallint" name="is_visible" padding="5" unsigned="true" nullable="false" identity="false" default="1" comment="Is Visible"/> <column xsi:type="varchar" name="input_filter" nullable="true" length="255" comment="Input Filter"/> @@ -461,7 +461,7 @@ <table name="customer_form_attribute" resource="default" engine="innodb" comment="Customer Form Attribute"> <column xsi:type="varchar" name="form_code" nullable="false" length="32" comment="Form Code"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Attribute Id"/> + comment="Attribute ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="form_code"/> <column name="attribute_id"/> @@ -476,9 +476,9 @@ <table name="customer_eav_attribute_website" resource="default" engine="innodb" comment="Customer Eav Attribute Website"> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Attribute Id"/> + comment="Attribute ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="smallint" name="is_visible" padding="5" unsigned="true" nullable="true" identity="false" comment="Is Visible"/> <column xsi:type="smallint" name="is_required" padding="5" unsigned="true" nullable="true" identity="false" @@ -504,7 +504,7 @@ <column xsi:type="bigint" name="visitor_id" padding="20" unsigned="true" nullable="false" identity="true" comment="Visitor ID"/> <column xsi:type="int" name="customer_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="varchar" name="session_id" nullable="true" length="64" comment="Session ID"/> <column xsi:type="timestamp" name="last_visit_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" comment="Last Visit Time"/> diff --git a/app/code/Magento/Customer/etc/di.xml b/app/code/Magento/Customer/etc/di.xml index a181d6dd217fd..3b7191e7ed326 100644 --- a/app/code/Magento/Customer/etc/di.xml +++ b/app/code/Magento/Customer/etc/di.xml @@ -157,6 +157,11 @@ <argument name="sectionConfig" xsi:type="object">SectionInvalidationConfigData</argument> </arguments> </type> + <type name="Magento\Customer\Block\SectionNamesProvider"> + <arguments> + <argument name="sectionConfig" xsi:type="object">SectionInvalidationConfigData</argument> + </arguments> + </type> <preference for="Magento\Customer\CustomerData\JsLayoutDataProviderPoolInterface" type="Magento\Customer\CustomerData\JsLayoutDataProviderPool"/> <type name="Magento\Framework\Webapi\ServiceTypeToEntityTypeMap"> diff --git a/app/code/Magento/Customer/view/adminhtml/ui_component/customer_address_form.xml b/app/code/Magento/Customer/view/adminhtml/ui_component/customer_address_form.xml index 692cb2ecb964d..3af0172b3fca8 100644 --- a/app/code/Magento/Customer/view/adminhtml/ui_component/customer_address_form.xml +++ b/app/code/Magento/Customer/view/adminhtml/ui_component/customer_address_form.xml @@ -191,13 +191,6 @@ </validation> <dataType>text</dataType> </settings> - <formElements> - <select> - <settings> - <options class="Magento\Directory\Model\ResourceModel\Country\Collection"/> - </settings> - </select> - </formElements> </field> <field name="region_id" component="Magento_Customer/js/form/element/region" formElement="select"> <settings> diff --git a/app/code/Magento/Customer/view/frontend/layout/default.xml b/app/code/Magento/Customer/view/frontend/layout/default.xml index 94e46fda194b0..3976fc6bd9090 100644 --- a/app/code/Magento/Customer/view/frontend/layout/default.xml +++ b/app/code/Magento/Customer/view/frontend/layout/default.xml @@ -41,9 +41,12 @@ </arguments> </block> <block name="customer.section.config" class="Magento\Customer\Block\SectionConfig" - template="Magento_Customer::js/section-config.phtml"/> - <block name="customer.customer.data" - class="Magento\Customer\Block\CustomerData" + template="Magento_Customer::js/section-config.phtml"> + <arguments> + <argument name="sectionNamesProvider" xsi:type="object">Magento\Customer\Block\SectionNamesProvider</argument> + </arguments> + </block> + <block name="customer.customer.data" class="Magento\Customer\Block\CustomerData" template="Magento_Customer::js/customer-data.phtml"/> <block name="customer.data.invalidation.rules" class="Magento\Customer\Block\CustomerScopeData" template="Magento_Customer::js/customer-data/invalidation-rules.phtml"/> diff --git a/app/code/Magento/Customer/view/frontend/templates/js/section-config.phtml b/app/code/Magento/Customer/view/frontend/templates/js/section-config.phtml index ebbd16164d7e8..e6511a0674e1d 100644 --- a/app/code/Magento/Customer/view/frontend/templates/js/section-config.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/js/section-config.phtml @@ -15,7 +15,9 @@ "baseUrls": <?= /* @noEscape */ $this->helper(\Magento\Framework\Json\Helper\Data::class)->jsonEncode(array_unique([ $block->getUrl(null, ['_secure' => true]), $block->getUrl(null, ['_secure' => false]), - ])) ?> + ])) ?>, + "sectionNames": <?= /* @noEscape */ $this->helper(\Magento\Framework\Json\Helper\Data::class) + ->jsonEncode($block->getData('sectionNamesProvider')->getSectionNames()) ?> } } } diff --git a/app/code/Magento/Customer/view/frontend/web/js/customer-data.js b/app/code/Magento/Customer/view/frontend/web/js/customer-data.js index 41cf05df2b1d5..de3ff10bb057b 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/customer-data.js +++ b/app/code/Magento/Customer/view/frontend/web/js/customer-data.js @@ -198,30 +198,9 @@ define([ * Customer data initialization */ init: function () { - var privateContentVersion = 'private_content_version', - privateContent = $.cookieStorage.get(privateContentVersion), - localPrivateContent = $.localStorage.get(privateContentVersion), - needVersion = 'need_version', - expiredSectionNames = this.getExpiredSectionNames(); - - if (privateContent && - !$.cookieStorage.isSet(privateContentVersion) && - !$.localStorage.isSet(privateContentVersion) - ) { - $.cookieStorage.set(privateContentVersion, needVersion); - $.localStorage.set(privateContentVersion, needVersion); - this.reload([], false); - } else if (localPrivateContent !== privateContent) { - if (!$.cookieStorage.isSet(privateContentVersion)) { - privateContent = needVersion; - $.cookieStorage.set(privateContentVersion, privateContent); - } - $.localStorage.set(privateContentVersion, privateContent); - _.each(dataProvider.getFromStorage(storage.keys()), function (sectionData, sectionName) { - buffer.notify(sectionName, sectionData); - }); - this.reload([], false); - } else if (expiredSectionNames.length > 0) { + var expiredSectionNames = this.getExpiredSectionNames(); + + if (expiredSectionNames.length > 0) { _.each(dataProvider.getFromStorage(storage.keys()), function (sectionData, sectionName) { buffer.notify(sectionName, sectionData); }); @@ -341,7 +320,9 @@ define([ var sectionDataIds, sectionsNamesForInvalidation; - sectionsNamesForInvalidation = _.contains(sectionNames, '*') ? buffer.keys() : sectionNames; + sectionsNamesForInvalidation = _.contains(sectionNames, '*') ? sectionConfig.getSectionNames() : + sectionNames; + $(document).trigger('customer-data-invalidate', [sectionsNamesForInvalidation]); buffer.remove(sectionsNamesForInvalidation); sectionDataIds = $.cookieStorage.get('section_data_ids') || {}; diff --git a/app/code/Magento/Customer/view/frontend/web/js/section-config.js b/app/code/Magento/Customer/view/frontend/web/js/section-config.js index 76fe7f2515a3a..d346d5b070729 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/section-config.js +++ b/app/code/Magento/Customer/view/frontend/web/js/section-config.js @@ -6,7 +6,7 @@ define(['underscore'], function (_) { 'use strict'; - var baseUrls, sections, clientSideSections, canonize; + var baseUrls, sections, clientSideSections, sectionNames, canonize; /** * @param {String} url @@ -70,6 +70,15 @@ define(['underscore'], function (_) { return _.contains(clientSideSections, sectionName); }, + /** + * Returns array of section names. + * + * @returns {Array} + */ + getSectionNames: function () { + return sectionNames; + }, + /** * @param {Object} options * @constructor @@ -78,6 +87,7 @@ define(['underscore'], function (_) { baseUrls = options.baseUrls; sections = options.sections; clientSideSections = options.clientSideSections; + sectionNames = options.sectionNames; } }; }); diff --git a/app/code/Magento/CustomerGraphQl/Model/Customer/Address/ExtractCustomerAddressData.php b/app/code/Magento/CustomerGraphQl/Model/Customer/Address/ExtractCustomerAddressData.php index a4649bccc02e8..8741bff7aa88d 100644 --- a/app/code/Magento/CustomerGraphQl/Model/Customer/Address/ExtractCustomerAddressData.php +++ b/app/code/Magento/CustomerGraphQl/Model/Customer/Address/ExtractCustomerAddressData.php @@ -125,6 +125,8 @@ public function execute(AddressInterface $address): array } $addressData = array_merge($addressData, $customAttributes); + $addressData['customer_id'] = null; + return $addressData; } } diff --git a/app/code/Magento/CustomerGraphQl/Model/Customer/ExtractCustomerData.php b/app/code/Magento/CustomerGraphQl/Model/Customer/ExtractCustomerData.php index de37482aca056..542165b49dc13 100644 --- a/app/code/Magento/CustomerGraphQl/Model/Customer/ExtractCustomerData.php +++ b/app/code/Magento/CustomerGraphQl/Model/Customer/ExtractCustomerData.php @@ -101,8 +101,11 @@ public function execute(CustomerInterface $customer): array } } $customerData = array_merge($customerData, $customAttributes); - + //Field is deprecated and should not be exposed on storefront. + $customerData['group_id'] = null; $customerData['model'] = $customer; + $customerData['id'] = null; + return $customerData; } } diff --git a/app/code/Magento/CustomerGraphQl/etc/schema.graphqls b/app/code/Magento/CustomerGraphQl/etc/schema.graphqls index d27debdc39c64..b5cd2f595a4c9 100644 --- a/app/code/Magento/CustomerGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CustomerGraphQl/etc/schema.graphqls @@ -36,13 +36,13 @@ input CustomerAddressInput { prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") suffix: String @doc(description: "A value such as Sr., Jr., or III") vat_id: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") - custom_attributes: [CustomerAddressAttributeInput] @doc(description: "Address custom attributes") + custom_attributes: [CustomerAddressAttributeInput] @doc(description: "Deprecated: Custom attributes should not be put into container.") } input CustomerAddressRegionInput @doc(description: "CustomerAddressRegionInput defines the customer's state or province") { region_code: String @doc(description: "The address region code") region: String @doc(description: "The state or province name") - region_id: Int @doc(description: "Uniquely identifies the region") + region_id: Int @doc(description: "region_id is deprecated. Region ID is excessive on storefront and region code should suffice for all scenarios") } input CustomerAddressAttributeInput { @@ -78,7 +78,7 @@ type RevokeCustomerTokenOutput { type Customer @doc(description: "Customer defines the customer name and address and other details") { created_at: String @doc(description: "Timestamp indicating when the account was created") - group_id: Int @doc(description: "The group assigned to the user. Default values are 0 (Not logged in), 1 (General), 2 (Wholesale), and 3 (Retailer)") + group_id: Int @deprecated(reason: "Customer group should not be exposed in the storefront scenarios") prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") firstname: String @doc(description: "The customer's first name") middlename: String @doc(description: "The customer's middle name") @@ -89,7 +89,7 @@ type Customer @doc(description: "Customer defines the customer name and address default_shipping: String @doc(description: "The ID assigned to the shipping address") dob: String @doc(description: "The customer's date of birth") taxvat: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") - id: Int @doc(description: "The ID assigned to the customer") + id: Int @doc(description: "The ID assigned to the customer") @deprecated(reason: "id is not needed as part of Customer because on server side it can be identified based on customer token used for authentication. There is no need to know customer ID on the client side.") is_subscribed: Boolean @doc(description: "Indicates whether the customer is subscribed to the company's newsletter") @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\IsSubscribed") addresses: [CustomerAddress] @doc(description: "An array containing the customer's shipping and billing addresses") @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\CustomerAddresses") gender: Int @doc(description: "The customer's gender(Male - 1, Female - 2)") @@ -97,9 +97,9 @@ type Customer @doc(description: "Customer defines the customer name and address type CustomerAddress @doc(description: "CustomerAddress contains detailed information about a customer's billing and shipping addresses"){ id: Int @doc(description: "The ID assigned to the address object") - customer_id: Int @doc(description: "The customer ID") + customer_id: Int @doc(description: "The customer ID") @deprecated(reason: "customer_id is not needed as part of CustomerAddress, address ID (id) is unique identifier for the addresses.") region: CustomerAddressRegion @doc(description: "An object containing the region name, region code, and region ID") - region_id: Int @doc(description: "A number that uniquely identifies the state, province, or other area") + region_id: Int @deprecated(reason: "Region ID is excessive on storefront and region code should suffice for all scenarios") country_id: String @doc(description: "The customer's country") street: [String] @doc(description: "An array of strings that define the street number and name") company: String @doc(description: "The customer's company") @@ -115,14 +115,14 @@ type CustomerAddress @doc(description: "CustomerAddress contains detailed inform vat_id: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") default_shipping: Boolean @doc(description: "Indicates whether the address is the default shipping address") default_billing: Boolean @doc(description: "Indicates whether the address is the default billing address") - custom_attributes: [CustomerAddressAttribute] @doc(description: "Address custom attributes") + custom_attributes: [CustomerAddressAttribute] @deprecated(reason: "Custom attributes should not be put into container") extension_attributes: [CustomerAddressAttribute] @doc(description: "Address extension attributes") } type CustomerAddressRegion @doc(description: "CustomerAddressRegion defines the customer's state or province") { region_code: String @doc(description: "The address region code") region: String @doc(description: "The state or province name") - region_id: Int @doc(description: "Uniquely identifies the region") + region_id: Int @deprecated(reason: "Region ID is excessive on storefront and region code should suffice for all scenarios") } type CustomerAddressAttribute { diff --git a/app/code/Magento/CustomerImportExport/Model/Import/Customer.php b/app/code/Magento/CustomerImportExport/Model/Import/Customer.php index 14759bd130f2b..f86ebaea69730 100644 --- a/app/code/Magento/CustomerImportExport/Model/Import/Customer.php +++ b/app/code/Magento/CustomerImportExport/Model/Import/Customer.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\CustomerImportExport\Model\Import; use Magento\Customer\Api\Data\CustomerInterface; @@ -21,7 +23,7 @@ class Customer extends AbstractCustomer { /** - * Attribute collection name + * Collection name attribute */ const ATTRIBUTE_COLLECTION_NAME = \Magento\Customer\Model\ResourceModel\Attribute\Collection::class; @@ -519,8 +521,10 @@ protected function _importData() ); } elseif ($this->getBehavior($rowData) == \Magento\ImportExport\Model\Import::BEHAVIOR_ADD_UPDATE) { $processedData = $this->_prepareDataForUpdate($rowData); + // phpcs:disable Magento2.Performance.ForeachArrayMerge $entitiesToCreate = array_merge($entitiesToCreate, $processedData[self::ENTITIES_TO_CREATE_KEY]); $entitiesToUpdate = array_merge($entitiesToUpdate, $processedData[self::ENTITIES_TO_UPDATE_KEY]); + // phpcs:enable foreach ($processedData[self::ATTRIBUTES_TO_SAVE_KEY] as $tableName => $customerAttributes) { if (!isset($attributesToSave[$tableName])) { $attributesToSave[$tableName] = []; @@ -598,14 +602,18 @@ protected function _validateRowForUpdate(array $rowData, $rowNumber) $isFieldNotSetAndCustomerDoesNotExist = !isset($rowData[$attributeCode]) && !$this->_getCustomerId($email, $website); $isFieldSetAndTrimmedValueIsEmpty - = isset($rowData[$attributeCode]) && '' === trim($rowData[$attributeCode]); + = isset($rowData[$attributeCode]) && '' === trim((string)$rowData[$attributeCode]); if ($isFieldRequired && ($isFieldNotSetAndCustomerDoesNotExist || $isFieldSetAndTrimmedValueIsEmpty)) { $this->addRowError(self::ERROR_VALUE_IS_REQUIRED, $rowNumber, $attributeCode); continue; } - if (isset($rowData[$attributeCode]) && strlen($rowData[$attributeCode])) { + if (isset($rowData[$attributeCode]) && strlen((string)$rowData[$attributeCode])) { + if ($attributeParams['type'] == 'select') { + continue; + } + $this->isAttributeValid( $attributeCode, $attributeParams, diff --git a/app/code/Magento/Deploy/Service/Bundle.php b/app/code/Magento/Deploy/Service/Bundle.php index f16b93a185595..26e61624c219e 100644 --- a/app/code/Magento/Deploy/Service/Bundle.php +++ b/app/code/Magento/Deploy/Service/Bundle.php @@ -216,7 +216,7 @@ private function isExcluded($filePath, $area, $theme) $excludedFiles = $this->bundleConfig->getExcludedFiles($area, $theme); foreach ($excludedFiles as $excludedFileId) { $excludedFilePath = $this->prepareExcludePath($excludedFileId); - if ($excludedFilePath === $filePath) { + if ($excludedFilePath === $filePath || $excludedFilePath === str_replace('.min.js', '.js', $filePath)) { return true; } } diff --git a/app/code/Magento/Deploy/Test/Mftf/Suite/MagentoDeveloperModeOnlyTestSuite.xml b/app/code/Magento/Deploy/Test/Mftf/Suite/MagentoDeveloperModeOnlyTestSuite.xml new file mode 100644 index 0000000000000..3a7d3663c8875 --- /dev/null +++ b/app/code/Magento/Deploy/Test/Mftf/Suite/MagentoDeveloperModeOnlyTestSuite.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<suites xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Suite/etc/suiteSchema.xsd"> + <suite name="MagentoDeveloperModeOnlyTestSuite"> + <before> + <magentoCLI command="deploy:mode:set developer" stepKey="enableDeveloperMode"/> + </before> + <include> + <group name="developer_mode_only"/> + </include> + <after> + <!-- Command should be uncommented once MQE-1711 is resolved --> + <comment userInput="Command should be uncommented once MQE-1711 is resolved" stepKey="comment" /> + <!-- <magentoCLI command="deploy:mode:set production" stepKey="enableProductionMode"/> --> + </after> + </suite> +</suites> diff --git a/app/code/Magento/Deploy/Test/Mftf/Suite/MagentoProductionModeOnlyTestSuite.xml b/app/code/Magento/Deploy/Test/Mftf/Suite/MagentoProductionModeOnlyTestSuite.xml new file mode 100644 index 0000000000000..bf7014cdbb49d --- /dev/null +++ b/app/code/Magento/Deploy/Test/Mftf/Suite/MagentoProductionModeOnlyTestSuite.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<suites xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Suite/etc/suiteSchema.xsd"> + <suite name="MagentoProductionModeOnlyTestSuite"> + <before> + <!-- Command should be uncommented once MQE-1711 is resolved --> + <comment userInput="Command should be uncommented once MQE-1711 is resolved" stepKey="comment" /> + <!-- <magentoCLI command="deploy:mode:set production" stepKey="enableProductionMode"/> --> + </before> + <include> + <group name="production_mode_only"/> + </include> + <after> + <comment userInput="Command should be uncommented once MQE-1711 is resolved" stepKey="comment" /> + </after> + </suite> +</suites> diff --git a/app/code/Magento/Directory/Model/Observer.php b/app/code/Magento/Directory/Model/Observer.php index e35c2de5cee5b..6d227e7148261 100644 --- a/app/code/Magento/Directory/Model/Observer.php +++ b/app/code/Magento/Directory/Model/Observer.php @@ -12,6 +12,11 @@ namespace Magento\Directory\Model; +/** + * Class Observer + * + * @package Magento\Directory\Model + */ class Observer { const CRON_STRING_PATH = 'crontab/default/jobs/currency_rates_update/schedule/cron_expr'; @@ -83,6 +88,8 @@ public function __construct( } /** + * Schedule update currency rates + * * @param mixed $schedule * @return void * @throws \Exception @@ -122,7 +129,7 @@ public function scheduledUpdateCurrencyRates($schedule) $importWarnings[] = __('FATAL ERROR:') . ' ' . __('Please specify the correct Import Service.'); } - if (sizeof($errors) > 0) { + if (count($errors) > 0) { foreach ($errors as $error) { $importWarnings[] = __('WARNING:') . ' ' . $error; } @@ -132,7 +139,7 @@ public function scheduledUpdateCurrencyRates($schedule) self::XML_PATH_ERROR_RECIPIENT, \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); - if (sizeof($importWarnings) == 0) { + if (count($importWarnings) == 0) { $this->_currencyFactory->create()->saveRates($rates); } elseif ($errorRecipient) { //if $errorRecipient is not set, there is no sense send email to nobody diff --git a/app/code/Magento/Directory/etc/crontab.xml b/app/code/Magento/Directory/etc/crontab.xml index d6868ff6aa0d6..589cd394d7cf1 100644 --- a/app/code/Magento/Directory/etc/crontab.xml +++ b/app/code/Magento/Directory/etc/crontab.xml @@ -7,6 +7,8 @@ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd"> <group id="default"> - <job name="currency_rates_update" instance="Magento\Directory\Model\Observer" method="scheduledUpdateCurrencyRates" /> + <job name="currency_rates_update" instance="Magento\Directory\Model\Observer" method="scheduledUpdateCurrencyRates"> + <config_path>crontab/default/jobs/currency_rates_update/schedule/cron_expr</config_path> + </job> </group> </config> diff --git a/app/code/Magento/Directory/etc/db_schema.xml b/app/code/Magento/Directory/etc/db_schema.xml index c11e0ee525e37..163e972423b98 100644 --- a/app/code/Magento/Directory/etc/db_schema.xml +++ b/app/code/Magento/Directory/etc/db_schema.xml @@ -8,7 +8,7 @@ <schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="directory_country" resource="default" engine="innodb" comment="Directory Country"> - <column xsi:type="varchar" name="country_id" nullable="false" length="2" comment="Country Id in ISO-2"/> + <column xsi:type="varchar" name="country_id" nullable="false" length="2" comment="Country ID in ISO-2"/> <column xsi:type="varchar" name="iso2_code" nullable="true" length="2" comment="Country ISO-2 format"/> <column xsi:type="varchar" name="iso3_code" nullable="true" length="3" comment="Country ISO-3"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -17,8 +17,8 @@ </table> <table name="directory_country_format" resource="default" engine="innodb" comment="Directory Country Format"> <column xsi:type="int" name="country_format_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Country Format Id"/> - <column xsi:type="varchar" name="country_id" nullable="true" length="2" comment="Country Id in ISO-2"/> + comment="Country Format ID"/> + <column xsi:type="varchar" name="country_id" nullable="true" length="2" comment="Country ID in ISO-2"/> <column xsi:type="varchar" name="type" nullable="true" length="30" comment="Country Format Type"/> <column xsi:type="text" name="format" nullable="false" comment="Country Format"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -31,9 +31,9 @@ </table> <table name="directory_country_region" resource="default" engine="innodb" comment="Directory Country Region"> <column xsi:type="int" name="region_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Region Id"/> + comment="Region ID"/> <column xsi:type="varchar" name="country_id" nullable="false" length="4" default="0" - comment="Country Id in ISO-2"/> + comment="Country ID in ISO-2"/> <column xsi:type="varchar" name="code" nullable="true" length="32" comment="Region code"/> <column xsi:type="varchar" name="default_name" nullable="true" length="255" comment="Region Name"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -47,7 +47,7 @@ comment="Directory Country Region Name"> <column xsi:type="varchar" name="locale" nullable="false" length="8" comment="Locale"/> <column xsi:type="int" name="region_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Region Id"/> + default="0" comment="Region ID"/> <column xsi:type="varchar" name="name" nullable="true" length="255" comment="Region Name"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="locale"/> diff --git a/app/code/Magento/Downloadable/Test/Mftf/ActionGroup/StorefrontOpenDownloadableLinkActionGroup.xml b/app/code/Magento/Downloadable/Test/Mftf/ActionGroup/StorefrontOpenDownloadableLinkActionGroup.xml new file mode 100644 index 0000000000000..565439655138e --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/ActionGroup/StorefrontOpenDownloadableLinkActionGroup.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontOpenDownloadableLinkActionGroup"> + <arguments> + <argument name="linkId" type="string"/> + </arguments> + <amOnPage url="{{StorefrontDownloadableLinkPage.url(linkId)}}" stepKey="openDownloadableLink"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Downloadable/Test/Mftf/ActionGroup/StorefrontOpenDownloadableSampleActionGroup.xml b/app/code/Magento/Downloadable/Test/Mftf/ActionGroup/StorefrontOpenDownloadableSampleActionGroup.xml new file mode 100644 index 0000000000000..25ac45317fe42 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/ActionGroup/StorefrontOpenDownloadableSampleActionGroup.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontOpenDownloadableSampleActionGroup"> + <arguments> + <argument name="sampleId" type="string"/> + </arguments> + <amOnPage url="{{StorefrontDownloadableSamplePage.url(sampleId)}}" stepKey="openDownloadableSample"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Data/LinkData.xml b/app/code/Magento/Downloadable/Test/Mftf/Data/LinkData.xml index 08f1c2349357d..eb3ad674a0fdf 100644 --- a/app/code/Magento/Downloadable/Test/Mftf/Data/LinkData.xml +++ b/app/code/Magento/Downloadable/Test/Mftf/Data/LinkData.xml @@ -63,6 +63,12 @@ <data key="file_type">URL</data> <data key="file">https://static.magento.com/sites/all/themes/mag_redesign/images/magento-logo.svg</data> </entity> + <entity name="DownloadableSample" type="downloadable_sample"> + <data key="title" unique="suffix">downloadableSampleUrl</data> + <data key="sort_order">1</data> + <data key="sample_type">url</data> + <data key="sample_url">http://example.com</data> + </entity> <entity name="ApiDownloadableLink" type="downloadable_link"> <data key="title" unique="suffix">Api Downloadable Link</data> <data key="price">2.00</data> @@ -72,4 +78,4 @@ <data key="sort_order">0</data> <data key="link_url">https://static.magento.com/sites/all/themes/mag_redesign/images/magento-logo.svg</data> </entity> -</entities> \ No newline at end of file +</entities> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Metadata/downloadable_link_sample-meta.xml b/app/code/Magento/Downloadable/Test/Mftf/Metadata/downloadable_link_sample-meta.xml new file mode 100644 index 0000000000000..b26bbb7af5a35 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Metadata/downloadable_link_sample-meta.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CreateDownloadableSample" dataType="downloadable_sample" type="create" auth="adminOauth" url="/V1/products/{sku}/downloadable-links/samples" method="POST"> + <contentType>application/json</contentType> + <object dataType="downloadable_sample" key="sample"> + <field key="title">string</field> + <field key="sort_order">integer</field> + <field key="sample_type">string</field> + <field key="sample_file">string</field> + <field key="sample_file_content">sample_file_content</field> + <field key="sample_url">string</field> + </object> + <field key="isGlobalScopeContent">boolean</field> + </operation> +</operations> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontDownloadableLinkPage.xml b/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontDownloadableLinkPage.xml new file mode 100644 index 0000000000000..7ab6b211d7441 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontDownloadableLinkPage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontDownloadableLinkPage" url="downloadable/download/linkSample/link_id/{{id}}/" area="storefront" module="Magento_Downloadable" parameterized="true"> + </page> +</pages> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontDownloadableSamplePage.xml b/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontDownloadableSamplePage.xml new file mode 100644 index 0000000000000..0d588faa777c0 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontDownloadableSamplePage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontDownloadableSamplePage" url="downloadable/download/sample/sample_id/{{id}}/" area="storefront" module="Magento_Downloadable" parameterized="true"> + </page> +</pages> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontProductPage.xml b/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontProductPage.xml new file mode 100644 index 0000000000000..7b9d205d19dc5 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Page/StorefrontProductPage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontProductPage" url="/{{var1}}.html" area="storefront" module="Magento_Catalog" parameterized="true"> + <section name="StorefrontDownloadableProductSection" /> + </page> +</pages> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Section/StorefrontDownloadableProductSection.xml b/app/code/Magento/Downloadable/Test/Mftf/Section/StorefrontDownloadableProductSection.xml index a1db2d4d94941..20b62ef060309 100644 --- a/app/code/Magento/Downloadable/Test/Mftf/Section/StorefrontDownloadableProductSection.xml +++ b/app/code/Magento/Downloadable/Test/Mftf/Section/StorefrontDownloadableProductSection.xml @@ -12,5 +12,8 @@ <element name="downloadableLinkBlock" type="text" selector="//div[contains(@class, 'field downloads required')]//span[text()='Downloadable Links']"/> <element name="downloadableLinkLabel" type="text" selector="//label[contains(., '{{title}}')]" parameterized="true" timeout="30"/> <element name="downloadableLinkByTitle" type="input" selector="//*[@id='downloadable-links-list']/*[contains(.,'{{title}}')]//input" parameterized="true" timeout="30"/> + <element name="downloadableLinkSampleByTitle" type="text" selector="//label[contains(., '{{title}}')]/a[contains(@class, 'sample link')]" parameterized="true"/> + <element name="downloadableSampleLabel" type="text" selector="//a[contains(.,normalize-space('{{title}}'))]" parameterized="true" timeout="30"/> + <element name="downloadableLinkSelectAllCheckbox" type="checkbox" selector="#links_all" /> </section> </sections> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml b/app/code/Magento/Downloadable/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml new file mode 100644 index 0000000000000..8cb0d5fde9863 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminDownloadableProductTypeSwitchingToConfigurableProductTest" extends="AdminSimpleProductTypeSwitchingToConfigurableProductTest"> + <annotations> + <features value="Downloadable"/> + <stories value="Product type switching"/> + <title value="Downloadable product type switching on editing to configurable product"/> + <description value="Downloadable product type switching on editing to configurable product"/> + <testCaseId value="MC-17957"/> + <useCaseId value="MAGETWO-44170"/> + <severity value="MAJOR"/> + <group value="catalog"/> + </annotations> + <!-- Open Dropdown and select downloadable product option --> + <click selector="{{AdminProductDownloadableSection.sectionHeader}}" stepKey="openDownloadableSection" after="waitForSimpleProductPageLoad"/> + <uncheckOption selector="{{AdminProductDownloadableSection.isDownloadableProduct}}" stepKey="checkOptionIsDownloadable" after="openDownloadableSection"/> + <selectOption selector="{{AdminProductFormSection.productWeightSelect}}" userInput="This item has weight" stepKey="selectWeightForProduct" after="checkOptionIsDownloadable"/> + <actionGroup ref="saveProductForm" stepKey="saveDownloadableProductForm" after="selectWeightForProduct"/> + </test> + <test name="AdminSimpleProductTypeSwitchingToDownloadableProductTest"> + <annotations> + <features value="Downloadable"/> + <stories value="Product type switching"/> + <title value="Simple product type switching on editing to downloadable product"/> + <description value="Simple product type switching on editing to downloadable product"/> + <testCaseId value="MC-17956"/> + <useCaseId value="MAGETWO-44170"/> + <severity value="MAJOR"/> + <group value="catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create product--> + <comment userInput="Create product" stepKey="commentCreateProduct"/> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + </before> + <after> + <!--Delete product--> + <comment userInput="Delete product" stepKey="commentDeleteProduct"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!--Change product type to Downloadable--> + <comment userInput="Change product type to Downloadable" stepKey="commentCreateDownloadable"/> + <amOnPage url="{{AdminProductEditPage.url($$createProduct.id$$)}}" stepKey="gotToDownloadableProductPage"/> + <waitForPageLoad stepKey="waitForDownloadableProductPageLoad"/> + <selectOption selector="{{AdminProductFormSection.productWeightSelect}}" userInput="This item has no weight" stepKey="selectNoWeightForProduct"/> + <actionGroup ref="AdminAddDownloadableLinkInformationActionGroup" stepKey="addDownloadableLinkInformation"/> + <checkOption selector="{{AdminProductDownloadableSection.isLinksPurchasedSeparately}}" stepKey="checkOptionPurchaseSeparately"/> + <actionGroup ref="addDownloadableProductLinkWithMaxDownloads" stepKey="addDownloadableProductLink"> + <argument name="link" value="downloadableLinkWithMaxDownloads"/> + </actionGroup> + <actionGroup ref="saveProductForm" stepKey="saveDownloadableProductForm"/> + <!--Assert downloadable product on Admin product page grid--> + <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertDownloadableProductOnAdmin"/> + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> + <actionGroup ref="filterProductGridBySku2" stepKey="filterProductGridBySku"> + <argument name="sku" value="$$createProduct.sku$$"/> + </actionGroup> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Name')}}" userInput="$$createProduct.name$$" stepKey="seeDownloadableProductNameInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Type')}}" userInput="Downloadable Product" stepKey="seeDownloadableProductTypeInGrid"/> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearDownloadableProductFilters"/> + <!--Assert downloadable product on storefront--> + <comment userInput="Assert downloadable product on storefront" stepKey="commentAssertDownloadableProductOnStorefront"/> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="openDownloadableProductPage"/> + <waitForPageLoad stepKey="waitForStorefrontDownloadableProductPageLoad"/> + <see userInput="IN STOCK" selector="{{StorefrontProductInfoMainSection.productStockStatus}}" stepKey="assertDownloadableProductInStock"/> + <scrollTo selector="{{StorefrontDownloadableProductSection.downloadableLinkBlock}}" stepKey="scrollToLinksInStorefront"/> + <seeElement selector="{{StorefrontDownloadableProductSection.downloadableLinkLabel(downloadableLinkWithMaxDownloads.title)}}" stepKey="seeDownloadableLink" /> + </test> +</tests> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Test/EditDownloadableProductWithSeparateLinksFromCartTest.xml b/app/code/Magento/Downloadable/Test/Mftf/Test/EditDownloadableProductWithSeparateLinksFromCartTest.xml new file mode 100644 index 0000000000000..0b905964fd2d9 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Test/EditDownloadableProductWithSeparateLinksFromCartTest.xml @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="EditDownloadableProductWithSeparateLinksFromCartTest"> + <annotations> + <features value="Catalog"/> + <stories value="Create Downloadable Product"/> + <title value="Edit downloadable product with separate links from cart test"/> + <description value="Product price should remain correct when editing downloadable product with separate links from cart."/> + <severity value="MAJOR"/> + <group value="Downloadable"/> + </annotations> + <before> + <!-- Create category --> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + + <!-- Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="LoginAsAdmin"/> + + <!-- Create downloadable product --> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="amOnProductGridPage"/> + <waitForPageLoad stepKey="waitForProductGridPageLoad"/> + <actionGroup ref="GoToSpecifiedCreateProductPage" stepKey="createProduct"> + <argument name="productType" value="downloadable"/> + </actionGroup> + + <!-- Fill downloadable product values --> + <actionGroup ref="fillMainProductFormNoWeight" stepKey="fillDownloadableProductForm"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Add downloadable product to category --> + <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" + parameterArray="[$$createCategory.name$$]" stepKey="fillCategory"/> + + <!-- Fill downloadable link information before the creation link --> + <actionGroup ref="AdminAddDownloadableLinkInformationActionGroup" stepKey="addDownloadableLinkInformation"/> + + <!-- Links can be purchased separately --> + <checkOption selector="{{AdminProductDownloadableSection.isLinksPurchasedSeparately}}" + stepKey="checkOptionPurchaseSeparately"/> + + <!-- Add first downloadable link --> + <actionGroup ref="addDownloadableProductLinkWithMaxDownloads" stepKey="addFirstDownloadableProductLink"> + <argument name="link" value="downloadableLinkWithMaxDownloads"/> + </actionGroup> + + <!-- Add second downloadable link --> + <actionGroup ref="addDownloadableProductLink" stepKey="addSecondDownloadableProductLink"> + <argument name="link" value="downloadableLink"/> + </actionGroup> + + <!-- Save product --> + <actionGroup ref="saveProductForm" stepKey="saveProduct"/> + </before> + <after> + <!-- Delete category --> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <!-- Delete created downloadable product --> + <actionGroup ref="deleteProductUsingProductGrid" stepKey="deleteProduct"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Log out --> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Step 1: Navigate to store front Product page as guest --> + <amOnPage url="/{{DownloadableProduct.sku}}.html" + stepKey="amOnStorefrontProductPage"/> + + <!-- Step 2: Add checkbox for first link --> + <click + selector="{{StorefrontDownloadableProductSection.downloadableLinkByTitle(downloadableLinkWithMaxDownloads.title)}}" + stepKey="selectProductLink"/> + + <!-- Step 3: Add the Product to cart --> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="addProductToCart"> + <argument name="product" value="DownloadableProduct"/> + <argument name="productCount" value="1"/> + </actionGroup> + + <!-- Step 4: Open cart --> + <amOnPage url="{{CheckoutCartPage.url}}" stepKey="openShoppingCartPage"/> + <waitForPageLoad stepKey="waitForShoppingCartPageLoad"/> + <see selector="{{CheckoutCartProductSection.ProductPriceByName(DownloadableProduct.name)}}" userInput="$51.99" + stepKey="assertProductPriceInCart"/> + + <!-- Step 5: Edit Product in cart --> + <click selector="{{CheckoutCartProductSection.nthEditButton('1')}}" stepKey="clickEdit"/> + <waitForPageLoad stepKey="waitForEditPage"/> + + <!-- Step 6: Make sure Product price is correct --> + <see selector="{{StorefrontProductInfoMainSection.productPrice}}" userInput="51.99" stepKey="checkPrice"/> + </test> +</tests> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Test/SelectAllDownloadableLinksDownloadableProductTest.xml b/app/code/Magento/Downloadable/Test/Mftf/Test/SelectAllDownloadableLinksDownloadableProductTest.xml new file mode 100644 index 0000000000000..94940f0e08195 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Test/SelectAllDownloadableLinksDownloadableProductTest.xml @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="SelectAllDownloadableLinksDownloadableProductTest"> + <annotations> + <features value="Catalog"/> + <stories value="Create Downloadable Product"/> + <title value="Select all downloadable links downloadable product test"/> + <description value="All the downloadable links must be selected or unselected when anyone click on select all or unselect all checkbox respectively."/> + <severity value="MAJOR"/> + <group value="Downloadable"/> + </annotations> + <before> + <!-- Create category --> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + + <!-- Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="LoginAsAdmin"/> + + <!-- Create downloadable product --> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="amOnProductGridPage"/> + <waitForPageLoad stepKey="waitForProductGridPageLoad"/> + <actionGroup ref="GoToSpecifiedCreateProductPage" stepKey="createProduct"> + <argument name="productType" value="downloadable"/> + </actionGroup> + + <!-- Fill downloadable product values --> + <actionGroup ref="fillMainProductFormNoWeight" stepKey="fillDownloadableProductForm"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Add downloadable product to category --> + <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" + parameterArray="[$$createCategory.name$$]" stepKey="fillCategory"/> + + <!-- Fill downloadable link information before the creation link --> + <actionGroup ref="AdminAddDownloadableLinkInformationActionGroup" stepKey="addDownloadableLinkInformation"/> + + <!-- Links can be purchased separately --> + <checkOption selector="{{AdminProductDownloadableSection.isLinksPurchasedSeparately}}" + stepKey="checkOptionPurchaseSeparately"/> + + <!-- Add first downloadable link --> + <actionGroup ref="addDownloadableProductLinkWithMaxDownloads" stepKey="addFirstDownloadableProductLink"> + <argument name="link" value="downloadableLinkWithMaxDownloads"/> + </actionGroup> + + <!-- Add second downloadable link --> + <actionGroup ref="addDownloadableProductLink" stepKey="addSecondDownloadableProductLink"> + <argument name="link" value="downloadableLink"/> + </actionGroup> + + <!-- Save product --> + <actionGroup ref="saveProductForm" stepKey="saveProduct"/> + </before> + <after> + <!-- Delete category --> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <!-- Delete created downloadable product --> + <actionGroup ref="deleteProductUsingProductGrid" stepKey="deleteProduct"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Log out --> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Step 1: Navigate to store front Product page as guest --> + <amOnPage url="/{{DownloadableProduct.sku}}.html" + stepKey="amOnStorefrontProductPage"/> + + <!-- Step 2: click on select all checkbox --> + <click + selector="{{StorefrontDownloadableProductSection.downloadableLinkSelectAllCheckbox}}" + stepKey="selectAllProductLink"/> + + <!-- Step 3: Make sure that all product links are checked --> + <seeCheckboxIsChecked selector="{{StorefrontDownloadableProductSection.downloadableLinkByTitle(downloadableLinkWithMaxDownloads.title)}}" stepKey="seeFirstCheckboxChecked"/> + + <seeCheckboxIsChecked selector="{{StorefrontDownloadableProductSection.downloadableLinkByTitle(downloadableLink.title)}}" stepKey="seeSecondCheckboxChecked"/> + + <!-- Step 4: click again on select all checkbox --> + <click + selector="{{StorefrontDownloadableProductSection.downloadableLinkSelectAllCheckbox}}" + stepKey="unselectAllProductLink"/> + + <!-- Step 5: Make sure that all product links are unchecked --> + <dontSeeCheckboxIsChecked selector="{{StorefrontDownloadableProductSection.downloadableLinkByTitle(downloadableLinkWithMaxDownloads.title)}}" stepKey="seeFirstCheckboxUnChecked"/> + + <dontSeeCheckboxIsChecked selector="{{StorefrontDownloadableProductSection.downloadableLinkByTitle(downloadableLink.title)}}" stepKey="seeSecondCheckboxUnChecked"/> + + </test> +</tests> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Test/VerifyDisableDownloadableProductSamplesAreNotAccessibleTest.xml b/app/code/Magento/Downloadable/Test/Mftf/Test/VerifyDisableDownloadableProductSamplesAreNotAccessibleTest.xml new file mode 100644 index 0000000000000..f29bbcf925e26 --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Mftf/Test/VerifyDisableDownloadableProductSamplesAreNotAccessibleTest.xml @@ -0,0 +1,114 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="VerifyDisableDownloadableProductSamplesAreNotAccessibleTest"> + <annotations> + <features value="Downloadable"/> + <stories value="Downloadable product"/> + <title value="Samples of Downloadable Products are not accessible, if product is disabled"/> + <description value="Samples of Downloadable Products are not accessible, if product is disabled"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-15845"/> + <useCaseId value="MC-14824"/> + <group value="downloadable"/> + <group value="catalog"/> + </annotations> + <before> + <!-- Create category --> + <createData entity="_defaultCategory" stepKey="createCategory"/> + + <!-- Create downloadable product --> + <createData entity="DownloadableProductWithOneLink" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <!-- Add downloadable link --> + <createData entity="downloadableLink1" stepKey="addDownloadableLink"> + <requiredEntity createDataKey="createProduct"/> + </createData> + + <!-- Add downloadable sample --> + <createData entity="DownloadableSample" stepKey="addDownloadableSample"> + <requiredEntity createDataKey="createProduct"/> + </createData> + </before> + <after> + <!-- Delete product --> + <deleteData createDataKey="createProduct" stepKey="deleteDownloadableProduct"/> + + <!-- Delete category --> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <!-- Admin logout --> + <actionGroup ref="logout" stepKey="adminLogout"/> + </after> + + <!-- Open Downloadable product from precondition on Storefront --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openStorefrontProductPage"> + <argument name="productUrl" value="$createProduct.custom_attributes[url_key]$"/> + </actionGroup> + + <!-- Sample url is accessible --> + <actionGroup ref="AssertStorefrontSeeElementActionGroup" stepKey="seeDownloadableSample"> + <argument name="selector" value="{{StorefrontDownloadableProductSection.downloadableSampleLabel(DownloadableSample.title)}}"/> + </actionGroup> + <click selector="{{StorefrontDownloadableProductSection.downloadableSampleLabel(DownloadableSample.title)}}" stepKey="clickDownloadableSample"/> + + <!-- Grab Sample id --> + <switchToNextTab stepKey="switchToSampleTab"/> + <grabFromCurrentUrl regex="~/sample_id/(\d+)/~" stepKey="grabDownloadableSampleId"/> + <closeTab stepKey="closeSampleTab"/> + + <!-- Link Sample url is accessible --> + <actionGroup ref="AssertStorefrontSeeElementActionGroup" stepKey="seeDownloadableLink"> + <argument name="selector" value="{{StorefrontDownloadableProductSection.downloadableLinkLabel(downloadableLink1.title)}}"/> + </actionGroup> + <click selector="{{StorefrontDownloadableProductSection.downloadableLinkSampleByTitle(downloadableLink1.title)}}" stepKey="clickDownloadableLinkSample"/> + + <!-- Grab Link Sample id --> + <switchToNextTab stepKey="switchToLinkSampleTab"/> + <grabFromCurrentUrl regex="~/link_id/(\d+)/~" stepKey="grabDownloadableLinkId"/> + <closeTab stepKey="closeLinkSampleTab"/> + + <!-- Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + + <!-- Open Downloadable product from precondition --> + <actionGroup ref="goToProductPageViaID" stepKey="openProductEditPage"> + <argument name="productId" value="$createProduct.id$"/> + </actionGroup> + + <!-- Change status of product to "Disable" and save it --> + <actionGroup ref="AdminSetProductDisabled" stepKey="disableProduct"/> + <actionGroup ref="saveProductForm" stepKey="clickSaveProduct"/> + + <!-- Assert product is disable on Storefront --> + <actionGroup ref="StorefrontNavigateCategoryPageActionGroup" stepKey="openCategoryPage"> + <argument name="category" value="$createCategory$"/> + </actionGroup> + <see selector="{{StorefrontCategoryMainSection.emptyProductMessage}}" userInput="We can't find products matching the selection." stepKey="seeEmptyProductMessage"/> + + <!-- Navigate to Link Sample url on Storefront --> + <actionGroup ref="StorefrontOpenDownloadableLinkActionGroup" stepKey="openDownloadableLinkSample"> + <argument name="linkId" value="{$grabDownloadableLinkId}"/> + </actionGroup> + + <!-- Link Sample url is not accessible. You are redirected to Home Page --> + <seeInCurrentUrl url="{{StorefrontHomePage.url}}" stepKey="seeRedirectToHomePage"/> + + <!-- Navigate to Sample url on Storefront --> + <actionGroup ref="StorefrontOpenDownloadableSampleActionGroup" stepKey="openDownloadableSample"> + <argument name="sampleId" value="{$grabDownloadableSampleId}"/> + </actionGroup> + + <!-- Sample url is not accessible. You are redirected to Home Page --> + <seeInCurrentUrl url="{{StorefrontHomePage.url}}" stepKey="seeHomePage"/> + </test> +</tests> diff --git a/app/code/Magento/Downloadable/view/frontend/web/js/downloadable.js b/app/code/Magento/Downloadable/view/frontend/web/js/downloadable.js index 09a5ad1afa9ec..a1e8c785c696a 100644 --- a/app/code/Magento/Downloadable/view/frontend/web/js/downloadable.js +++ b/app/code/Magento/Downloadable/view/frontend/web/js/downloadable.js @@ -38,6 +38,8 @@ define([ }); } }); + + this._reloadPrice(); }, /** diff --git a/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php b/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php index 72f4086c1c56b..7af7bf447c45a 100644 --- a/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php +++ b/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php @@ -4,16 +4,14 @@ * See COPYING.txt for license details. */ -/** - * Attribute add/edit form options tab - * - * @author Magento Core Team <core@magentocommerce.com> - */ namespace Magento\Eav\Block\Adminhtml\Attribute\Edit\Options; use Magento\Store\Model\ResourceModel\Store\Collection; +use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; /** + * Attribute add/edit form options tab + * * @api * @since 100.0.2 */ @@ -61,6 +59,7 @@ public function __construct( /** * Is true only for system attributes which use source model + * * Option labels and position for such attributes are kept in source model and thus cannot be overridden * * @return bool @@ -96,12 +95,16 @@ public function getStoresSortedBySortOrder() { $stores = $this->getStores(); if (is_array($stores)) { - usort($stores, function ($storeA, $storeB) { - if ($storeA->getSortOrder() == $storeB->getSortOrder()) { - return $storeA->getId() < $storeB->getId() ? -1 : 1; + usort( + $stores, + function ($storeA, $storeB) { + if ($storeA->getSortOrder() == $storeB->getSortOrder()) { + return $storeA->getId() < $storeB->getId() ? -1 : 1; + } + + return ($storeA->getSortOrder() < $storeB->getSortOrder()) ? -1 : 1; } - return ($storeA->getSortOrder() < $storeB->getSortOrder()) ? -1 : 1; - }); + ); } return $stores; } @@ -130,12 +133,14 @@ public function getOptionValues() } /** - * @param \Magento\Eav\Model\Entity\Attribute\AbstractAttribute $attribute + * Preparing values of attribute options + * + * @param AbstractAttribute $attribute * @param array|\Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection $optionCollection * @return array */ protected function _prepareOptionValues( - \Magento\Eav\Model\Entity\Attribute\AbstractAttribute $attribute, + AbstractAttribute $attribute, $optionCollection ) { $type = $attribute->getFrontendInput(); @@ -149,6 +154,41 @@ protected function _prepareOptionValues( $values = []; $isSystemAttribute = is_array($optionCollection); + if ($isSystemAttribute) { + $values = $this->getPreparedValues($optionCollection, $isSystemAttribute, $inputType, $defaultValues); + } else { + $optionCollection->setPageSize(200); + $pageCount = $optionCollection->getLastPageNumber(); + $currentPage = 1; + while ($currentPage <= $pageCount) { + $optionCollection->clear(); + $optionCollection->setCurPage($currentPage); + $values = array_merge( + $values, + $this->getPreparedValues($optionCollection, $isSystemAttribute, $inputType, $defaultValues) + ); + $currentPage++; + } + } + + return $values; + } + + /** + * Return prepared values of system or user defined attribute options + * + * @param array|\Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection $optionCollection + * @param bool $isSystemAttribute + * @param string $inputType + * @param array $defaultValues + */ + private function getPreparedValues( + $optionCollection, + bool $isSystemAttribute, + string $inputType, + array $defaultValues + ) { + $values = []; foreach ($optionCollection as $option) { $bunch = $isSystemAttribute ? $this->_prepareSystemAttributeOptionValues( $option, @@ -169,12 +209,13 @@ protected function _prepareOptionValues( /** * Retrieve option values collection + * * It is represented by an array in case of system attribute * - * @param \Magento\Eav\Model\Entity\Attribute\AbstractAttribute $attribute + * @param AbstractAttribute $attribute * @return array|\Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection */ - protected function _getOptionValuesCollection(\Magento\Eav\Model\Entity\Attribute\AbstractAttribute $attribute) + protected function _getOptionValuesCollection(AbstractAttribute $attribute) { if ($this->canManageOptionDefaultOnly()) { $options = $this->_universalFactory->create( @@ -226,7 +267,7 @@ protected function _prepareSystemAttributeOptionValues($option, $inputType, $def foreach ($this->getStores() as $store) { $storeId = $store->getId(); $value['store' . $storeId] = $storeId == - \Magento\Store\Model\Store::DEFAULT_STORE_ID ? $valuePrefix . $this->escapeHtml($option['label']) : ''; + \Magento\Store\Model\Store::DEFAULT_STORE_ID ? $valuePrefix . $this->escapeHtml($option['label']) : ''; } return [$value]; diff --git a/app/code/Magento/Eav/Model/Entity/Attribute.php b/app/code/Magento/Eav/Model/Entity/Attribute.php index bb2477d4df827..8bd9ca2cc03c8 100644 --- a/app/code/Magento/Eav/Model/Entity/Attribute.php +++ b/app/code/Magento/Eav/Model/Entity/Attribute.php @@ -32,12 +32,12 @@ class Attribute extends \Magento\Eav\Model\Entity\Attribute\AbstractAttribute im const ATTRIBUTE_CODE_MAX_LENGTH = 60; /** - * Attribute code min length. + * Min accepted length of an attribute code. */ const ATTRIBUTE_CODE_MIN_LENGTH = 1; /** - * Cache tag + * Tag to use for attributes caching. */ const CACHE_TAG = 'EAV_ATTRIBUTE'; @@ -311,7 +311,7 @@ public function beforeSave() } /** - * Save additional data + * @inheritdoc * * @return $this * @throws LocalizedException @@ -489,6 +489,9 @@ public function getIdentities() /** * @inheritdoc * @since 100.0.7 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -502,6 +505,9 @@ public function __sleep() /** * @inheritdoc * @since 100.0.7 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Eav/Model/Entity/Attribute/AbstractAttribute.php b/app/code/Magento/Eav/Model/Entity/Attribute/AbstractAttribute.php index 3857118ae67ca..16fe495de18db 100644 --- a/app/code/Magento/Eav/Model/Entity/Attribute/AbstractAttribute.php +++ b/app/code/Magento/Eav/Model/Entity/Attribute/AbstractAttribute.php @@ -1405,6 +1405,9 @@ public function setExtensionAttributes(\Magento\Eav\Api\Data\AttributeExtensionI /** * @inheritdoc * @since 100.0.7 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -1430,6 +1433,9 @@ public function __sleep() /** * @inheritdoc * @since 100.0.7 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Eav/Model/ResourceModel/Entity/Attribute.php b/app/code/Magento/Eav/Model/ResourceModel/Entity/Attribute.php index 0e7a46125d872..c5077733e10ae 100644 --- a/app/code/Magento/Eav/Model/ResourceModel/Entity/Attribute.php +++ b/app/code/Magento/Eav/Model/ResourceModel/Entity/Attribute.php @@ -457,6 +457,7 @@ protected function _updateAttributeOption($object, $optionId, $option) if (!empty($option['delete'][$optionId])) { if ($intOptionId) { $connection->delete($table, ['option_id = ?' => $intOptionId]); + $this->clearSelectedOptionInEntities($object, $intOptionId); } return false; } @@ -475,6 +476,41 @@ protected function _updateAttributeOption($object, $optionId, $option) return $intOptionId; } + /** + * Clear selected option in entities + * + * @param EntityAttribute|AbstractModel $object + * @param int $optionId + * @return void + */ + private function clearSelectedOptionInEntities(AbstractModel $object, int $optionId) + { + $backendTable = $object->getBackendTable(); + $attributeId = $object->getAttributeId(); + if (!$backendTable || !$attributeId) { + return; + } + + $connection = $this->getConnection(); + $where = $connection->quoteInto('attribute_id = ?', $attributeId); + $update = []; + + if ($object->getBackendType() === 'varchar') { + $where.= ' AND ' . $connection->prepareSqlCondition('value', ['finset' => $optionId]); + $concat = $connection->getConcatSql(["','", 'value', "','"]); + $expr = $connection->quoteInto( + "TRIM(BOTH ',' FROM REPLACE($concat,',?,',','))", + $optionId + ); + $update['value'] = new \Zend_Db_Expr($expr); + } else { + $where.= $connection->quoteInto(' AND value = ?', $optionId); + $update['value'] = null; + } + + $connection->update($backendTable, $update, $where); + } + /** * Save option values records per store * @@ -725,6 +761,9 @@ public function getValidAttributeIds($attributeIds) * * @return array * @since 100.0.7 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -738,6 +777,9 @@ public function __sleep() * * @return void * @since 100.0.7 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Eav/etc/db_schema.xml b/app/code/Magento/Eav/etc/db_schema.xml index b6c42d725e5e9..ba1c27c703790 100644 --- a/app/code/Magento/Eav/etc/db_schema.xml +++ b/app/code/Magento/Eav/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="eav_entity_type" resource="default" engine="innodb" comment="Eav Entity Type"> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Entity Type Id"/> + comment="Entity Type ID"/> <column xsi:type="varchar" name="entity_type_code" nullable="false" length="50" comment="Entity Type Code"/> <column xsi:type="varchar" name="entity_model" nullable="false" length="255" comment="Entity Model"/> <column xsi:type="varchar" name="attribute_model" nullable="true" length="255" comment="Attribute Model"/> @@ -21,7 +21,7 @@ <column xsi:type="varchar" name="data_sharing_key" nullable="true" length="100" default="default" comment="Data Sharing Key"/> <column xsi:type="smallint" name="default_attribute_set_id" padding="5" unsigned="true" nullable="false" - identity="false" default="0" comment="Default Attribute Set Id"/> + identity="false" default="0" comment="Default Attribute Set ID"/> <column xsi:type="varchar" name="increment_model" nullable="true" length="255" comment="Increment Model"/> <column xsi:type="smallint" name="increment_per_store" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Increment Per Store"/> @@ -44,14 +44,14 @@ <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="attribute_set_id" padding="5" unsigned="true" nullable="false" - identity="false" default="0" comment="Attribute Set Id"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + identity="false" default="0" comment="Attribute Set ID"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Parent Id"/> + default="0" comment="Parent ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" @@ -75,13 +75,13 @@ </table> <table name="eav_entity_datetime" resource="default" engine="innodb" comment="Eav Entity Value Prefix"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="datetime" name="value" on_update="false" nullable="true" comment="Attribute Value"/> @@ -115,13 +115,13 @@ </table> <table name="eav_entity_decimal" resource="default" engine="innodb" comment="Eav Entity Value Prefix"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="decimal" name="value" scale="4" precision="12" unsigned="false" nullable="false" default="0" @@ -156,13 +156,13 @@ </table> <table name="eav_entity_int" resource="default" engine="innodb" comment="Eav Entity Value Prefix"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="int" name="value" padding="11" unsigned="false" nullable="false" identity="false" default="0" @@ -196,13 +196,13 @@ </table> <table name="eav_entity_text" resource="default" engine="innodb" comment="Eav Entity Value Prefix"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="text" name="value" nullable="false" comment="Attribute Value"/> @@ -233,13 +233,13 @@ </table> <table name="eav_entity_varchar" resource="default" engine="innodb" comment="Eav Entity Value Prefix"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="varchar" name="value" nullable="true" length="255" comment="Attribute Value"/> @@ -273,9 +273,9 @@ </table> <table name="eav_attribute" resource="default" engine="innodb" comment="Eav Attribute"> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Attribute Id"/> + comment="Attribute ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="varchar" name="attribute_code" nullable="false" length="255" comment="Attribute Code"/> <column xsi:type="varchar" name="attribute_model" nullable="true" length="255" comment="Attribute Model"/> <column xsi:type="varchar" name="backend_model" nullable="true" length="255" comment="Backend Model"/> @@ -308,13 +308,13 @@ </table> <table name="eav_entity_store" resource="default" engine="innodb" comment="Eav Entity Store"> <column xsi:type="int" name="entity_store_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Store Id"/> + comment="Entity Store ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="varchar" name="increment_prefix" nullable="true" length="20" comment="Increment Prefix"/> - <column xsi:type="varchar" name="increment_last_id" nullable="true" length="50" comment="Last Incremented Id"/> + <column xsi:type="varchar" name="increment_last_id" nullable="true" length="50" comment="Last Incremented ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="entity_store_id"/> </constraint> @@ -332,9 +332,9 @@ </table> <table name="eav_attribute_set" resource="default" engine="innodb" comment="Eav Attribute Set"> <column xsi:type="smallint" name="attribute_set_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Attribute Set Id"/> + comment="Attribute Set ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="varchar" name="attribute_set_name" nullable="true" length="255" comment="Attribute Set Name"/> <column xsi:type="smallint" name="sort_order" padding="6" unsigned="false" nullable="false" identity="false" default="0" comment="Sort Order"/> @@ -355,15 +355,15 @@ </table> <table name="eav_attribute_group" resource="default" engine="innodb" comment="Eav Attribute Group"> <column xsi:type="smallint" name="attribute_group_id" padding="5" unsigned="true" nullable="false" - identity="true" comment="Attribute Group Id"/> + identity="true" comment="Attribute Group ID"/> <column xsi:type="smallint" name="attribute_set_id" padding="5" unsigned="true" nullable="false" - identity="false" default="0" comment="Attribute Set Id"/> + identity="false" default="0" comment="Attribute Set ID"/> <column xsi:type="varchar" name="attribute_group_name" nullable="true" length="255" comment="Attribute Group Name"/> <column xsi:type="smallint" name="sort_order" padding="6" unsigned="false" nullable="false" identity="false" default="0" comment="Sort Order"/> <column xsi:type="smallint" name="default_id" padding="5" unsigned="true" nullable="true" identity="false" - default="0" comment="Default Id"/> + default="0" comment="Default ID"/> <column xsi:type="varchar" name="attribute_group_code" nullable="false" length="255" comment="Attribute Group Code"/> <column xsi:type="varchar" name="tab_group_code" nullable="true" length="255" comment="Tab Group Code"/> @@ -388,15 +388,15 @@ </table> <table name="eav_entity_attribute" resource="default" engine="innodb" comment="Eav Entity Attributes"> <column xsi:type="int" name="entity_attribute_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Attribute Id"/> + comment="Entity Attribute ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity Type Id"/> + default="0" comment="Entity Type ID"/> <column xsi:type="smallint" name="attribute_set_id" padding="5" unsigned="true" nullable="false" - identity="false" default="0" comment="Attribute Set Id"/> + identity="false" default="0" comment="Attribute Set ID"/> <column xsi:type="smallint" name="attribute_group_id" padding="5" unsigned="true" nullable="false" - identity="false" default="0" comment="Attribute Group Id"/> + identity="false" default="0" comment="Attribute Group ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="sort_order" padding="6" unsigned="false" nullable="false" identity="false" default="0" comment="Sort Order"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -426,9 +426,9 @@ </table> <table name="eav_attribute_option" resource="default" engine="innodb" comment="Eav Attribute Option"> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Option Id"/> + comment="Option ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="sort_order" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Sort Order"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -443,11 +443,11 @@ </table> <table name="eav_attribute_option_value" resource="default" engine="innodb" comment="Eav Attribute Option Value"> <column xsi:type="int" name="value_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Option Id"/> + default="0" comment="Option ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="varchar" name="value" nullable="true" length="255" comment="Value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="value_id"/> @@ -467,11 +467,11 @@ </table> <table name="eav_attribute_label" resource="default" engine="innodb" comment="Eav Attribute Label"> <column xsi:type="int" name="attribute_label_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Attribute Label Id"/> + comment="Attribute Label ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Attribute Id"/> + default="0" comment="Attribute ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="varchar" name="value" nullable="true" length="255" comment="Value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="attribute_label_id"/> @@ -491,14 +491,14 @@ </table> <table name="eav_form_type" resource="default" engine="innodb" comment="Eav Form Type"> <column xsi:type="smallint" name="type_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Type Id"/> + comment="Type ID"/> <column xsi:type="varchar" name="code" nullable="false" length="64" comment="Code"/> <column xsi:type="varchar" name="label" nullable="false" length="255" comment="Label"/> <column xsi:type="smallint" name="is_system" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Is System"/> <column xsi:type="varchar" name="theme" nullable="true" length="64" comment="Theme"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="type_id"/> </constraint> @@ -515,9 +515,9 @@ </table> <table name="eav_form_type_entity" resource="default" engine="innodb" comment="Eav Form Type Entity"> <column xsi:type="smallint" name="type_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Type Id"/> + comment="Type ID"/> <column xsi:type="smallint" name="entity_type_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Entity Type Id"/> + comment="Entity Type ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="type_id"/> <column name="entity_type_id"/> @@ -534,9 +534,9 @@ </table> <table name="eav_form_fieldset" resource="default" engine="innodb" comment="Eav Form Fieldset"> <column xsi:type="smallint" name="fieldset_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Fieldset Id"/> + comment="Fieldset ID"/> <column xsi:type="smallint" name="type_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Type Id"/> + comment="Type ID"/> <column xsi:type="varchar" name="code" nullable="false" length="64" comment="Code"/> <column xsi:type="int" name="sort_order" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Sort Order"/> @@ -552,9 +552,9 @@ </table> <table name="eav_form_fieldset_label" resource="default" engine="innodb" comment="Eav Form Fieldset Label"> <column xsi:type="smallint" name="fieldset_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Fieldset Id"/> + comment="Fieldset ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="label" nullable="false" length="255" comment="Label"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="fieldset_id"/> @@ -572,13 +572,13 @@ </table> <table name="eav_form_element" resource="default" engine="innodb" comment="Eav Form Element"> <column xsi:type="int" name="element_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Element Id"/> + comment="Element ID"/> <column xsi:type="smallint" name="type_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Type Id"/> + comment="Type ID"/> <column xsi:type="smallint" name="fieldset_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Fieldset Id"/> + comment="Fieldset ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Attribute Id"/> + comment="Attribute ID"/> <column xsi:type="int" name="sort_order" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Sort Order"/> <constraint xsi:type="primary" referenceId="PRIMARY"> diff --git a/app/code/Magento/EavGraphQl/Model/Resolver/AttributeOptions.php b/app/code/Magento/EavGraphQl/Model/Resolver/AttributeOptions.php index e4c27adc60247..7361d52372cd6 100644 --- a/app/code/Magento/EavGraphQl/Model/Resolver/AttributeOptions.php +++ b/app/code/Magento/EavGraphQl/Model/Resolver/AttributeOptions.php @@ -57,29 +57,31 @@ public function resolve( array $args = null ) : Value { - return $this->valueFactory->create(function () use ($value) { - $entityType = $this->getEntityType($value); - $attributeCode = $this->getAttributeCode($value); + return $this->valueFactory->create( + function () use ($value) { + $entityType = $this->getEntityType($value); + $attributeCode = $this->getAttributeCode($value); - $optionsData = $this->getAttributeOptionsData($entityType, $attributeCode); - return $optionsData; - }); + $optionsData = $this->getAttributeOptionsData($entityType, $attributeCode); + return $optionsData; + } + ); } /** * Get entity type * * @param array $value - * @return int + * @return string * @throws LocalizedException */ - private function getEntityType(array $value): int + private function getEntityType(array $value): string { if (!isset($value['entity_type'])) { throw new LocalizedException(__('"Entity type should be specified')); } - return (int)$value['entity_type']; + return $value['entity_type']; } /** @@ -101,13 +103,13 @@ private function getAttributeCode(array $value): string /** * Get attribute options data * - * @param int $entityType + * @param string $entityType * @param string $attributeCode * @return array * @throws GraphQlInputException * @throws GraphQlNoSuchEntityException */ - private function getAttributeOptionsData(int $entityType, string $attributeCode): array + private function getAttributeOptionsData(string $entityType, string $attributeCode): array { try { $optionsData = $this->attributeOptionsDataProvider->getData($entityType, $attributeCode); diff --git a/app/code/Magento/EavGraphQl/Model/Resolver/CustomAttributeMetadata.php b/app/code/Magento/EavGraphQl/Model/Resolver/CustomAttributeMetadata.php index 62e3f01836619..85445580bb1fb 100644 --- a/app/code/Magento/EavGraphQl/Model/Resolver/CustomAttributeMetadata.php +++ b/app/code/Magento/EavGraphQl/Model/Resolver/CustomAttributeMetadata.php @@ -9,6 +9,7 @@ use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\EavGraphQl\Model\Resolver\Query\Type; +use Magento\EavGraphQl\Model\Resolver\Query\FrontendType; use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; @@ -26,12 +27,19 @@ class CustomAttributeMetadata implements ResolverInterface */ private $type; + /** + * @var FrontendType + */ + private $frontendType; + /** * @param Type $type + * @param FrontendType $frontendType */ - public function __construct(Type $type) + public function __construct(Type $type, FrontendType $frontendType) { $this->type = $type; + $this->frontendType = $frontendType; } /** @@ -52,6 +60,7 @@ public function resolve( continue; } try { + $frontendType = $this->frontendType->getType($attribute['attribute_code'], $attribute['entity_type']); $type = $this->type->getType($attribute['attribute_code'], $attribute['entity_type']); } catch (InputException $exception) { $attributes['items'][] = new GraphQlNoSuchEntityException( @@ -78,7 +87,8 @@ public function resolve( $attributes['items'][] = [ 'attribute_code' => $attribute['attribute_code'], 'entity_type' => $attribute['entity_type'], - 'attribute_type' => ucfirst($type) + 'attribute_type' => ucfirst($type), + 'input_type' => $frontendType ]; } diff --git a/app/code/Magento/EavGraphQl/Model/Resolver/DataProvider/AttributeOptions.php b/app/code/Magento/EavGraphQl/Model/Resolver/DataProvider/AttributeOptions.php index 900a31c1093ed..3371fbe658c9c 100644 --- a/app/code/Magento/EavGraphQl/Model/Resolver/DataProvider/AttributeOptions.php +++ b/app/code/Magento/EavGraphQl/Model/Resolver/DataProvider/AttributeOptions.php @@ -29,11 +29,13 @@ public function __construct( } /** - * @param int $entityType + * Get attribute options data + * + * @param string $entityType * @param string $attributeCode * @return array */ - public function getData(int $entityType, string $attributeCode): array + public function getData(string $entityType, string $attributeCode): array { $options = $this->optionManager->getItems($entityType, $attributeCode); diff --git a/app/code/Magento/EavGraphQl/Model/Resolver/Query/FrontendType.php b/app/code/Magento/EavGraphQl/Model/Resolver/Query/FrontendType.php new file mode 100644 index 0000000000000..c76f19e6dfeb4 --- /dev/null +++ b/app/code/Magento/EavGraphQl/Model/Resolver/Query/FrontendType.php @@ -0,0 +1,61 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\EavGraphQl\Model\Resolver\Query; + +use Magento\Eav\Api\AttributeRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Webapi\ServiceTypeToEntityTypeMap; + +/** + * Get frontend input type for EAV attribute + */ +class FrontendType +{ + /** + * @var AttributeRepositoryInterface + */ + private $attributeRepository; + + /** + * @var ServiceTypeToEntityTypeMap + */ + private $serviceTypeMap; + + /** + * @param AttributeRepositoryInterface $attributeRepository + * @param ServiceTypeToEntityTypeMap $serviceTypeMap + */ + public function __construct( + AttributeRepositoryInterface $attributeRepository, + ServiceTypeToEntityTypeMap $serviceTypeMap + ) { + $this->attributeRepository = $attributeRepository; + $this->serviceTypeMap = $serviceTypeMap; + } + + /** + * Return frontend type for attribute + * + * @param string $attributeCode + * @param string $entityType + * @return null|string + */ + public function getType(string $attributeCode, string $entityType): ?string + { + $mappedEntityType = $this->serviceTypeMap->getEntityType($entityType); + if ($mappedEntityType) { + $entityType = $mappedEntityType; + } + try { + $attribute = $this->attributeRepository->get($entityType, $attributeCode); + } catch (NoSuchEntityException $e) { + return null; + } + return $attribute->getFrontendInput(); + } +} diff --git a/app/code/Magento/EavGraphQl/etc/schema.graphqls b/app/code/Magento/EavGraphQl/etc/schema.graphqls index 0b174fbc4d84d..21aa7001fab2b 100644 --- a/app/code/Magento/EavGraphQl/etc/schema.graphqls +++ b/app/code/Magento/EavGraphQl/etc/schema.graphqls @@ -13,6 +13,7 @@ type Attribute @doc(description: "Attribute contains the attribute_type of the s attribute_code: String @doc(description: "The unique identifier for an attribute code. This value should be in lowercase letters without spaces.") entity_type: String @doc(description: "The type of entity that defines the attribute") attribute_type: String @doc(description: "The data type of the attribute") + input_type: String @doc(description: "The frontend input type of the attribute") attribute_options: [AttributeOption] @resolver(class: "Magento\\EavGraphQl\\Model\\Resolver\\AttributeOptions") @doc(description: "Attribute options list.") } diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php b/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php index 97a76de4b995a..f6d0a6318a5f2 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php @@ -195,15 +195,11 @@ public function cleanIndex($storeId, $mappedIndexerId) { $this->checkIndex($storeId, $mappedIndexerId, true); $indexName = $this->indexNameResolver->getIndexName($storeId, $mappedIndexerId, $this->preparedIndex); - if ($this->client->isEmptyIndex($indexName)) { - // use existing index if empty - return $this; - } // prepare new index name and increase version $indexPattern = $this->indexNameResolver->getIndexPattern($storeId, $mappedIndexerId); $version = (int)(str_replace($indexPattern, '', $indexName)); - $newIndexName = $indexPattern . ++$version; + $newIndexName = $indexPattern . (++$version); // remove index if already exists if ($this->client->indexExists($newIndexName)) { @@ -354,12 +350,14 @@ protected function prepareIndex($storeId, $indexName, $mappedIndexerId) { $this->indexBuilder->setStoreId($storeId); $settings = $this->indexBuilder->build(); - $allAttributeTypes = $this->fieldMapper->getAllAttributesTypes([ - 'entityType' => $mappedIndexerId, - // Use store id instead of website id from context for save existing fields mapping. - // In future websiteId will be eliminated due to index stored per store - 'websiteId' => $storeId - ]); + $allAttributeTypes = $this->fieldMapper->getAllAttributesTypes( + [ + 'entityType' => $mappedIndexerId, + // Use store id instead of website id from context for save existing fields mapping. + // In future websiteId will be eliminated due to index stored per store + 'websiteId' => $storeId + ] + ); $settings['index']['mapping']['total_fields']['limit'] = $this->getMappingTotalFieldsLimit($allAttributeTypes); $this->client->createIndex($indexName, ['settings' => $settings]); $this->client->addFieldsMapping( diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeAdapter.php b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeAdapter.php index 165f7e78eb65f..41a50961ae4bc 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeAdapter.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeAdapter.php @@ -115,6 +115,18 @@ public function isBooleanType(): bool && $this->getAttribute()->getBackendType() !== 'varchar'; } + /** + * Check if attribute is text type + * + * @return bool + */ + public function isTextType(): bool + { + return in_array($this->getAttribute()->getBackendType(), ['varchar', 'static'], true) + && in_array($this->getFrontendInput(), ['text'], true) + && $this->getAttribute()->getIsVisible(); + } + /** * Check if attribute has boolean type. * diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php index 6876b23bbb156..0f3020974d08a 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php @@ -130,6 +130,15 @@ public function getFields(array $context = []): array ]; } + if ($attributeAdapter->isTextType()) { + $keywordFieldName = FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD; + $allAttributes[$fieldName]['fields'][$keywordFieldName] = [ + 'type' => $this->fieldTypeConverter->convert( + FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD + ) + ]; + } + if ($attributeAdapter->isComplexType()) { $childFieldName = $this->fieldNameResolver->getFieldName( $attributeAdapter, diff --git a/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolver.php b/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolver.php index 1c8885fec63be..ce88fc290e23c 100644 --- a/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolver.php +++ b/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolver.php @@ -76,9 +76,6 @@ public function __construct( */ public function resolve(): SearchCriteria { - if ($this->size !== 0) { - $this->builder->setPageSize($this->size); - } $searchCriteria = $this->builder->create(); $searchCriteria->setRequestName($this->searchRequestName); $searchCriteria->setSortOrders($this->orders); diff --git a/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php b/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php index 3ae2d384782c3..aac396f238358 100644 --- a/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php +++ b/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php @@ -25,16 +25,32 @@ class SearchResultApplier implements SearchResultApplierInterface */ private $searchResult; + /** + * @var int + */ + private $size; + + /** + * @var int + */ + private $currentPage; + /** * @param Collection $collection * @param SearchResultInterface $searchResult + * @param int $size + * @param int $currentPage */ public function __construct( Collection $collection, - SearchResultInterface $searchResult + SearchResultInterface $searchResult, + int $size, + int $currentPage ) { $this->collection = $collection; $this->searchResult = $searchResult; + $this->size = $size; + $this->currentPage = $currentPage; } /** @@ -46,8 +62,10 @@ public function apply() $this->collection->getSelect()->where('NULL'); return; } + + $items = $this->sliceItems($this->searchResult->getItems(), $this->size, $this->currentPage); $ids = []; - foreach ($this->searchResult->getItems() as $item) { + foreach ($items as $item) { $ids[] = (int)$item->getId(); } $this->collection->setPageSize(null); @@ -56,4 +74,25 @@ public function apply() $this->collection->getSelect()->reset(\Magento\Framework\DB\Select::ORDER); $this->collection->getSelect()->order("FIELD(e.entity_id,$orderList)"); } + + /** + * Slice current items + * + * @param array $items + * @param int $size + * @param int $currentPage + * @return array + */ + private function sliceItems(array $items, int $size, int $currentPage): array + { + if ($size !== 0) { + $offset = ($currentPage - 1) * $size; + if ($offset < 0) { + $offset = 0; + } + $items = array_slice($items, $offset, $this->size); + } + + return $items; + } } diff --git a/app/code/Magento/Elasticsearch/SearchAdapter/Filter/Builder/Term.php b/app/code/Magento/Elasticsearch/SearchAdapter/Filter/Builder/Term.php index ed8cd049d2915..d88c7e53d813a 100644 --- a/app/code/Magento/Elasticsearch/SearchAdapter/Filter/Builder/Term.php +++ b/app/code/Magento/Elasticsearch/SearchAdapter/Filter/Builder/Term.php @@ -5,10 +5,17 @@ */ namespace Magento\Elasticsearch\SearchAdapter\Filter\Builder; +use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\AttributeProvider; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Search\Request\Filter\Term as TermFilterRequest; use Magento\Framework\Search\Request\FilterInterface as RequestFilterInterface; use Magento\Elasticsearch\Model\Adapter\FieldMapperInterface; +use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldType\ConverterInterface + as FieldTypeConverterInterface; +/** + * Term filter builder + */ class Term implements FilterInterface { /** @@ -16,26 +23,56 @@ class Term implements FilterInterface */ protected $fieldMapper; + /** + * @var AttributeProvider + */ + private $attributeAdapterProvider; + + /** + * @var array + * @see \Magento\Elasticsearch\Elasticsearch5\Model\Adapter\FieldMapper\Product\FieldProvider\FieldType\Resolver\IntegerType::$integerTypeAttributes + */ + private $integerTypeAttributes = ['category_ids']; + /** * @param FieldMapperInterface $fieldMapper + * @param AttributeProvider $attributeAdapterProvider + * @param array $integerTypeAttributes */ - public function __construct(FieldMapperInterface $fieldMapper) - { + public function __construct( + FieldMapperInterface $fieldMapper, + AttributeProvider $attributeAdapterProvider = null, + array $integerTypeAttributes = [] + ) { $this->fieldMapper = $fieldMapper; + $this->attributeAdapterProvider = $attributeAdapterProvider + ?? ObjectManager::getInstance()->get(AttributeProvider::class); + $this->integerTypeAttributes = array_merge($this->integerTypeAttributes, $integerTypeAttributes); } /** + * Build term filter request + * * @param RequestFilterInterface|TermFilterRequest $filter * @return array */ public function buildFilter(RequestFilterInterface $filter) { $filterQuery = []; + + $attribute = $this->attributeAdapterProvider->getByAttributeCode($filter->getField()); + $fieldName = $this->fieldMapper->getFieldName($filter->getField()); + + if ($attribute->isTextType() && !in_array($attribute->getAttributeCode(), $this->integerTypeAttributes)) { + $suffix = FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD; + $fieldName .= '.' . $suffix; + } + if ($filter->getValue()) { $operator = is_array($filter->getValue()) ? 'terms' : 'term'; $filterQuery []= [ $operator => [ - $this->fieldMapper->getFieldName($filter->getField()) => $filter->getValue(), + $fieldName => $filter->getValue(), ], ]; } diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php index ec50ba7b3d5df..326c04aad6165 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php @@ -77,6 +77,7 @@ class ElasticsearchTest extends \PHPUnit\Framework\TestCase * Setup * * @return void + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ protected function setUp() { @@ -93,10 +94,12 @@ protected function setUp() ->getMock(); $this->clientConfig = $this->getMockBuilder(\Magento\Elasticsearch\Model\Config::class) ->disableOriginalConstructor() - ->setMethods([ - 'getIndexPrefix', - 'getEntityType', - ])->getMock(); + ->setMethods( + [ + 'getIndexPrefix', + 'getEntityType', + ] + )->getMock(); $this->indexBuilder = $this->getMockBuilder(\Magento\Elasticsearch\Model\Adapter\Index\BuilderInterface::class) ->disableOriginalConstructor() ->getMock(); @@ -104,44 +107,52 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); $elasticsearchClientMock = $this->getMockBuilder(\Elasticsearch\Client::class) - ->setMethods([ - 'indices', - 'ping', - 'bulk', - 'search', - ]) + ->setMethods( + [ + 'indices', + 'ping', + 'bulk', + 'search', + ] + ) ->disableOriginalConstructor() ->getMock(); $indicesMock = $this->getMockBuilder(\Elasticsearch\Namespaces\IndicesNamespace::class) - ->setMethods([ - 'exists', - 'getSettings', - 'create', - 'putMapping', - 'deleteMapping', - 'existsAlias', - 'updateAliases', - 'stats' - ]) + ->setMethods( + [ + 'exists', + 'getSettings', + 'create', + 'putMapping', + 'deleteMapping', + 'existsAlias', + 'updateAliases', + 'stats' + ] + ) ->disableOriginalConstructor() ->getMock(); $elasticsearchClientMock->expects($this->any()) ->method('indices') ->willReturn($indicesMock); $this->client = $this->getMockBuilder(\Magento\Elasticsearch\Model\Client\Elasticsearch::class) - ->setConstructorArgs([ - 'options' => $this->getClientOptions(), - 'elasticsearchClient' => $elasticsearchClientMock - ]) + ->setConstructorArgs( + [ + 'options' => $this->getClientOptions(), + 'elasticsearchClient' => $elasticsearchClientMock + ] + ) ->getMock(); $this->connectionManager->expects($this->any()) ->method('getConnection') ->willReturn($this->client); $this->fieldMapper->expects($this->any()) ->method('getAllAttributesTypes') - ->willReturn([ - 'name' => 'string', - ]); + ->willReturn( + [ + 'name' => 'string', + ] + ); $this->clientConfig->expects($this->any()) ->method('getIndexPrefix') ->willReturn('indexName'); @@ -151,12 +162,14 @@ protected function setUp() $this->indexNameResolver = $this->getMockBuilder( \Magento\Elasticsearch\Model\Adapter\Index\IndexNameResolver::class ) - ->setMethods([ - 'getIndexName', - 'getIndexNamespace', - 'getIndexFromAlias', - 'getIndexNameForAlias', - ]) + ->setMethods( + [ + 'getIndexName', + 'getIndexNamespace', + 'getIndexFromAlias', + 'getIndexNameForAlias', + ] + ) ->disableOriginalConstructor() ->getMock(); $this->batchDocumentDataMapper = $this->getMockBuilder( @@ -216,9 +229,11 @@ public function testPrepareDocsPerStore() { $this->batchDocumentDataMapper->expects($this->once()) ->method('map') - ->willReturn([ - 'name' => 'Product Name', - ]); + ->willReturn( + [ + 'name' => 'Product Name', + ] + ); $this->assertInternalType( 'array', $this->model->prepareDocsPerStore( @@ -283,10 +298,6 @@ public function testCleanIndex() ->with(1, 'product', []) ->willReturn('indexName_product_1_v'); - $this->client->expects($this->once()) - ->method('isEmptyIndex') - ->with('indexName_product_1_v') - ->willReturn(false); $this->client->expects($this->atLeastOnce()) ->method('indexExists') ->willReturn(true); @@ -299,26 +310,6 @@ public function testCleanIndex() ); } - /** - * Test cleanIndex() method isEmptyIndex is true - */ - public function testCleanIndexTrue() - { - $this->indexNameResolver->expects($this->any()) - ->method('getIndexName') - ->willReturn('indexName_product_1_v'); - - $this->client->expects($this->once()) - ->method('isEmptyIndex') - ->with('indexName_product_1_v') - ->willReturn(true); - - $this->assertSame( - $this->model, - $this->model->cleanIndex(1, 'product') - ); - } - /** * Test deleteDocs() method */ @@ -376,9 +367,11 @@ public function testConnectException() { $connectionManager = $this->getMockBuilder(\Magento\Elasticsearch\SearchAdapter\ConnectionManager::class) ->disableOriginalConstructor() - ->setMethods([ - 'getConnection', - ]) + ->setMethods( + [ + 'getConnection', + ] + ) ->getMock(); $connectionManager->expects($this->any()) diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/FieldMapper/Product/FieldProvider/StaticFieldTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/FieldMapper/Product/FieldProvider/StaticFieldTest.php index de85b8b6602b8..f90c13c9bfb65 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/FieldMapper/Product/FieldProvider/StaticFieldTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/FieldMapper/Product/FieldProvider/StaticFieldTest.php @@ -139,6 +139,7 @@ public function testGetAllAttributesTypes( $isComplexType, $complexType, $isSortable, + $isTextType, $fieldName, $compositeFieldName, $sortFieldName, @@ -153,29 +154,33 @@ public function testGetAllAttributesTypes( $this->indexTypeConverter->expects($this->any()) ->method('convert') ->with($this->anything()) - ->will($this->returnCallback( - function ($type) { - if ($type === 'no_index') { - return 'no'; - } elseif ($type === 'no_analyze') { - return 'not_analyzed'; + ->will( + $this->returnCallback( + function ($type) { + if ($type === 'no_index') { + return 'no'; + } elseif ($type === 'no_analyze') { + return 'not_analyzed'; + } } - } - )); + ) + ); $this->fieldNameResolver->expects($this->any()) ->method('getFieldName') ->with($this->anything()) - ->will($this->returnCallback( - function ($attributeMock, $context) use ($fieldName, $compositeFieldName, $sortFieldName) { - if (empty($context)) { - return $fieldName; - } elseif ($context['type'] === 'sort') { - return $sortFieldName; - } elseif ($context['type'] === 'text') { - return $compositeFieldName; + ->will( + $this->returnCallback( + function ($attributeMock, $context) use ($fieldName, $compositeFieldName, $sortFieldName) { + if (empty($context)) { + return $fieldName; + } elseif ($context['type'] === 'sort') { + return $sortFieldName; + } elseif ($context['type'] === 'text') { + return $compositeFieldName; + } } - } - )); + ) + ); $productAttributeMock = $this->getMockBuilder(AbstractAttribute::class) ->setMethods(['getAttributeCode']) @@ -189,7 +194,7 @@ function ($attributeMock, $context) use ($fieldName, $compositeFieldName, $sortF $attributeMock = $this->getMockBuilder(AttributeAdapter::class) ->disableOriginalConstructor() - ->setMethods(['isComplexType', 'getAttributeCode', 'isSortable']) + ->setMethods(['isComplexType', 'getAttributeCode', 'isSortable', 'isTextType']) ->getMock(); $attributeMock->expects($this->any()) ->method('isComplexType') @@ -197,6 +202,9 @@ function ($attributeMock, $context) use ($fieldName, $compositeFieldName, $sortF $attributeMock->expects($this->any()) ->method('isSortable') ->willReturn($isSortable); + $attributeMock->expects($this->any()) + ->method('isTextType') + ->willReturn($isTextType); $attributeMock->expects($this->any()) ->method('getAttributeCode') ->willReturn($attributeCode); @@ -207,22 +215,24 @@ function ($attributeMock, $context) use ($fieldName, $compositeFieldName, $sortF $this->fieldTypeConverter->expects($this->any()) ->method('convert') ->with($this->anything()) - ->will($this->returnCallback( - function ($type) use ($complexType) { - static $callCount = []; - $callCount[$type] = !isset($callCount[$type]) ? 1 : ++$callCount[$type]; + ->will( + $this->returnCallback( + function ($type) use ($complexType) { + static $callCount = []; + $callCount[$type] = !isset($callCount[$type]) ? 1 : ++$callCount[$type]; - if ($type === 'string') { - return 'string'; - } elseif ($type === 'float') { - return 'float'; - } elseif ($type === 'keyword') { - return 'string'; - } else { - return $complexType; + if ($type === 'string') { + return 'string'; + } elseif ($type === 'float') { + return 'float'; + } elseif ($type === 'keyword') { + return 'string'; + } else { + return $complexType; + } } - } - )); + ) + ); $this->assertEquals( $expected, @@ -243,13 +253,19 @@ public function attributeProvider() true, 'text', false, + true, 'category_ids', 'category_ids_value', '', [ 'category_ids' => [ 'type' => 'select', - 'index' => true + 'index' => true, + 'fields' => [ + 'keyword' => [ + 'type' => 'string', + ] + ] ], 'category_ids_value' => [ 'type' => 'string' @@ -267,13 +283,19 @@ public function attributeProvider() false, null, false, + true, 'attr_code', '', '', [ 'attr_code' => [ 'type' => 'text', - 'index' => 'no' + 'index' => 'no', + 'fields' => [ + 'keyword' => [ + 'type' => 'string', + ] + ] ], 'store_id' => [ 'type' => 'string', @@ -288,6 +310,7 @@ public function attributeProvider() false, null, false, + false, 'attr_code', '', '', @@ -308,6 +331,7 @@ public function attributeProvider() false, null, true, + false, 'attr_code', '', 'sort_attr_code', diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolverTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolverTest.php index fbf63630464f9..b2c0f5e341fc2 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolverTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/Model/ResourceModel/Fulltext/Collection/SearchCriteriaResolverTest.php @@ -106,7 +106,7 @@ public function resolveSortOrderDataProvider() ], [ ['size' => 10, 'orders' => ['test' => 'ASC']], - ['size' => 10, 'orders' => ['test' => 'ASC']], + ['size' => null, 'orders' => ['test' => 'ASC']], ], ]; } diff --git a/app/code/Magento/GiftMessage/etc/db_schema.xml b/app/code/Magento/GiftMessage/etc/db_schema.xml index 4ae98799df0c2..5c1e7fb17bc5d 100644 --- a/app/code/Magento/GiftMessage/etc/db_schema.xml +++ b/app/code/Magento/GiftMessage/etc/db_schema.xml @@ -9,9 +9,9 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="gift_message" resource="default" engine="innodb" comment="Gift Message"> <column xsi:type="int" name="gift_message_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="GiftMessage Id"/> + comment="GiftMessage ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Customer id"/> + default="0" comment="Customer ID"/> <column xsi:type="varchar" name="sender" nullable="true" length="255" comment="Sender"/> <column xsi:type="varchar" name="recipient" nullable="true" length="255" comment="Registrant"/> <column xsi:type="text" name="message" nullable="true" comment="Message"/> @@ -21,27 +21,27 @@ </table> <table name="quote" resource="checkout" comment="Sales Flat Quote"> <column xsi:type="int" name="gift_message_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Gift Message Id"/> + comment="Gift Message ID"/> </table> <table name="quote_address" resource="checkout" comment="Sales Flat Quote Address"> <column xsi:type="int" name="gift_message_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Gift Message Id"/> + comment="Gift Message ID"/> </table> <table name="quote_item" resource="checkout" comment="Sales Flat Quote Item"> <column xsi:type="int" name="gift_message_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Gift Message Id"/> + comment="Gift Message ID"/> </table> <table name="quote_address_item" resource="checkout" comment="Sales Flat Quote Address Item"> <column xsi:type="int" name="gift_message_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Gift Message Id"/> + comment="Gift Message ID"/> </table> <table name="sales_order" resource="sales" comment="Sales Flat Order"> <column xsi:type="int" name="gift_message_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Gift Message Id"/> + comment="Gift Message ID"/> </table> <table name="sales_order_item" resource="sales" comment="Sales Flat Order Item"> <column xsi:type="int" name="gift_message_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Gift Message Id"/> + comment="Gift Message ID"/> <column xsi:type="int" name="gift_message_available" padding="11" unsigned="false" nullable="true" identity="false" comment="Gift Message Available"/> </table> diff --git a/app/code/Magento/GoogleOptimizer/etc/db_schema.xml b/app/code/Magento/GoogleOptimizer/etc/db_schema.xml index 76c377544cfb3..ed3dfcecb90f3 100644 --- a/app/code/Magento/GoogleOptimizer/etc/db_schema.xml +++ b/app/code/Magento/GoogleOptimizer/etc/db_schema.xml @@ -9,12 +9,12 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="googleoptimizer_code" resource="default" engine="innodb" comment="Google Experiment code"> <column xsi:type="int" name="code_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Google experiment code id"/> + comment="Google experiment code ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Optimized entity id product id or catalog id"/> + comment="Optimized entity ID product ID or catalog ID"/> <column xsi:type="varchar" name="entity_type" nullable="true" length="50" comment="Optimized entity type"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store id"/> + comment="Store ID"/> <column xsi:type="text" name="experiment_script" nullable="true" comment="Google experiment script"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="code_id"/> diff --git a/app/code/Magento/GraphQl/etc/schema.graphqls b/app/code/Magento/GraphQl/etc/schema.graphqls index eb6a88a4d487d..77bba5ea3a9d4 100644 --- a/app/code/Magento/GraphQl/etc/schema.graphqls +++ b/app/code/Magento/GraphQl/etc/schema.graphqls @@ -65,6 +65,20 @@ input FilterTypeInput @doc(description: "FilterTypeInput specifies which action nin: [String] @doc(description: "Not in. The value can contain a set of comma-separated values") } +input FilterEqualTypeInput @doc(description: "Defines a filter that matches the input exactly.") { + in: [String] @doc(description: "An array of values to filter on") + eq: String @doc(description: "A string to filter on") +} + +input FilterRangeTypeInput @doc(description: "Defines a filter that matches a range of values, such as prices or dates.") { + from: String @doc(description: "The beginning of the range") + to: String @doc(description: "The end of the range") +} + +input FilterMatchTypeInput @doc(description: "Defines a filter that performs a fuzzy search.") { + match: String @doc(description: "One or more words to filter on") +} + type SearchResultPageInfo @doc(description: "SearchResultPageInfo provides navigation for the query response") { page_size: Int @doc(description: "Specifies the maximum number of items to return") current_page: Int @doc(description: "Specifies which page of results to return") diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Test/AdminAssociateGroupedProductToWebsitesTest.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Test/AdminAssociateGroupedProductToWebsitesTest.xml new file mode 100644 index 0000000000000..3827666252478 --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Test/AdminAssociateGroupedProductToWebsitesTest.xml @@ -0,0 +1,116 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminAssociateGroupedProductToWebsitesTest"> + <annotations> + <features value="GroupedProduct"/> + <stories value="Create/Edit grouped product in Admin"/> + <title value="Admin should be able to associate grouped product to websites"/> + <description value="Admin should be able to associate grouped product to websites"/> + <testCaseId value="MC-3377"/> + <severity value="CRITICAL"/> + <group value="catalog"/> + <group value="groupedProduct"/> + </annotations> + + <before> + <!-- Set Store Code To Urls --> + <magentoCLI command="config:set {{StorefrontEnableAddStoreCodeToUrls.path}} {{StorefrontEnableAddStoreCodeToUrls.value}}" stepKey="setAddStoreCodeToUrlsToYes"/> + + <!-- Create grouped product --> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct"/> + <createData entity="ApiGroupedProduct" stepKey="createGroupedProduct"/> + <createData entity="OneSimpleProductLink" stepKey="addProductOne"> + <requiredEntity createDataKey="createGroupedProduct"/> + <requiredEntity createDataKey="createSimpleProduct"/> + </createData> + + <!-- Login as Admin --> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + + <!--Create website--> + <actionGroup ref="AdminCreateWebsiteActionGroup" stepKey="createSecondWebsite"> + <argument name="newWebsiteName" value="{{secondCustomWebsite.name}}"/> + <argument name="websiteCode" value="{{secondCustomWebsite.code}}"/> + </actionGroup> + <!-- Create second store --> + <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="createSecondStoreGroup"> + <argument name="website" value="{{secondCustomWebsite.name}}"/> + <argument name="storeGroupName" value="{{SecondStoreGroupUnique.name}}"/> + <argument name="storeGroupCode" value="{{SecondStoreGroupUnique.code}}"/> + </actionGroup> + <!-- Create second store view --> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createSecondStoreView"> + <argument name="StoreGroup" value="SecondStoreGroupUnique"/> + <argument name="customStore" value="SecondStoreUnique"/> + </actionGroup> + + <!-- Reindex --> + <magentoCLI command="indexer:reindex" stepKey="reindexAllIndexes"/> + </before> + + <after> + <!-- Disable Store Code To Urls --> + <magentoCLI command="config:set {{StorefrontDisableAddStoreCodeToUrls.path}} {{StorefrontDisableAddStoreCodeToUrls.value}}" stepKey="setAddStoreCodeToUrlsToNo"/> + + <!-- Delete product data --> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + <deleteData createDataKey="createGroupedProduct" stepKey="deleteGroupedProduct"/> + + <!-- Delete second website --> + <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteWebsite"> + <argument name="websiteName" value="{{secondCustomWebsite.name}}"/> + </actionGroup> + + <actionGroup ref="NavigateToAndResetProductGridToDefaultView" stepKey="resetProductGridFilter"/> + + <!-- Admin logout --> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Open product page and assign grouped project to second website --> + <actionGroup ref="filterAndSelectProduct" stepKey="openAdminProductPage"> + <argument name="productSku" value="$$createGroupedProduct.sku$$"/> + </actionGroup> + <actionGroup ref="AdminAssignProductInWebsiteActionGroup" stepKey="assignProductToSecondWebsite"> + <argument name="website" value="{{secondCustomWebsite.name}}"/> + </actionGroup> + <actionGroup ref="AdminUnassignProductInWebsiteActionGroup" stepKey="unassignProductFromDefaultWebsite"> + <argument name="website" value="{{_defaultWebsite.name}}"/> + </actionGroup> + <actionGroup ref="saveProductForm" stepKey="saveGroupedProduct"/> + + <!-- Assert product is assigned to Second website --> + <actionGroup ref="AssertProductIsAssignedToWebsite" stepKey="seeCustomWebsiteIsChecked"> + <argument name="website" value="{{secondCustomWebsite.name}}"/> + </actionGroup> + + <!-- Assert product is not assigned to Main website --> + <actionGroup ref="AssertProductIsNotAssignedToWebsite" stepKey="seeMainWebsiteIsNotChecked"> + <argument name="website" value="{{_defaultWebsite.name}}"/> + </actionGroup> + + <!-- Go to frontend and open product on Main website --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openProductPage"> + <argument name="productUrl" value="$$createGroupedProduct.custom_attributes[url_key]$$"/> + </actionGroup> + + <!-- Assert 404 page --> + <actionGroup ref="StorefrontAssertPageNotFoundErrorOnProductDetailPageActionGroup" stepKey="assertPageNotFoundErrorOnProductDetailPage"> + <argument name="product" value="$$createGroupedProduct$$"/> + </actionGroup> + + <!-- Assert grouped product on Second website --> + <actionGroup ref="StorefrontOpenProductPageUsingStoreCodeInUrlActionGroup" stepKey="openProductPageUsingStoreCodeInUrl"> + <argument name="product" value="$$createGroupedProduct$$"/> + <argument name="storeView" value="SecondStoreUnique"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/ImportExport/Api/Data/ExtendedExportInfoInterface.php b/app/code/Magento/ImportExport/Api/Data/ExtendedExportInfoInterface.php new file mode 100644 index 0000000000000..aa14d562d9cf7 --- /dev/null +++ b/app/code/Magento/ImportExport/Api/Data/ExtendedExportInfoInterface.php @@ -0,0 +1,29 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ImportExport\Api\Data; + +/** + * Extended export interface for implementation of Skipped Attributes which are missing from the basic interface + */ +interface ExtendedExportInfoInterface extends ExportInfoInterface +{ + /** + * Returns skipped attributes + * + * @return mixed + */ + public function getSkipAttr(); + + /** + * Set skipped attributes + * + * @param string $skipAttr + * @return mixed + */ + public function setSkipAttr($skipAttr); +} diff --git a/app/code/Magento/ImportExport/Controller/Adminhtml/Export/Export.php b/app/code/Magento/ImportExport/Controller/Adminhtml/Export/Export.php index 13c22a976e798..c5885f72474f9 100644 --- a/app/code/Magento/ImportExport/Controller/Adminhtml/Export/Export.php +++ b/app/code/Magento/ImportExport/Controller/Adminhtml/Export/Export.php @@ -5,13 +5,13 @@ */ namespace Magento\ImportExport\Controller\Adminhtml\Export; +use Magento\Backend\App\Action\Context; use Magento\Framework\App\Action\HttpPostActionInterface as HttpPostActionInterface; +use Magento\Framework\App\Response\Http\FileFactory; use Magento\Framework\Controller\ResultFactory; +use Magento\Framework\MessageQueue\PublisherInterface; use Magento\ImportExport\Controller\Adminhtml\Export as ExportController; -use Magento\Backend\App\Action\Context; -use Magento\Framework\App\Response\Http\FileFactory; use Magento\ImportExport\Model\Export as ExportModel; -use Magento\Framework\MessageQueue\PublisherInterface; use Magento\ImportExport\Model\Export\Entity\ExportInfoFactory; /** @@ -76,11 +76,16 @@ public function execute() try { $params = $this->getRequest()->getParams(); + if (!array_key_exists('skip_attr', $params)) { + $params['skip_attr'] = []; + } + /** @var ExportInfoFactory $dataObject */ $dataObject = $this->exportInfoFactory->create( $params['file_format'], $params['entity'], - $params['export_filter'] + $params['export_filter'], + $params['skip_attr'] ); $this->messagePublisher->publish('import_export.export', $dataObject); diff --git a/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfo.php b/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfo.php index 6dffc1827cfd0..a5d5d63e4f8da 100644 --- a/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfo.php +++ b/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfo.php @@ -7,12 +7,12 @@ namespace Magento\ImportExport\Model\Export\Entity; -use \Magento\ImportExport\Api\Data\ExportInfoInterface; +use Magento\ImportExport\Api\Data\ExtendedExportInfoInterface; /** * Class ExportInfo implementation for ExportInfoInterface. */ -class ExportInfo implements ExportInfoInterface +class ExportInfo implements ExtendedExportInfoInterface { /** * @var string @@ -39,6 +39,11 @@ class ExportInfo implements ExportInfoInterface */ private $exportFilter; + /** + * @var mixed + */ + private $skipAttr; + /** * @inheritdoc */ @@ -118,4 +123,20 @@ public function setExportFilter($exportFilter) { $this->exportFilter = $exportFilter; } + + /** + * @inheritdoc + */ + public function getSkipAttr() + { + return $this->skipAttr; + } + + /** + * @inheritdoc + */ + public function setSkipAttr($skipAttr) + { + $this->skipAttr = $skipAttr; + } } diff --git a/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfoFactory.php b/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfoFactory.php index e3cbd162aa5af..32c989acb661c 100644 --- a/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfoFactory.php +++ b/app/code/Magento/ImportExport/Model/Export/Entity/ExportInfoFactory.php @@ -84,17 +84,25 @@ public function __construct( * @param string $fileFormat * @param string $entity * @param string $exportFilter + * @param array $skipAttr * @return ExportInfoInterface * @throws \Magento\Framework\Exception\LocalizedException */ - public function create($fileFormat, $entity, $exportFilter) + public function create($fileFormat, $entity, $exportFilter, $skipAttr) { $writer = $this->getWriter($fileFormat); - $entityAdapter = $this->getEntityAdapter($entity, $fileFormat, $exportFilter, $writer->getContentType()); + $entityAdapter = $this->getEntityAdapter( + $entity, + $fileFormat, + $exportFilter, + $skipAttr, + $writer->getContentType() + ); $fileName = $this->generateFileName($entity, $entityAdapter, $writer->getFileExtension()); /** @var ExportInfoInterface $exportInfo */ $exportInfo = $this->objectManager->create(ExportInfoInterface::class); $exportInfo->setExportFilter($this->serializer->serialize($exportFilter)); + $exportInfo->setSkipAttr($skipAttr); $exportInfo->setFileName($fileName); $exportInfo->setEntity($entity); $exportInfo->setFileFormat($fileFormat); @@ -130,11 +138,12 @@ private function generateFileName($entity, $entityAdapter, $fileExtensions) * @param string $entity * @param string $fileFormat * @param string $exportFilter + * @param array $skipAttr * @param string $contentType * @return \Magento\ImportExport\Model\Export\AbstractEntity|AbstractEntity * @throws \Magento\Framework\Exception\LocalizedException */ - private function getEntityAdapter($entity, $fileFormat, $exportFilter, $contentType) + private function getEntityAdapter($entity, $fileFormat, $exportFilter, $skipAttr, $contentType) { $entities = $this->exportConfig->getEntities(); if (isset($entities[$entity])) { @@ -166,12 +175,15 @@ private function getEntityAdapter($entity, $fileFormat, $exportFilter, $contentT } else { throw new \Magento\Framework\Exception\LocalizedException(__('Please enter a correct entity.')); } - $entityAdapter->setParameters([ - 'fileFormat' => $fileFormat, - 'entity' => $entity, - 'exportFilter' => $exportFilter, - 'contentType' => $contentType, - ]); + $entityAdapter->setParameters( + [ + 'fileFormat' => $fileFormat, + 'entity' => $entity, + 'exportFilter' => $exportFilter, + 'skipAttr' => $skipAttr, + 'contentType' => $contentType, + ] + ); return $entityAdapter; } diff --git a/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminImportProductsActionGroup.xml b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminImportProductsActionGroup.xml index 2ac790b953ec1..9063916e9f502 100644 --- a/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminImportProductsActionGroup.xml +++ b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminImportProductsActionGroup.xml @@ -14,21 +14,50 @@ </annotations> <arguments> <argument name="behavior" type="string"/> + <argument name="validationStrategy" type="string" defaultValue="Stop on Error"/> + <argument name="allowedErrorsCount" type="string" defaultValue="10"/> <argument name="importFile" type="string"/> - <argument name="importMessage" type="string"/> + <argument name="importNoticeMessage" type="string"/> + <argument name="importMessageType" type="string" defaultValue="success"/> + <argument name="importMessage" type="string" defaultValue="Import successfully done"/> </arguments> <amOnPage url="{{AdminImportIndexPage.url}}" stepKey="goToImportIndexPage"/> - <waitForPageLoad stepKey="AdminImportMainSectionLoad"/> + <waitForPageLoad stepKey="adminImportMainSectionLoad"/> <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Products" stepKey="selectProductsOption"/> <waitForElementVisible selector="{{AdminImportMainSection.importBehavior}}" stepKey="waitForImportBehaviorElementVisible"/> - <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="{{behavior}}" stepKey="selectImportOption"/> + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="{{behavior}}" stepKey="selectImportBehaviorOption"/> + <selectOption selector="{{AdminImportMainSection.validationStrategy}}" userInput="{{validationStrategy}}" stepKey="selectValidationStrategyOption"/> + <fillField selector="{{AdminImportMainSection.allowedErrorsCount}}" userInput="{{allowedErrorsCount}}" stepKey="fillAllowedErrorsCountField"/> <attachFile selector="{{AdminImportMainSection.selectFileToImport}}" userInput="{{importFile}}" stepKey="attachFileForImport"/> <click selector="{{AdminImportHeaderSection.checkDataButton}}" stepKey="clickCheckDataButton"/> <click selector="{{AdminImportMainSection.importButton}}" stepKey="clickImportButton"/> - <waitForPageLoad stepKey="AdminImportMainSectionLoad2"/> - <see selector="{{AdminMessagesSection.successMessage}}" userInput="Import successfully done" stepKey="assertSuccessMessage"/> - <waitForPageLoad stepKey="AdminMessagesSection"/> - <see selector="{{AdminMessagesSection.notice}}" userInput="{{importMessage}}" stepKey="seeImportMessage"/> + <waitForElementVisible selector="{{AdminImportValidationMessagesSection.notice}}" stepKey="waitForNoticeMessage"/> + <see selector="{{AdminImportValidationMessagesSection.notice}}" userInput="{{importNoticeMessage}}" stepKey="seeNoticeMessage"/> + <see selector="{{AdminImportValidationMessagesSection.messageByType(importMessageType)}}" userInput="{{importMessage}}" stepKey="seeImportMessage"/> + </actionGroup> + + <actionGroup name="AdminImportProductsWithCheckValidationResultActionGroup" extends="AdminImportProductsActionGroup"> + <arguments> + <argument name="validationNoticeMessage" type="string"/> + <argument name="validationMessage" type="string" defaultValue="File is valid! To start import process press "Import" button"/> + </arguments> + <waitForElementVisible selector="{{AdminImportValidationMessagesSection.notice}}" after="clickCheckDataButton" stepKey="waitForValidationNoticeMessage"/> + <see selector="{{AdminImportValidationMessagesSection.notice}}" userInput="{{validationNoticeMessage}}" after="waitForValidationNoticeMessage" stepKey="seeValidationNoticeMessage"/> + <see selector="{{AdminImportValidationMessagesSection.success}}" userInput="{{validationMessage}}" after="seeValidationNoticeMessage" stepKey="seeValidationMessage"/> + </actionGroup> + <actionGroup name="AdminCheckDataForImportProductActionGroup"> + <arguments> + <argument name="behavior" type="string" defaultValue="Add/Update"/> + <argument name="importFile" type="string"/> + </arguments> + <amOnPage url="{{AdminImportIndexPage.url}}" stepKey="goToImportIndexPage"/> + <waitForPageLoad stepKey="adminImportMainSectionLoad"/> + <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Products" stepKey="selectProductsOption"/> + <waitForElementVisible selector="{{AdminImportMainSection.importBehavior}}" stepKey="waitForImportBehaviorElementVisible"/> + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="{{behavior}}" stepKey="selectImportBehaviorOption"/> + <attachFile selector="{{AdminImportMainSection.selectFileToImport}}" userInput="{{importFile}}" stepKey="attachFileForImport"/> + <click selector="{{AdminImportHeaderSection.checkDataButton}}" stepKey="clickCheckDataButton"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Page/AdminImportIndexPage.xml b/app/code/Magento/ImportExport/Test/Mftf/Page/AdminImportIndexPage.xml index 87807eb9b0e85..6262931179599 100644 --- a/app/code/Magento/ImportExport/Test/Mftf/Page/AdminImportIndexPage.xml +++ b/app/code/Magento/ImportExport/Test/Mftf/Page/AdminImportIndexPage.xml @@ -11,5 +11,6 @@ <page name="AdminImportIndexPage" url="admin/import/" area="admin" module="Magento_ImportExport"> <section name="AdminImportHeaderSection"/> <section name="AdminImportMainSection"/> + <section name="AdminImportValidationMessagesSection"/> </page> </pages> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml index 2ce6b1e35777f..ba1deeebbd89a 100644 --- a/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml +++ b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml @@ -13,5 +13,9 @@ <element name="importBehavior" type="select" selector="#basic_behavior"/> <element name="selectFileToImport" type="input" selector="#import_file"/> <element name="importButton" type="button" selector="#import_validation_container button" timeout="30"/> + <element name="messageSuccess" type="text" selector=".messages div.message-success"/> + <element name="messageError" type="text" selector=".messages div.message-error"/> + <element name="validationStrategy" type="select" selector="#basic_behaviorvalidation_strategy"/> + <element name="allowedErrorsCount" type="input" selector="#basic_behavior_allowed_error_count"/> </section> </sections> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportValidationMessagesSection.xml b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportValidationMessagesSection.xml new file mode 100644 index 0000000000000..370d9546fa2f7 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportValidationMessagesSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminImportValidationMessagesSection"> + <element name="notice" type="text" selector="#import_validation_messages .message-notice"/> + <element name="success" type="text" selector="#import_validation_messages .message-success"/> + <element name="messageByType" type="text" selector="#import_validation_messages .message-{{messageType}}" parameterized="true" /> + <element name="importErrorList" type="text" selector="#import_validation_messages .import-error-list"/> + </section> +</sections> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminCheckDoubleImportOfProductsTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminCheckDoubleImportOfProductsTest.xml index 0f2dde99b9016..909c6101fe53e 100644 --- a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminCheckDoubleImportOfProductsTest.xml +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminCheckDoubleImportOfProductsTest.xml @@ -60,14 +60,14 @@ <actionGroup ref="AdminImportProductsActionGroup" stepKey="adminImportProductsFirstTime"> <argument name="behavior" value="Add/Update"/> <argument name="importFile" value="prepared-for-sample-data.csv"/> - <argument name="importMessage" value="Created: 100, Updated: 3, Deleted: 0"/> + <argument name="importNoticeMessage" value="Created: 100, Updated: 3, Deleted: 0"/> </actionGroup> <!-- Import products with add/update behavior again --> <actionGroup ref="AdminImportProductsActionGroup" stepKey="adminImportProductsSecondTime"> <argument name="behavior" value="Add/Update"/> <argument name="importFile" value="prepared-for-sample-data.csv"/> - <argument name="importMessage" value="Created: 0, Updated: 300, Deleted: 0"/> + <argument name="importNoticeMessage" value="Created: 0, Updated: 300, Deleted: 0"/> </actionGroup> </test> </tests> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithAddUpdateBehaviorTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithAddUpdateBehaviorTest.xml index ceb4e93e4e9aa..796732d572290 100644 --- a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithAddUpdateBehaviorTest.xml +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithAddUpdateBehaviorTest.xml @@ -72,7 +72,7 @@ <actionGroup ref="AdminImportProductsActionGroup" stepKey="adminImportProducts"> <argument name="behavior" value="Add/Update"/> <argument name="importFile" value="catalog_import_products.csv"/> - <argument name="importMessage" value="Created: 2, Updated: 1, Deleted: 0"/> + <argument name="importNoticeMessage" value="Created: 2, Updated: 1, Deleted: 0"/> </actionGroup> <!-- Assert Simple Product1 on grid--> @@ -109,7 +109,7 @@ <actionGroup ref="StoreFrontProductValidationActionGroup" stepKey="storeFrontSimpleProduct1Validation"> <argument name="product" value="SimpleProductAfterImport1"/> </actionGroup> - + <!-- Assert SimpleProduct2 on store front--> <actionGroup ref="StoreFrontProductValidationActionGroup" stepKey="storeFrontSimpleProduct2Validation"> <argument name="product" value="SimpleProductAfterImport2"/> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithErrorEntriesTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithErrorEntriesTest.xml new file mode 100644 index 0000000000000..94840a4ea6142 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithErrorEntriesTest.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminImportProductsWithErrorEntriesTest"> + <annotations> + <features value="ImportExport"/> + <stories value="Import Products"/> + <title value="Import products with error entries"/> + <description value="Verify import status during import products with error entries"/> + <severity value="MAJOR"/> + <testCaseId value="MC-6358"/> + <useCaseId value="MAGETWO-65066"/> + <group value="importExport"/> + </annotations> + <before> + <!--Login to Admin Page--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <!--Clear products grid filters--> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearProductsGridFilters"/> + <!--Delete all imported products--> + <actionGroup ref="deleteProductsIfTheyExist" stepKey="deleteAllProducts"/> + <!--Logout from Admin page--> + <actionGroup ref="logout" stepKey="logoutFromAdminPage"/> + </after> + + <!--Import products with "Skip error entries"--> + <actionGroup ref="AdminImportProductsWithCheckValidationResultActionGroup" stepKey="importProductsWithSkipErrorEntries"> + <argument name="behavior" value="Add/Update"/> + <argument name="validationStrategy" value="Skip error entries"/> + <argument name="importFile" value="catalog_product_err_img.csv"/> + <argument name="importNoticeMessage" value="Created: 10, Updated: 0, Deleted: 0"/> + <argument name="validationNoticeMessage" value="Checked rows: 10, checked entities: 10, invalid rows: 0, total errors: 0"/> + </actionGroup> + <see selector="{{AdminImportValidationMessagesSection.importErrorList}}" userInput="row(s): 1, 2, 3, 4, 5, 6, 7, 8, 9, 10" stepKey="seeTenImportError"/> + + <!--Import products with "Stop on Error" and "Allowed Errors Count" equals 5--> + <actionGroup ref="AdminImportProductsWithCheckValidationResultActionGroup" stepKey="importProductsWithAllowedErrorsCountFive"> + <argument name="behavior" value="Add/Update"/> + <argument name="allowedErrorsCount" value="5"/> + <argument name="importFile" value="catalog_product_err_img.csv"/> + <argument name="importNoticeMessage" value="Following Error(s) has been occurred during importing process"/> + <argument name="importMessageType" value="error"/> + <argument name="importMessage" value="Maximum error count has been reached or system error is occurred!"/> + <argument name="validationNoticeMessage" value="Checked rows: 10, checked entities: 10, invalid rows: 0, total errors: 0"/> + </actionGroup> + <see selector="{{AdminImportValidationMessagesSection.importErrorList}}" userInput="row(s): 1, 2, 3, 4, 5, 6" stepKey="seeAboutFiveImportError"/> + + <!--Import products with "Stop on Error" and "Allowed Errors Count" equals 11--> + <actionGroup ref="AdminImportProductsWithCheckValidationResultActionGroup" stepKey="importProductsWithAllowedErrorsCountEleven"> + <argument name="behavior" value="Add/Update"/> + <argument name="allowedErrorsCount" value="11"/> + <argument name="importFile" value="catalog_product_err_img.csv"/> + <argument name="importNoticeMessage" value="Created: 0, Updated: 10, Deleted: 0"/> + <argument name="validationNoticeMessage" value="Checked rows: 10, checked entities: 10, invalid rows: 0, total errors: 0"/> + </actionGroup> + <see selector="{{AdminImportValidationMessagesSection.importErrorList}}" userInput="row(s): 1, 2, 3, 4, 5, 6, 7, 8, 9, 10" stepKey="seeAboutTenImportError"/> + </test> +</tests> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithReplaceBehaviorTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithReplaceBehaviorTest.xml index d63a5546716b1..dc4ede1978de3 100644 --- a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithReplaceBehaviorTest.xml +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithReplaceBehaviorTest.xml @@ -39,7 +39,7 @@ <actionGroup ref="AdminImportProductsActionGroup" stepKey="adminImportProducts"> <argument name="behavior" value="Replace"/> <argument name="importFile" value="catalog_import_products.csv"/> - <argument name="importMessage" value="Created: 3, Updated: 0, Deleted: 3"/> + <argument name="importNoticeMessage" value="Created: 3, Updated: 0, Deleted: 3"/> </actionGroup> </test> </tests> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImportCSVFileCorrectDifferentFilesTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImportCSVFileCorrectDifferentFilesTest.xml new file mode 100644 index 0000000000000..eb84929ec8d93 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImportCSVFileCorrectDifferentFilesTest.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminProductImportCSVFileCorrectDifferentFilesTest"> + <annotations> + <description value="Product import from CSV file correct from different files."/> + <features value="Import/Export"/> + <title value="Product import from CSV file correct from different files."/> + <severity value="MAJOR"/> + <testCaseId value="MC-17104"/> + <useCaseId value="MAGETWO-70803"/> + <group value="importExport"/> + </annotations> + <before> + <!--Login as Admin--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <!--Logout from Admin--> + <actionGroup ref="logout" stepKey="logoutFromAdmin"/> + </after> + <!--Check data products with add/update behavior--> + <actionGroup ref="AdminCheckDataForImportProductActionGroup" stepKey="adminImportProducts"> + <argument name="behavior" value="Add/Update"/> + <argument name="importFile" value="BB-ProductsWorking.csv"/> + </actionGroup> + <see selector="{{AdminImportMainSection.messageSuccess}}" userInput='File is valid! To start import process press "Import" button' stepKey="seeSuccessMessage"/> + <actionGroup ref="AdminCheckDataForImportProductActionGroup" stepKey="adminImportProducts1"> + <argument name="behavior" value="Add/Update"/> + <argument name="importFile" value="BB-Products.csv"/> + </actionGroup> + <see selector="{{AdminImportMainSection.messageError}}" userInput='Curly quotes used instead of straight quotes in row(s): 84, 85' stepKey="seeErrorMessage"/> + </test> +</tests> diff --git a/app/code/Magento/ImportExport/etc/communication.xml b/app/code/Magento/ImportExport/etc/communication.xml index 7794b3e5ab248..3f87eef1ddbd4 100644 --- a/app/code/Magento/ImportExport/etc/communication.xml +++ b/app/code/Magento/ImportExport/etc/communication.xml @@ -6,7 +6,7 @@ */ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Communication/etc/communication.xsd"> - <topic name="import_export.export" request="Magento\ImportExport\Api\Data\ExportInfoInterface"> + <topic name="import_export.export" request="Magento\ImportExport\Api\Data\ExtendedExportInfoInterface"> <handler name="exportProcessor" type="Magento\ImportExport\Model\Export\Consumer" method="process" /> </topic> </config> diff --git a/app/code/Magento/ImportExport/etc/db_schema.xml b/app/code/Magento/ImportExport/etc/db_schema.xml index df45131848519..404999cb9e07a 100644 --- a/app/code/Magento/ImportExport/etc/db_schema.xml +++ b/app/code/Magento/ImportExport/etc/db_schema.xml @@ -8,7 +8,7 @@ <schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="importexport_importdata" resource="default" engine="innodb" comment="Import Data Table"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="varchar" name="entity" nullable="false" length="50" comment="Entity"/> <column xsi:type="varchar" name="behavior" nullable="false" length="10" default="append" comment="Behavior"/> <column xsi:type="longtext" name="data" nullable="true" comment="Data"/> @@ -18,7 +18,7 @@ </table> <table name="import_history" resource="default" engine="innodb" comment="Import history table"> <column xsi:type="int" name="history_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="History record Id"/> + comment="History record ID"/> <column xsi:type="timestamp" name="started_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Started at"/> <column xsi:type="int" name="user_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" diff --git a/app/code/Magento/ImportExport/etc/di.xml b/app/code/Magento/ImportExport/etc/di.xml index 909b526e4790c..2a9e1d388754f 100644 --- a/app/code/Magento/ImportExport/etc/di.xml +++ b/app/code/Magento/ImportExport/etc/di.xml @@ -11,6 +11,7 @@ <preference for="Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregatorInterface" type="Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregator" /> <preference for="Magento\ImportExport\Model\Report\ReportProcessorInterface" type="Magento\ImportExport\Model\Report\Csv" /> <preference for="Magento\ImportExport\Api\Data\ExportInfoInterface" type="Magento\ImportExport\Model\Export\Entity\ExportInfo" /> + <preference for="Magento\ImportExport\Api\Data\ExtendedExportInfoInterface" type="Magento\ImportExport\Model\Export\Entity\ExportInfo" /> <preference for="Magento\ImportExport\Api\ExportManagementInterface" type="Magento\ImportExport\Model\Export\ExportManagement" /> <type name="Magento\Framework\Module\Setup\Migration"> <arguments> diff --git a/app/code/Magento/Indexer/etc/db_schema.xml b/app/code/Magento/Indexer/etc/db_schema.xml index d7cb006a2cf45..c9c8e665b3755 100644 --- a/app/code/Magento/Indexer/etc/db_schema.xml +++ b/app/code/Magento/Indexer/etc/db_schema.xml @@ -9,8 +9,8 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="indexer_state" resource="default" engine="innodb" comment="Indexer State"> <column xsi:type="int" name="state_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Indexer State Id"/> - <column xsi:type="varchar" name="indexer_id" nullable="true" length="255" comment="Indexer Id"/> + comment="Indexer State ID"/> + <column xsi:type="varchar" name="indexer_id" nullable="true" length="255" comment="Indexer ID"/> <column xsi:type="varchar" name="status" nullable="true" length="16" default="invalid" comment="Indexer Status"/> <column xsi:type="datetime" name="updated" on_update="false" nullable="true" comment="Indexer Status"/> @@ -24,13 +24,13 @@ </table> <table name="mview_state" resource="default" engine="innodb" comment="View State"> <column xsi:type="int" name="state_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="View State Id"/> - <column xsi:type="varchar" name="view_id" nullable="true" length="255" comment="View Id"/> + comment="View State ID"/> + <column xsi:type="varchar" name="view_id" nullable="true" length="255" comment="View ID"/> <column xsi:type="varchar" name="mode" nullable="true" length="16" default="disabled" comment="View Mode"/> <column xsi:type="varchar" name="status" nullable="true" length="16" default="idle" comment="View Status"/> <column xsi:type="datetime" name="updated" on_update="false" nullable="true" comment="View updated time"/> <column xsi:type="int" name="version_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="View Version Id"/> + comment="View Version ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="state_id"/> </constraint> diff --git a/app/code/Magento/Integration/etc/db_schema.xml b/app/code/Magento/Integration/etc/db_schema.xml index cbf43d79b2cf6..de0cec2e4e20d 100644 --- a/app/code/Magento/Integration/etc/db_schema.xml +++ b/app/code/Magento/Integration/etc/db_schema.xml @@ -129,7 +129,7 @@ <table name="oauth_token_request_log" resource="default" engine="innodb" comment="Log of token request authentication failures."> <column xsi:type="int" name="log_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Log Id"/> + comment="Log ID"/> <column xsi:type="varchar" name="user_name" nullable="false" length="255" comment="Customer email or admin login"/> <column xsi:type="smallint" name="user_type" padding="5" unsigned="true" nullable="false" identity="false" diff --git a/app/code/Magento/MediaStorage/etc/adminhtml/system.xml b/app/code/Magento/MediaStorage/etc/adminhtml/system.xml index 0f6e7f93aea11..2c7219fe8afaa 100644 --- a/app/code/Magento/MediaStorage/etc/adminhtml/system.xml +++ b/app/code/Magento/MediaStorage/etc/adminhtml/system.xml @@ -28,6 +28,7 @@ </field> <field id="configuration_update_time" translate="label" type="text" sortOrder="400" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>Environment Update Time</label> + <validate>validate-zero-or-greater validate-digits</validate> </field> </group> </section> diff --git a/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithMultishipmentTest.xml b/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithMultishipmentTest.xml index 271f6e707cd69..3a58ead3b6dfa 100644 --- a/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithMultishipmentTest.xml +++ b/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithMultishipmentTest.xml @@ -57,6 +57,7 @@ <deleteData stepKey="deleteProduct1" createDataKey="product1"/> <deleteData stepKey="deleteProduct2" createDataKey="product2"/> <deleteData stepKey="deleteCustomer" createDataKey="customer"/> + <createData entity="FreeShippinMethodDefault" stepKey="disableFreeShipping"/> <actionGroup ref="logout" stepKey="logout"/> </after> </test> diff --git a/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithSingleShipmentTest.xml b/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithSingleShipmentTest.xml index f425a44130ecb..c9f1856249762 100644 --- a/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithSingleShipmentTest.xml +++ b/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontCheckingWithSingleShipmentTest.xml @@ -57,6 +57,7 @@ <deleteData stepKey="deleteProduct1" createDataKey="product1"/> <deleteData stepKey="deleteProduct2" createDataKey="product2"/> <deleteData stepKey="deleteCustomer" createDataKey="customer"/> + <createData entity="FreeShippinMethodDefault" stepKey="disableFreeShipping"/> <actionGroup ref="logout" stepKey="logout"/> </after> </test> diff --git a/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontMinicartWithMultishipmentTest.xml b/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontMinicartWithMultishipmentTest.xml index 8d5a58acc7e18..d52ddb11212aa 100644 --- a/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontMinicartWithMultishipmentTest.xml +++ b/app/code/Magento/Multishipping/Test/Mftf/Test/StoreFrontMinicartWithMultishipmentTest.xml @@ -37,6 +37,16 @@ </actionGroup> </before> + <after> + <deleteData stepKey="deleteCategory" createDataKey="category"/> + <deleteData stepKey="deleteProduct1" createDataKey="product1"/> + <deleteData stepKey="deleteProduct2" createDataKey="product2"/> + <deleteData stepKey="deleteCustomer" createDataKey="customer"/> + <createData entity="FreeShippinMethodDefault" stepKey="disableFreeShipping"/> + <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="logoutCustomer"/> + <actionGroup ref="logout" stepKey="logoutAdmin"/> + </after> + <amOnPage url="$$product1.name$$.html" stepKey="goToProduct1"/> <actionGroup ref="addToCartFromStorefrontProductPage" stepKey="addToCartFromStorefrontProduct1"> <argument name="productName" value="$$product1.name$$"/> @@ -53,13 +63,5 @@ <amOnPage url="/checkout/cart/index/" stepKey="amOnCheckoutCartIndexPage"/> <actionGroup ref="StorefrontOpenCartFromMinicartActionGroup" stepKey="openCartAgain"/> <actionGroup ref="CheckingWithMinicartActionGroup" stepKey="checkoutWithMinicart"/> - - <after> - <deleteData stepKey="deleteCategory" createDataKey="category"/> - <deleteData stepKey="deleteProduct1" createDataKey="product1"/> - <deleteData stepKey="deleteProduct2" createDataKey="product2"/> - <deleteData stepKey="deleteCustomer" createDataKey="customer"/> - <actionGroup ref="logout" stepKey="logout"/> - </after> </test> </tests> diff --git a/app/code/Magento/NewRelicReporting/etc/db_schema.xml b/app/code/Magento/NewRelicReporting/etc/db_schema.xml index c6e61b88f4b1b..e18d7c8077bb9 100644 --- a/app/code/Magento/NewRelicReporting/etc/db_schema.xml +++ b/app/code/Magento/NewRelicReporting/etc/db_schema.xml @@ -22,7 +22,7 @@ </table> <table name="reporting_module_status" resource="default" engine="innodb" comment="Module Status Table"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Module Id"/> + comment="Module ID"/> <column xsi:type="varchar" name="name" nullable="true" length="255" comment="Module Name"/> <column xsi:type="varchar" name="active" nullable="true" length="255" comment="Module Active Status"/> <column xsi:type="varchar" name="setup_version" nullable="true" length="255" comment="Module Version"/> diff --git a/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php b/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php index f9e9d57bf4b40..8ca489d89c1df 100644 --- a/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php +++ b/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php @@ -130,7 +130,7 @@ public function loadByEmail($subscriberEmail) */ public function loadByCustomerData(\Magento\Customer\Api\Data\CustomerInterface $customer) { - $storeIds = $this->storeManager->getWebsite($customer->getWebsiteId())->getStoreIds(); + $storeIds = $this->storeManager->getWebsite()->getStoreIds(); if ($customer->getId()) { $select = $this->connection diff --git a/app/code/Magento/Newsletter/Model/Subscriber.php b/app/code/Magento/Newsletter/Model/Subscriber.php index 85d512afaf262..c5eee5e3cf771 100644 --- a/app/code/Magento/Newsletter/Model/Subscriber.php +++ b/app/code/Magento/Newsletter/Model/Subscriber.php @@ -353,11 +353,7 @@ public function isStatusChanged() */ public function isSubscribed() { - if ($this->getId() && $this->getStatus() == self::STATUS_SUBSCRIBED) { - return true; - } - - return false; + return $this->getId() && (int)$this->getStatus() === self::STATUS_SUBSCRIBED; } /** diff --git a/app/code/Magento/Newsletter/Test/Mftf/Test/AdminAddImageToWYSIWYGNewsletterTest.xml b/app/code/Magento/Newsletter/Test/Mftf/Test/AdminAddImageToWYSIWYGNewsletterTest.xml index 510f3e16e8d8e..0371c0265d149 100644 --- a/app/code/Magento/Newsletter/Test/Mftf/Test/AdminAddImageToWYSIWYGNewsletterTest.xml +++ b/app/code/Magento/Newsletter/Test/Mftf/Test/AdminAddImageToWYSIWYGNewsletterTest.xml @@ -16,9 +16,6 @@ <description value="Admin should be able to add image to WYSIWYG content Newsletter"/> <severity value="CRITICAL"/> <testCaseId value="MAGETWO-84377"/> - <skip> - <issueId value="MC-17233"/> - </skip> </annotations> <before> <actionGroup ref="LoginActionGroup" stepKey="login"/> diff --git a/app/code/Magento/Newsletter/etc/db_schema.xml b/app/code/Magento/Newsletter/etc/db_schema.xml index 5cb572f41b6be..257416d0bc465 100644 --- a/app/code/Magento/Newsletter/etc/db_schema.xml +++ b/app/code/Magento/Newsletter/etc/db_schema.xml @@ -9,13 +9,13 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="newsletter_subscriber" resource="default" engine="innodb" comment="Newsletter Subscriber"> <column xsi:type="int" name="subscriber_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Subscriber Id"/> + comment="Subscriber ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="timestamp" name="change_status_at" on_update="false" nullable="true" comment="Change Status At"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Customer Id"/> + default="0" comment="Customer ID"/> <column xsi:type="varchar" name="subscriber_email" nullable="true" length="150" comment="Subscriber Email"/> <column xsi:type="int" name="subscriber_status" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Subscriber Status"/> @@ -69,7 +69,7 @@ </table> <table name="newsletter_queue" resource="default" engine="innodb" comment="Newsletter Queue"> <column xsi:type="int" name="queue_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Queue Id"/> + comment="Queue ID"/> <column xsi:type="int" name="template_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Template ID"/> <column xsi:type="int" name="newsletter_type" padding="11" unsigned="false" nullable="true" identity="false" @@ -98,11 +98,11 @@ </table> <table name="newsletter_queue_link" resource="default" engine="innodb" comment="Newsletter Queue Link"> <column xsi:type="int" name="queue_link_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Queue Link Id"/> + comment="Queue Link ID"/> <column xsi:type="int" name="queue_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Queue Id"/> + default="0" comment="Queue ID"/> <column xsi:type="int" name="subscriber_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Subscriber Id"/> + default="0" comment="Subscriber ID"/> <column xsi:type="timestamp" name="letter_sent_at" on_update="false" nullable="true" comment="Letter Sent At"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="queue_link_id"/> @@ -123,9 +123,9 @@ </table> <table name="newsletter_queue_store_link" resource="default" engine="innodb" comment="Newsletter Queue Store Link"> <column xsi:type="int" name="queue_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Queue Id"/> + default="0" comment="Queue ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="queue_id"/> <column name="store_id"/> @@ -142,11 +142,11 @@ </table> <table name="newsletter_problem" resource="default" engine="innodb" comment="Newsletter Problems"> <column xsi:type="int" name="problem_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Problem Id"/> + comment="Problem ID"/> <column xsi:type="int" name="subscriber_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Subscriber Id"/> + comment="Subscriber ID"/> <column xsi:type="int" name="queue_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Queue Id"/> + default="0" comment="Queue ID"/> <column xsi:type="int" name="problem_error_code" padding="10" unsigned="true" nullable="true" identity="false" default="0" comment="Problem Error Code"/> <column xsi:type="varchar" name="problem_error_text" nullable="true" length="200" comment="Problem Error Text"/> diff --git a/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml b/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml index 5175080add914..20ff63a60a263 100644 --- a/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml +++ b/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml @@ -17,6 +17,7 @@ <iframe name="preview_iframe" id="preview_iframe" + class="preview_iframe" frameborder="0" title="<?= $block->escapeHtmlAttr(__('Preview')) ?>" width="100%" diff --git a/app/code/Magento/OfflineShipping/etc/db_schema.xml b/app/code/Magento/OfflineShipping/etc/db_schema.xml index 5129e8a29b2a1..6bda6597e2f61 100644 --- a/app/code/Magento/OfflineShipping/etc/db_schema.xml +++ b/app/code/Magento/OfflineShipping/etc/db_schema.xml @@ -11,11 +11,11 @@ <column xsi:type="int" name="pk" padding="10" unsigned="true" nullable="false" identity="true" comment="Primary key"/> <column xsi:type="int" name="website_id" padding="11" unsigned="false" nullable="false" identity="false" - default="0" comment="Website Id"/> + default="0" comment="Website ID"/> <column xsi:type="varchar" name="dest_country_id" nullable="false" length="4" default="0" comment="Destination coutry ISO/2 or ISO/3 code"/> <column xsi:type="int" name="dest_region_id" padding="11" unsigned="false" nullable="false" identity="false" - default="0" comment="Destination Region Id"/> + default="0" comment="Destination Region ID"/> <column xsi:type="varchar" name="dest_zip" nullable="false" length="10" default="*" comment="Destination Post Code (Zip)"/> <column xsi:type="varchar" name="condition_name" nullable="false" length="30" comment="Rate Condition name"/> diff --git a/app/code/Magento/PageCache/Test/Mftf/Test/AdminFrontendAreaSessionMustNotAffectAdminAreaTest.xml b/app/code/Magento/PageCache/Test/Mftf/Test/AdminFrontendAreaSessionMustNotAffectAdminAreaTest.xml new file mode 100644 index 0000000000000..375211e5f2f51 --- /dev/null +++ b/app/code/Magento/PageCache/Test/Mftf/Test/AdminFrontendAreaSessionMustNotAffectAdminAreaTest.xml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminFrontendAreaSessionMustNotAffectAdminAreaTest"> + <annotations> + <stories value="Backend"/> + <features value="Session cookies"/> + <title value="Frontend area session must not affect admin area"/> + <description value="Frontend area session must not affect admin area"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-12353"/> + <group value="backend"/> + <group value="pagecache"/> + <group value="cookie"/> + </annotations> + <before> + <!-- Create Data --> + <createData entity="_defaultCategory" stepKey="createCategoryA"/> + <createData entity="SubCategoryWithParent" stepKey="createCategoryB"> + <requiredEntity createDataKey="createCategoryA"/> + </createData> + <createData entity="SubCategoryWithParent" stepKey="createCategoryC"> + <requiredEntity createDataKey="createCategoryB"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct1"> + <requiredEntity createDataKey="createCategoryC"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct2"> + <requiredEntity createDataKey="createCategoryC"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct3"> + <requiredEntity createDataKey="createCategoryA"/> + </createData> + + <magentoCLI command="cache:clean" arguments="full_page" stepKey="clearCache"/> + <actionGroup ref="logout" stepKey="logout"/> + <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="logoutCustomer"/> + <resetCookie userInput="PHPSESSID" stepKey="resetSessionCookie"/> + </before> + <after> + <!-- Delete data --> + <deleteData createDataKey="createProduct1" stepKey="deleteProduct1"/> + <deleteData createDataKey="createProduct2" stepKey="deleteProduct2"/> + <deleteData createDataKey="createProduct3" stepKey="deleteProduct3"/> + + <deleteData createDataKey="createCategoryC" stepKey="deleteCategoryC"/> + <deleteData createDataKey="createCategoryB" stepKey="deleteCategoryB"/> + <deleteData createDataKey="createCategoryA" stepKey="deleteCategoryA"/> + + <actionGroup ref="logout" stepKey="logoutAdmin"/> + </after> + + <!-- 1. Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="LoginAsAdmin"/> + + <!-- 2. Navigate Go to "Catalog"->"Products" --> + <amOnPage url="{{ProductCatalogPage.url}}" stepKey="onCatalogProductPage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <!-- 3. Open separate tab with Storefront --> + <openNewTab stepKey="openNewTab"/> + + <!-- 4. Navigate to Men -> "Tops" -> "Jackets" --> + <amOnPage + url="{{StorefrontCategoryPage.url($$createCategoryA.custom_attributes[url_key]$$/$$createCategoryB.custom_attributes[url_key]$$/$$createCategoryC.custom_attributes[url_key]$$)}}" + stepKey="openCategoryPage"/> + <waitForPageLoad time="60" stepKey="waitForCategoryPage"/> + + <!-- 5. Open admin tab with page with products. Reload this page twice. --> + <switchToPreviousTab stepKey="switchToPreviousTab"/> + <reloadPage stepKey="reloadAdminCatalogPageFirst"/> + <waitForPageLoad stepKey="waitForReloadFirst"/> + <reloadPage stepKey="reloadAdminCatalogPageSecond"/> + <waitForPageLoad stepKey="waitForReloadSecond"/> + + <seeInTitle userInput="Products / Inventory / Catalog / Magento Admin" stepKey="seeAdminProductsPageTitle"/> + <see userInput="Products" selector="{{AdminHeaderSection.pageTitle}}" stepKey="seeAdminProductsPageHeader"/> + + <switchToNextTab stepKey="switchToFrontendTab"/> + <closeTab stepKey="closeFrontendTab"/> + </test> +</tests> diff --git a/app/code/Magento/PageCache/etc/adminhtml/system.xml b/app/code/Magento/PageCache/etc/adminhtml/system.xml index 8013ad40ef5aa..234e3e48a95d8 100644 --- a/app/code/Magento/PageCache/etc/adminhtml/system.xml +++ b/app/code/Magento/PageCache/etc/adminhtml/system.xml @@ -74,6 +74,7 @@ </group> <field id="ttl" type="text" translate="label comment" sortOrder="5" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> <label>TTL for public content</label> + <validate>validate-zero-or-greater validate-digits</validate> <comment>Public content cache lifetime in seconds. If field is empty default value 86400 will be saved. </comment> <backend_model>Magento\PageCache\Model\System\Config\Backend\Ttl</backend_model> </field> diff --git a/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php b/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php index 7ad8fe658ec16..895cdb8d4c600 100644 --- a/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php +++ b/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php @@ -9,6 +9,8 @@ use Magento\Framework\App\Action\Action as AppAction; use Magento\Framework\App\Action\HttpGetActionInterface; use Magento\Framework\App\Action\HttpPostActionInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Api\Data\CartInterface; /** @@ -98,6 +100,11 @@ abstract class AbstractExpress extends AppAction implements */ protected $_customerUrl; + /** + * @var CartRepositoryInterface + */ + private $quoteRepository; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Customer\Model\Session $customerSession @@ -107,6 +114,7 @@ abstract class AbstractExpress extends AppAction implements * @param \Magento\Framework\Session\Generic $paypalSession * @param \Magento\Framework\Url\Helper\Data $urlHelper * @param \Magento\Customer\Model\Url $customerUrl + * @param CartRepositoryInterface $quoteRepository */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -116,7 +124,8 @@ public function __construct( \Magento\Paypal\Model\Express\Checkout\Factory $checkoutFactory, \Magento\Framework\Session\Generic $paypalSession, \Magento\Framework\Url\Helper\Data $urlHelper, - \Magento\Customer\Model\Url $customerUrl + \Magento\Customer\Model\Url $customerUrl, + CartRepositoryInterface $quoteRepository = null ) { $this->_customerSession = $customerSession; $this->_checkoutSession = $checkoutSession; @@ -128,6 +137,7 @@ public function __construct( parent::__construct($context); $parameters = ['params' => [$this->_configMethod]]; $this->_config = $this->_objectManager->create($this->_configType, $parameters); + $this->quoteRepository = $quoteRepository ?: ObjectManager::getInstance()->get(CartRepositoryInterface::class); } /** @@ -233,7 +243,12 @@ protected function _getCheckoutSession() protected function _getQuote() { if (!$this->_quote) { - $this->_quote = $this->_getCheckoutSession()->getQuote(); + if ($this->_getSession()->getQuoteId()) { + $this->_quote = $this->quoteRepository->get($this->_getSession()->getQuoteId()); + $this->_getCheckoutSession()->replaceQuote($this->_quote); + } else { + $this->_quote = $this->_getCheckoutSession()->getQuote(); + } } return $this->_quote; } @@ -243,7 +258,7 @@ protected function _getQuote() */ public function getCustomerBeforeAuthUrl() { - return; + return null; } /** diff --git a/app/code/Magento/Paypal/Controller/Express/OnAuthorization.php b/app/code/Magento/Paypal/Controller/Express/OnAuthorization.php index 62f4c4c4c457a..0d7ec3fc6f32d 100644 --- a/app/code/Magento/Paypal/Controller/Express/OnAuthorization.php +++ b/app/code/Magento/Paypal/Controller/Express/OnAuthorization.php @@ -155,6 +155,7 @@ public function execute(): ResultInterface } else { $responseContent['redirectUrl'] = $this->urlBuilder->getUrl('paypal/express/review'); $this->_checkoutSession->setQuoteId($quote->getId()); + $this->_getSession()->setQuoteId($quote->getId()); } } catch (ApiProcessableException $e) { $responseContent['success'] = false; diff --git a/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/System/Config/Fieldset/GroupTest.php b/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/System/Config/Fieldset/GroupTest.php deleted file mode 100644 index e4de60cafb8ad..0000000000000 --- a/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/System/Config/Fieldset/GroupTest.php +++ /dev/null @@ -1,110 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ - -namespace Magento\Paypal\Test\Unit\Block\Adminhtml\System\Config\Fieldset; - -/** - * Class GroupTest - */ -class GroupTest extends \PHPUnit\Framework\TestCase -{ - /** - * @var Group - */ - protected $_model; - - /** - * @var \Magento\Framework\Data\Form\Element\AbstractElement - */ - protected $_element; - - /** - * @var \Magento\Backend\Model\Auth\Session|\PHPUnit_Framework_MockObject_MockObject - */ - protected $_authSession; - - /** - * @var \Magento\User\Model\User|\PHPUnit_Framework_MockObject_MockObject - */ - protected $_user; - - /** - * @var \Magento\Config\Model\Config\Structure\Element\Group|\PHPUnit_Framework_MockObject_MockObject - */ - protected $_group; - - protected function setUp() - { - $helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->_group = $this->createMock(\Magento\Config\Model\Config\Structure\Element\Group::class); - $this->_element = $this->getMockForAbstractClass( - \Magento\Framework\Data\Form\Element\AbstractElement::class, - [], - '', - false, - true, - true, - ['getHtmlId', 'getElementHtml', 'getName', 'getElements', 'getId'] - ); - $this->_element->expects($this->any()) - ->method('getHtmlId') - ->will($this->returnValue('html id')); - $this->_element->expects($this->any()) - ->method('getElementHtml') - ->will($this->returnValue('element html')); - $this->_element->expects($this->any()) - ->method('getName') - ->will($this->returnValue('name')); - $this->_element->expects($this->any()) - ->method('getElements') - ->will($this->returnValue([])); - $this->_element->expects($this->any()) - ->method('getId') - ->will($this->returnValue('id')); - $this->_user = $this->createMock(\Magento\User\Model\User::class); - $this->_authSession = $this->createMock(\Magento\Backend\Model\Auth\Session::class); - $this->_authSession->expects($this->any()) - ->method('__call') - ->with('getUser') - ->will($this->returnValue($this->_user)); - $this->_model = $helper->getObject( - \Magento\Paypal\Block\Adminhtml\System\Config\Fieldset\Group::class, - ['authSession' => $this->_authSession] - ); - $this->_model->setGroup($this->_group); - } - - /** - * @param mixed $expanded - * @param int $expected - * @dataProvider isCollapseStateDataProvider - */ - public function testIsCollapseState($expanded, $expected) - { - $this->_user->setExtra(['configState' => []]); - $this->_element->setGroup(isset($expanded) ? ['expanded' => $expanded] : []); - $html = $this->_model->render($this->_element); - $this->assertContains( - '<input id="' . $this->_element->getHtmlId() . '-state" name="config_state[' - . $this->_element->getId() . ']" type="hidden" value="' . $expected . '" />', - $html - ); - } - - /** - * @return array - */ - public function isCollapseStateDataProvider() - { - return [ - [null, 0], - [false, 0], - ['', 0], - [1, 1], - ['1', 1], - ]; - } -} diff --git a/app/code/Magento/Paypal/etc/db_schema.xml b/app/code/Magento/Paypal/etc/db_schema.xml index 4dc283fcb48ce..3300f3754e656 100644 --- a/app/code/Magento/Paypal/etc/db_schema.xml +++ b/app/code/Magento/Paypal/etc/db_schema.xml @@ -9,17 +9,17 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="paypal_billing_agreement" resource="default" engine="innodb" comment="Sales Billing Agreement"> <column xsi:type="int" name="agreement_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Agreement Id"/> + comment="Agreement ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="varchar" name="method_code" nullable="false" length="32" comment="Method Code"/> - <column xsi:type="varchar" name="reference_id" nullable="false" length="32" comment="Reference Id"/> + <column xsi:type="varchar" name="reference_id" nullable="false" length="32" comment="Reference ID"/> <column xsi:type="varchar" name="status" nullable="false" length="20" comment="Status"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="false" nullable="true" comment="Updated At"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="agreement_label" nullable="true" length="255" comment="Agreement Label"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="agreement_id"/> @@ -40,9 +40,9 @@ <table name="paypal_billing_agreement_order" resource="default" engine="innodb" comment="Sales Billing Agreement Order"> <column xsi:type="int" name="agreement_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Agreement Id"/> + comment="Agreement ID"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> + comment="Order ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="agreement_id"/> <column name="order_id"/> @@ -59,9 +59,9 @@ </table> <table name="paypal_settlement_report" resource="default" engine="innodb" comment="Paypal Settlement Report Table"> <column xsi:type="int" name="report_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Report Id"/> + comment="Report ID"/> <column xsi:type="date" name="report_date" comment="Report Date"/> - <column xsi:type="varchar" name="account_id" nullable="true" length="64" comment="Account Id"/> + <column xsi:type="varchar" name="account_id" nullable="true" length="64" comment="Account ID"/> <column xsi:type="varchar" name="filename" nullable="true" length="24" comment="Filename"/> <column xsi:type="timestamp" name="last_modified" on_update="false" nullable="true" comment="Last Modified"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -75,15 +75,15 @@ <table name="paypal_settlement_report_row" resource="default" engine="innodb" comment="Paypal Settlement Report Row Table"> <column xsi:type="int" name="row_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Row Id"/> + comment="Row ID"/> <column xsi:type="int" name="report_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Report Id"/> - <column xsi:type="varchar" name="transaction_id" nullable="true" length="19" comment="Transaction Id"/> - <column xsi:type="varchar" name="invoice_id" nullable="true" length="127" comment="Invoice Id"/> + comment="Report ID"/> + <column xsi:type="varchar" name="transaction_id" nullable="true" length="19" comment="Transaction ID"/> + <column xsi:type="varchar" name="invoice_id" nullable="true" length="127" comment="Invoice ID"/> <column xsi:type="varchar" name="paypal_reference_id" nullable="true" length="19" - comment="Paypal Reference Id"/> + comment="Paypal Reference ID"/> <column xsi:type="varchar" name="paypal_reference_id_type" nullable="true" length="3" - comment="Paypal Reference Id Type"/> + comment="Paypal Reference ID Type"/> <column xsi:type="varchar" name="transaction_event_code" nullable="true" length="5" comment="Transaction Event Code"/> <column xsi:type="timestamp" name="transaction_initiation_date" on_update="false" nullable="true" @@ -101,7 +101,7 @@ default="0" comment="Fee Amount"/> <column xsi:type="varchar" name="fee_currency" nullable="true" length="3" comment="Fee Currency"/> <column xsi:type="varchar" name="custom_field" nullable="true" length="255" comment="Custom Field"/> - <column xsi:type="varchar" name="consumer_id" nullable="true" length="127" comment="Consumer Id"/> + <column xsi:type="varchar" name="consumer_id" nullable="true" length="127" comment="Consumer ID"/> <column xsi:type="varchar" name="payment_tracking_id" nullable="true" length="255" comment="Payment Tracking ID"/> <column xsi:type="varchar" name="store_id" nullable="true" length="50" comment="Store ID"/> @@ -117,9 +117,9 @@ </table> <table name="paypal_cert" resource="default" engine="innodb" comment="Paypal Certificate Table"> <column xsi:type="smallint" name="cert_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Cert Id"/> + comment="Cert ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website Id"/> + default="0" comment="Website ID"/> <column xsi:type="text" name="content" nullable="true" comment="Content"/> <column xsi:type="timestamp" name="updated_at" on_update="false" nullable="true" comment="Updated At"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -135,7 +135,7 @@ comment="PayPal Payflow Link Payment Transaction"> <column xsi:type="int" name="transaction_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/> - <column xsi:type="varchar" name="txn_id" nullable="true" length="100" comment="Txn Id"/> + <column xsi:type="varchar" name="txn_id" nullable="true" length="100" comment="Txn ID"/> <column xsi:type="blob" name="additional_information" nullable="true" comment="Additional Information"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="true" comment="Created At"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -146,11 +146,11 @@ </constraint> </table> <table name="quote_payment" resource="checkout" comment="Sales Flat Quote Payment"> - <column xsi:type="varchar" name="paypal_payer_id" nullable="true" length="255" comment="Paypal Payer Id"/> + <column xsi:type="varchar" name="paypal_payer_id" nullable="true" length="255" comment="Paypal Payer ID"/> <column xsi:type="varchar" name="paypal_payer_status" nullable="true" length="255" comment="Paypal Payer Status"/> <column xsi:type="varchar" name="paypal_correlation_id" nullable="true" length="255" - comment="Paypal Correlation Id"/> + comment="Paypal Correlation ID"/> </table> <table name="sales_order" resource="sales" comment="Sales Flat Order"> <column xsi:type="int" name="paypal_ipn_customer_notified" padding="11" unsigned="false" nullable="true" diff --git a/app/code/Magento/Paypal/view/frontend/templates/express/review.phtml b/app/code/Magento/Paypal/view/frontend/templates/express/review.phtml index 7a94ac56232bc..8e222ca7eb04d 100644 --- a/app/code/Magento/Paypal/view/frontend/templates/express/review.phtml +++ b/app/code/Magento/Paypal/view/frontend/templates/express/review.phtml @@ -136,10 +136,6 @@ value="<?= $block->escapeHtml(__('Place Order')) ?>"> <span><?= $block->escapeHtml(__('Place Order')) ?></span> </button> - <button type="button" id="review-submit" class="action checkout primary" - value="<?= $block->escapeHtml(__('Place Order')) ?>"> - <span><?= $block->escapeHtml(__('Place Order')) ?></span> - </button> </div> <span class="please-wait load indicator" id="review-please-wait" style="display: none;" data-text="<?= $block->escapeHtml(__('Submitting order information...')) ?>"> diff --git a/app/code/Magento/Paypal/view/frontend/web/js/order-review.js b/app/code/Magento/Paypal/view/frontend/web/js/order-review.js index 1deee1bd76593..e3db1010693ee 100644 --- a/app/code/Magento/Paypal/view/frontend/web/js/order-review.js +++ b/app/code/Magento/Paypal/view/frontend/web/js/order-review.js @@ -26,7 +26,6 @@ define([ agreementSelector: 'div.checkout-agreements input', isAjax: false, updateShippingMethodSubmitSelector: '#update-shipping-method-submit', - reviewSubmitSelector: '#review-submit', shippingMethodUpdateUrl: null, updateOrderSubmitUrl: null, canEditShippingMethod: false @@ -57,8 +56,7 @@ define([ this.options.updateContainerSelector ) ).find(this.options.updateOrderSelector).on('click', $.proxy(this._updateOrderHandler, this)).end() - .find(this.options.updateShippingMethodSubmitSelector).hide().end() - .find(this.options.reviewSubmitSelector).hide(); + .find(this.options.updateShippingMethodSubmitSelector).hide().end(); this._shippingTobilling(); if ($(this.options.shippingSubmitFormSelector).length && this.options.canEditShippingMethod) { diff --git a/app/code/Magento/PaypalGraphQl/etc/schema.graphqls b/app/code/Magento/PaypalGraphQl/etc/schema.graphqls index 2bacfb18054df..78335e089cdc6 100644 --- a/app/code/Magento/PaypalGraphQl/etc/schema.graphqls +++ b/app/code/Magento/PaypalGraphQl/etc/schema.graphqls @@ -8,7 +8,7 @@ type Query { type Mutation { createPaypalExpressToken(input: PaypalExpressTokenInput!): PaypalExpressToken @resolver(class: "\\Magento\\PaypalGraphQl\\Model\\Resolver\\PaypalExpressToken") @doc(description:"Initiates an Express Checkout transaction and receives a token. Use this mutation for Express Checkout and Payments Standard payment methods.") - createPayflowProToken(input: PayflowProTokenInput!): PayflowProToken @resolver(class: "\\Magento\\PaypalGraphQl\\Model\\Resolver\\PayflowProToken") @doc(description: "Initiates a transaction and receives a token. Use this mutation for Payflow Pro and Payments Pro payment methods") + createPayflowProToken(input: PayflowProTokenInput!): CreatePayflowProTokenOutput @resolver(class: "\\Magento\\PaypalGraphQl\\Model\\Resolver\\PayflowProToken") @doc(description: "Initiates a transaction and receives a token. Use this mutation for Payflow Pro and Payments Pro payment methods") handlePayflowProResponse(input: PayflowProResponseInput!): PayflowProResponseOutput @resolver(class: "\\Magento\\PaypalGraphQl\\Model\\Resolver\\PayflowProResponse") @doc(description: "Handles payment response and saves payment in Quote. Use this mutations for Payflow Pro and Payments Pro payment methods.") } @@ -112,7 +112,15 @@ input PayflowProUrlInput @doc(description:"A set of relative URLs that PayPal wi error_url: String! @doc(description:"The relative URL of the transaction error page that PayPal will redirect to upon payment error. If the full URL to this page is https://www.example.com/paypal/action/error.html, the relative URL is paypal/action/error.html.") } -type PayflowProToken @doc(description: "Contains the secure information used to authorize transaction. Applies to Payflow Pro and Payments Pro payment methods.") { +type PayflowProToken @deprecated(reason: "Use CreatePayflowProTokenOutput instead.") @doc(description: "Contains the secure information used to authorize transaction. Applies to Payflow Pro and Payments Pro payment methods.") { + secure_token: String! + secure_token_id: String! + response_message: String! + result: Int! + result_code: Int! +} + +type CreatePayflowProTokenOutput @doc(description: "Contains the secure information used to authorize transaction. Applies to Payflow Pro and Payments Pro payment methods.") { secure_token: String! secure_token_id: String! response_message: String! diff --git a/app/code/Magento/Persistent/etc/db_schema.xml b/app/code/Magento/Persistent/etc/db_schema.xml index 5021d240417b2..e14dedba0ed56 100644 --- a/app/code/Magento/Persistent/etc/db_schema.xml +++ b/app/code/Magento/Persistent/etc/db_schema.xml @@ -9,10 +9,10 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="persistent_session" resource="default" engine="innodb" comment="Persistent Session"> <column xsi:type="int" name="persistent_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Session id"/> + comment="Session ID"/> <column xsi:type="varchar" name="key" nullable="false" length="50" comment="Unique cookie key"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer id"/> + comment="Customer ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Website ID"/> <column xsi:type="text" name="info" nullable="true" comment="Session Data"/> diff --git a/app/code/Magento/Persistent/etc/frontend/sections.xml b/app/code/Magento/Persistent/etc/frontend/sections.xml index 16b44c502fc47..7466fbe990b02 100644 --- a/app/code/Magento/Persistent/etc/frontend/sections.xml +++ b/app/code/Magento/Persistent/etc/frontend/sections.xml @@ -10,4 +10,7 @@ <action name="persistent/index/unsetCookie"> <section name="persistent"/> </action> + <action name="customer/account/logout"> + <section name="persistent"/> + </action> </config> diff --git a/app/code/Magento/Persistent/view/frontend/templates/remember_me.phtml b/app/code/Magento/Persistent/view/frontend/templates/remember_me.phtml index 0447b3e1b9cef..ba55895453a09 100644 --- a/app/code/Magento/Persistent/view/frontend/templates/remember_me.phtml +++ b/app/code/Magento/Persistent/view/frontend/templates/remember_me.phtml @@ -1,4 +1,5 @@ <?php + /** * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. @@ -13,12 +14,10 @@ ?> <div id="remember-me-box" class="field choice persistent"> <?php $rememberMeId = 'remember_me' . $block->getRandomString(10); ?> - <input type="checkbox" name="persistent_remember_me" class="checkbox" id="<?= $block->escapeHtmlAttr($rememberMeId) ?>"<?php if ($block->isRememberMeChecked()) : ?> checked="checked"<?php endif; ?> title="<?= $block->escapeHtmlAttr(__('Remember Me')) ?>" /> + <input type="checkbox" name="persistent_remember_me" class="checkbox" id="<?= $block->escapeHtmlAttr($rememberMeId) ?>" <?php if ($block->isRememberMeChecked()) : ?> checked="checked" <?php endif; ?> title="<?= $block->escapeHtmlAttr(__('Remember Me')) ?>" /> <label for="<?= $block->escapeHtmlAttr($rememberMeId) ?>" class="label"><span><?= $block->escapeHtml(__('Remember Me')) ?></span></label> <span class="tooltip wrapper"> - <a class="link tooltip toggle" href="#"><?= $block->escapeHtml(__('What\'s this?')) ?></a> - <span class="tooltip content"> - <?= $block->escapeHtml(__('Check "Remember Me" to access your shopping cart on this computer even if you are not signed in.')) ?> - </span> + <strong class="tooltip toggle"> <?= $block->escapeHtml(__('What\'s this?')) ?></strong> + <span class="tooltip content"> <?= $block->escapeHtml(__('Check "Remember Me" to access your shopping cart on this computer even if you are not signed in.')) ?></span> </span> </div> diff --git a/app/code/Magento/Persistent/view/frontend/web/template/remember-me.html b/app/code/Magento/Persistent/view/frontend/web/template/remember-me.html index f5dd1ffd57d9d..427e6bdcd63ab 100644 --- a/app/code/Magento/Persistent/view/frontend/web/template/remember-me.html +++ b/app/code/Magento/Persistent/view/frontend/web/template/remember-me.html @@ -9,8 +9,7 @@ <input type="checkbox" name="persistent_remember_me" class="checkbox" id="persistent_remember_me" data-bind="checked: isRememberMeCheckboxChecked, attr: {title: $t('Remember Me'), 'data-scope': dataScope}" /> <label for="persistent_remember_me" class="label"><span data-bind="i18n: 'Remember Me'"></span></label> <span class="tooltip wrapper"> - <a class="link tooltip toggle" href="#" data-bind="i18n: 'What\'s this?'"></a> - <span class="tooltip content" data-bind="i18n: 'Check \'Remember Me\' to access your shopping cart on this computer even if you are not signed in.'"></span> - </span> + <strong class="tooltip toggle" data-bind="i18n: 'What\'s this?'"></strong> + <span class="tooltip content" data-bind="i18n: 'Check \'Remember Me\' to access your shopping cart on this computer even if you are not signed in.'"></span></span> </div> <!-- /ko --> diff --git a/app/code/Magento/ProductAlert/etc/db_schema.xml b/app/code/Magento/ProductAlert/etc/db_schema.xml index cb91560f8daa6..17cc76246e5c6 100644 --- a/app/code/Magento/ProductAlert/etc/db_schema.xml +++ b/app/code/Magento/ProductAlert/etc/db_schema.xml @@ -9,17 +9,17 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="product_alert_price" resource="default" engine="innodb" comment="Product Alert Price"> <column xsi:type="int" name="alert_price_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Product alert price id"/> + comment="Product alert price ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Customer id"/> + default="0" comment="Customer ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product id"/> + default="0" comment="Product ID"/> <column xsi:type="decimal" name="price" scale="6" precision="20" unsigned="false" nullable="false" default="0" comment="Price amount"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website id"/> + default="0" comment="Website ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - default="0" comment="Store id"/> + default="0" comment="Store ID"/> <column xsi:type="timestamp" name="add_date" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Product alert add date"/> <column xsi:type="timestamp" name="last_send_date" on_update="false" nullable="true" @@ -58,15 +58,15 @@ </table> <table name="product_alert_stock" resource="default" engine="innodb" comment="Product Alert Stock"> <column xsi:type="int" name="alert_stock_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Product alert stock id"/> + comment="Product alert stock ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Customer id"/> + default="0" comment="Customer ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product id"/> + default="0" comment="Product ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website id"/> + default="0" comment="Website ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - default="0" comment="Store id"/> + default="0" comment="Store ID"/> <column xsi:type="timestamp" name="add_date" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Product alert add date"/> <column xsi:type="timestamp" name="send_date" on_update="false" nullable="true" diff --git a/app/code/Magento/Quote/Model/Quote/Address/Total.php b/app/code/Magento/Quote/Model/Quote/Address/Total.php index 00060c15c10d8..d8dd0953407d4 100644 --- a/app/code/Magento/Quote/Model/Quote/Address/Total.php +++ b/app/code/Magento/Quote/Model/Quote/Address/Total.php @@ -168,7 +168,7 @@ public function getAllBaseTotalAmounts() { return $this->baseTotalAmounts; } - + //@codeCoverageIgnoreEnd /** diff --git a/app/code/Magento/Quote/Observer/SubmitObserver.php b/app/code/Magento/Quote/Observer/SubmitObserver.php index 1213636e5966b..0d6613a691390 100644 --- a/app/code/Magento/Quote/Observer/SubmitObserver.php +++ b/app/code/Magento/Quote/Observer/SubmitObserver.php @@ -5,13 +5,21 @@ */ namespace Magento\Quote\Observer; -use Magento\Sales\Model\Order\Email\Sender\OrderSender; +use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; +use Magento\Quote\Model\Quote; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Email\Sender\InvoiceSender; +use Magento\Sales\Model\Order\Email\Sender\OrderSender; +use Psr\Log\LoggerInterface; +/** + * Class SubmitObserver + */ class SubmitObserver implements ObserverInterface { /** - * @var \Psr\Log\LoggerInterface + * @var LoggerInterface */ private $logger; @@ -21,27 +29,37 @@ class SubmitObserver implements ObserverInterface private $orderSender; /** - * @param \Psr\Log\LoggerInterface $logger + * @var InvoiceSender + */ + private $invoiceSender; + + /** + * @param LoggerInterface $logger * @param OrderSender $orderSender + * @param InvoiceSender $invoiceSender */ public function __construct( - \Psr\Log\LoggerInterface $logger, - OrderSender $orderSender + LoggerInterface $logger, + OrderSender $orderSender, + InvoiceSender $invoiceSender ) { $this->logger = $logger; $this->orderSender = $orderSender; + $this->invoiceSender = $invoiceSender; } /** - * @param \Magento\Framework\Event\Observer $observer + * Sends emails to customer. + * + * @param Observer $observer * * @return void */ - public function execute(\Magento\Framework\Event\Observer $observer) + public function execute(Observer $observer) { - /** @var \Magento\Quote\Model\Quote $quote */ + /** @var Quote $quote */ $quote = $observer->getEvent()->getQuote(); - /** @var \Magento\Sales\Model\Order $order */ + /** @var Order $order */ $order = $observer->getEvent()->getOrder(); /** @@ -51,6 +69,10 @@ public function execute(\Magento\Framework\Event\Observer $observer) if (!$redirectUrl && $order->getCanSendNewEmailFlag()) { try { $this->orderSender->send($order); + $invoice = current($order->getInvoiceCollection()->getItems()); + if ($invoice) { + $this->invoiceSender->send($invoice); + } } catch (\Exception $e) { $this->logger->critical($e); } diff --git a/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml b/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml index 4ec608a18a686..f7a4fda4f67d8 100644 --- a/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml +++ b/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml @@ -73,6 +73,9 @@ <requiredEntity createDataKey="createConfigProduct"/> <requiredEntity createDataKey="createConfigChildProduct2"/> </createData> + <magentoCLI command="config:set customer/online_customers/section_data_lifetime 1" + stepKey="setConfigForCartLifetime"/> + <magentoCLI command="cache:flush" stepKey="flushCache" /> </before> <after> <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> @@ -116,6 +119,7 @@ </actionGroup> <closeTab stepKey="closeTab"/> <!-- Check cart --> + <wait time="60" stepKey="waitForCartToBeUpdated"/> <reloadPage stepKey="reloadPage"/> <waitForPageLoad stepKey="waitForCheckoutPageReload"/> <click selector="{{StorefrontMiniCartSection.show}}" stepKey="clickMiniCart"/> @@ -143,6 +147,7 @@ </actionGroup> <closeTab stepKey="closeTab2"/> <!--Check cart--> + <wait time="60" stepKey="waitForCartToBeUpdated2"/> <reloadPage stepKey="reloadPage2"/> <waitForPageLoad stepKey="waitForCheckoutPageReload2"/> <click selector="{{StorefrontMiniCartSection.show}}" stepKey="clickMiniCart2"/> diff --git a/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php b/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php index c19606a7b8f5d..f06f5466df91f 100644 --- a/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php +++ b/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php @@ -5,75 +5,116 @@ */ namespace Magento\Quote\Test\Unit\Observer; +use Magento\Framework\Event; +use Magento\Framework\Event\Observer; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Payment; +use Magento\Quote\Observer\SubmitObserver; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Email\Sender\InvoiceSender; +use Magento\Sales\Model\Order\Email\Sender\OrderSender; +use Magento\Sales\Model\Order\Invoice; +use Magento\Sales\Model\ResourceModel\Order\Invoice\Collection; +use Psr\Log\LoggerInterface; + +/** + * Class SubmitObserverTest + */ class SubmitObserverTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Quote\Observer\SubmitObserver + * @var SubmitObserver */ - protected $model; + private $model; /** - * @var \Psr\Log\LoggerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var LoggerInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $loggerMock; + private $loggerMock; /** - * @var \Magento\Sales\Model\Order\Email\Sender\OrderSender|\PHPUnit_Framework_MockObject_MockObject + * @var OrderSender|\PHPUnit_Framework_MockObject_MockObject */ - protected $orderSenderMock; + private $orderSenderMock; /** - * @var \Magento\Framework\Event\Observer|\PHPUnit_Framework_MockObject_MockObject + * @var InvoiceSender|\PHPUnit_Framework_MockObject_MockObject */ - protected $observerMock; + private $invoiceSender; /** - * @var \Magento\Quote\Model\Quote|\PHPUnit_Framework_MockObject_MockObject + * @var Observer|\PHPUnit_Framework_MockObject_MockObject */ - protected $quoteMock; + private $observerMock; /** - * @var \Magento\Sales\Model\Order|\PHPUnit_Framework_MockObject_MockObject + * @var Quote|\PHPUnit_Framework_MockObject_MockObject */ - protected $orderMock; + private $quoteMock; /** - * @var \Magento\Quote\Model\Quote\Payment|\PHPUnit_Framework_MockObject_MockObject + * @var Order|\PHPUnit_Framework_MockObject_MockObject */ - protected $paymentMock; + private $orderMock; + + /** + * @var Payment|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentMock; protected function setUp() { - $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); - $this->quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); - $this->orderMock = $this->createMock(\Magento\Sales\Model\Order::class); - $this->paymentMock = $this->createMock(\Magento\Quote\Model\Quote\Payment::class); - $this->orderSenderMock = - $this->createMock(\Magento\Sales\Model\Order\Email\Sender\OrderSender::class); - $eventMock = $this->getMockBuilder(\Magento\Framework\Event::class) + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->quoteMock = $this->createMock(Quote::class); + $this->orderMock = $this->createMock(Order::class); + $this->paymentMock = $this->createMock(Payment::class); + $this->orderSenderMock = $this->createMock(OrderSender::class); + $this->invoiceSender = $this->createMock(InvoiceSender::class); + $eventMock = $this->getMockBuilder(Event::class) ->disableOriginalConstructor() ->setMethods(['getQuote', 'getOrder']) ->getMock(); - $this->observerMock = $this->createPartialMock(\Magento\Framework\Event\Observer::class, ['getEvent']); + $this->observerMock = $this->createPartialMock(Observer::class, ['getEvent']); $this->observerMock->expects($this->any())->method('getEvent')->willReturn($eventMock); $eventMock->expects($this->once())->method('getQuote')->willReturn($this->quoteMock); $eventMock->expects($this->once())->method('getOrder')->willReturn($this->orderMock); $this->quoteMock->expects($this->once())->method('getPayment')->willReturn($this->paymentMock); - $this->model = new \Magento\Quote\Observer\SubmitObserver( + $this->model = new SubmitObserver( $this->loggerMock, - $this->orderSenderMock + $this->orderSenderMock, + $this->invoiceSender ); } + /** + * Tests successful email sending. + */ public function testSendEmail() { - $this->paymentMock->expects($this->once())->method('getOrderPlaceRedirectUrl')->willReturn(''); - $this->orderMock->expects($this->once())->method('getCanSendNewEmailFlag')->willReturn(true); - $this->orderSenderMock->expects($this->once())->method('send')->willReturn(true); - $this->loggerMock->expects($this->never())->method('critical'); + $this->paymentMock->method('getOrderPlaceRedirectUrl')->willReturn(''); + $invoice = $this->createMock(Invoice::class); + $invoiceCollection = $this->createMock(Collection::class); + $invoiceCollection->method('getItems') + ->willReturn([$invoice]); + + $this->orderMock->method('getInvoiceCollection') + ->willReturn($invoiceCollection); + $this->orderMock->method('getCanSendNewEmailFlag')->willReturn(true); + $this->orderSenderMock->expects($this->once()) + ->method('send')->willReturn(true); + $this->invoiceSender->expects($this->once()) + ->method('send') + ->with($invoice) + ->willReturn(true); + $this->loggerMock->expects($this->never()) + ->method('critical'); + $this->model->execute($this->observerMock); } + /** + * Tests failing email sending. + */ public function testFailToSendEmail() { $this->paymentMock->expects($this->once())->method('getOrderPlaceRedirectUrl')->willReturn(''); @@ -85,6 +126,9 @@ public function testFailToSendEmail() $this->model->execute($this->observerMock); } + /** + * Tests send email when redirect. + */ public function testSendEmailWhenRedirectUrlExists() { $this->paymentMock->expects($this->once())->method('getOrderPlaceRedirectUrl')->willReturn(false); diff --git a/app/code/Magento/Quote/etc/db_schema.xml b/app/code/Magento/Quote/etc/db_schema.xml index b4c75fc1d21d0..d41591c619cde 100644 --- a/app/code/Magento/Quote/etc/db_schema.xml +++ b/app/code/Magento/Quote/etc/db_schema.xml @@ -11,7 +11,7 @@ <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP"/> @@ -27,7 +27,7 @@ <column xsi:type="decimal" name="items_qty" scale="4" precision="12" unsigned="false" nullable="true" default="0" comment="Items Qty"/> <column xsi:type="int" name="orig_order_id" padding="10" unsigned="true" nullable="true" identity="false" - default="0" comment="Orig Order Id"/> + default="0" comment="Orig Order ID"/> <column xsi:type="decimal" name="store_to_base_rate" scale="4" precision="12" unsigned="false" nullable="true" default="0" comment="Store To Base Rate"/> <column xsi:type="decimal" name="store_to_quote_rate" scale="4" precision="12" unsigned="false" nullable="true" @@ -43,11 +43,11 @@ default="0" comment="Base Grand Total"/> <column xsi:type="varchar" name="checkout_method" nullable="true" length="255" comment="Checkout Method"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="int" name="customer_tax_class_id" padding="10" unsigned="true" nullable="true" - identity="false" comment="Customer Tax Class Id"/> + identity="false" comment="Customer Tax Class ID"/> <column xsi:type="int" name="customer_group_id" padding="10" unsigned="true" nullable="true" identity="false" - default="0" comment="Customer Group Id"/> + default="0" comment="Customer Group ID"/> <column xsi:type="varchar" name="customer_email" nullable="true" length="255" comment="Customer Email"/> <column xsi:type="varchar" name="customer_prefix" nullable="true" length="40" comment="Customer Prefix"/> <column xsi:type="varchar" name="customer_firstname" nullable="true" length="255" comment="Customer Firstname"/> @@ -63,7 +63,7 @@ identity="false" default="0" comment="Customer Is Guest"/> <column xsi:type="varchar" name="remote_ip" nullable="true" length="45" comment="Remote Ip"/> <column xsi:type="varchar" name="applied_rule_ids" nullable="true" length="255" comment="Applied Rule Ids"/> - <column xsi:type="varchar" name="reserved_order_id" nullable="true" length="64" comment="Reserved Order Id"/> + <column xsi:type="varchar" name="reserved_order_id" nullable="true" length="64" comment="Reserved Order ID"/> <column xsi:type="varchar" name="password_hash" nullable="true" length="255" comment="Password Hash"/> <column xsi:type="varchar" name="coupon_code" nullable="true" length="255" comment="Coupon Code"/> <column xsi:type="varchar" name="global_currency_code" nullable="true" length="255" @@ -103,19 +103,19 @@ </table> <table name="quote_address" resource="checkout" engine="innodb" comment="Sales Flat Quote Address"> <column xsi:type="int" name="address_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Address Id"/> + comment="Address ID"/> <column xsi:type="int" name="quote_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Quote Id"/> + default="0" comment="Quote ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" comment="Updated At"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="smallint" name="save_in_address_book" padding="6" unsigned="false" nullable="true" identity="false" default="0" comment="Save In Address Book"/> <column xsi:type="int" name="customer_address_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Address Id"/> + comment="Customer Address ID"/> <column xsi:type="varchar" name="address_type" nullable="true" length="10" comment="Address Type"/> <column xsi:type="varchar" name="email" nullable="true" length="255" comment="Email"/> <column xsi:type="varchar" name="prefix" nullable="true" length="40" comment="Prefix"/> @@ -129,9 +129,9 @@ <column xsi:type="varchar" name="city" nullable="true" length="255"/> <column xsi:type="varchar" name="region" nullable="true" length="255"/> <column xsi:type="int" name="region_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Region Id"/> + comment="Region ID"/> <column xsi:type="varchar" name="postcode" nullable="true" length="20" comment="Postcode"/> - <column xsi:type="varchar" name="country_id" nullable="true" length="30" comment="Country Id"/> + <column xsi:type="varchar" name="country_id" nullable="true" length="30" comment="Country ID"/> <column xsi:type="varchar" name="telephone" nullable="true" length="255"/> <column xsi:type="varchar" name="fax" nullable="true" length="255"/> <column xsi:type="smallint" name="same_as_billing" padding="5" unsigned="true" nullable="false" identity="false" @@ -195,10 +195,10 @@ comment="Shipping Incl Tax"/> <column xsi:type="decimal" name="base_shipping_incl_tax" scale="4" precision="20" unsigned="false" nullable="true" comment="Base Shipping Incl Tax"/> - <column xsi:type="text" name="vat_id" nullable="true" comment="Vat Id"/> + <column xsi:type="text" name="vat_id" nullable="true" comment="Vat ID"/> <column xsi:type="smallint" name="vat_is_valid" padding="6" unsigned="false" nullable="true" identity="false" comment="Vat Is Valid"/> - <column xsi:type="text" name="vat_request_id" nullable="true" comment="Vat Request Id"/> + <column xsi:type="text" name="vat_request_id" nullable="true" comment="Vat Request ID"/> <column xsi:type="text" name="vat_request_date" nullable="true" comment="Vat Request Date"/> <column xsi:type="smallint" name="vat_request_success" padding="6" unsigned="false" nullable="true" identity="false" comment="Vat Request Success"/> @@ -215,19 +215,19 @@ </table> <table name="quote_item" resource="checkout" engine="innodb" comment="Sales Flat Quote Item"> <column xsi:type="int" name="item_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Item Id"/> + comment="Item ID"/> <column xsi:type="int" name="quote_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Quote Id"/> + default="0" comment="Quote ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" comment="Updated At"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="parent_item_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Parent Item Id"/> + comment="Parent Item ID"/> <column xsi:type="smallint" name="is_virtual" padding="5" unsigned="true" nullable="true" identity="false" comment="Is Virtual"/> <column xsi:type="varchar" name="sku" nullable="true" length="255" comment="Sku"/> @@ -315,13 +315,13 @@ </table> <table name="quote_address_item" resource="checkout" engine="innodb" comment="Sales Flat Quote Address Item"> <column xsi:type="int" name="address_item_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Address Item Id"/> + comment="Address Item ID"/> <column xsi:type="int" name="parent_item_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Parent Item Id"/> + comment="Parent Item ID"/> <column xsi:type="int" name="quote_address_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Quote Address Id"/> + default="0" comment="Quote Address ID"/> <column xsi:type="int" name="quote_item_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Quote Item Id"/> + default="0" comment="Quote Item ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" @@ -349,13 +349,13 @@ <column xsi:type="decimal" name="row_weight" scale="4" precision="12" unsigned="false" nullable="true" default="0" comment="Row Weight"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="int" name="super_product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Super Product Id"/> + comment="Super Product ID"/> <column xsi:type="int" name="parent_product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Parent Product Id"/> + comment="Parent Product ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="sku" nullable="true" length="255" comment="Sku"/> <column xsi:type="varchar" name="image" nullable="true" length="255" comment="Image"/> <column xsi:type="varchar" name="name" nullable="true" length="255" comment="Name"/> @@ -413,11 +413,11 @@ </table> <table name="quote_item_option" resource="checkout" engine="innodb" comment="Sales Flat Quote Item Option"> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Option Id"/> + comment="Option ID"/> <column xsi:type="int" name="item_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Item Id"/> + comment="Item ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="code" nullable="false" length="255" comment="Code"/> <column xsi:type="text" name="value" nullable="true" comment="Value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -431,9 +431,9 @@ </table> <table name="quote_payment" resource="checkout" engine="innodb" comment="Sales Flat Quote Payment"> <column xsi:type="int" name="payment_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Payment Id"/> + comment="Payment ID"/> <column xsi:type="int" name="quote_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Quote Id"/> + default="0" comment="Quote ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" @@ -467,9 +467,9 @@ </table> <table name="quote_shipping_rate" resource="checkout" engine="innodb" comment="Sales Flat Quote Shipping Rate"> <column xsi:type="int" name="rate_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Rate Id"/> + comment="Rate ID"/> <column xsi:type="int" name="address_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Address Id"/> + default="0" comment="Address ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/DiscountAggregator.php b/app/code/Magento/QuoteGraphQl/Model/Cart/DiscountAggregator.php new file mode 100644 index 0000000000000..a620b4b2610cf --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/DiscountAggregator.php @@ -0,0 +1,48 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteGraphQl\Model\Cart; + +use Magento\Quote\Model\Quote; + +/** + * Aggregate cart level discounts + * + * @package Magento\QuoteGraphQl\Model\Cart + */ +class DiscountAggregator +{ + /** + * Aggregate Discount per rule + * + * @param Quote $quote + * @return array + */ + public function aggregateDiscountPerRule( + Quote $quote + ) { + $items = $quote->getItems(); + $discountPerRule = []; + foreach ($items as $item) { + $discountBreakdown = $item->getExtensionAttributes()->getDiscounts(); + if ($discountBreakdown) { + foreach ($discountBreakdown as $key => $value) { + /* @var \Magento\SalesRule\Model\Rule\Action\Discount\Data $discount */ + $discount = $value['discount']; + $rule = $value['rule']; + if (isset($discountPerRule[$key])) { + $discountPerRule[$key]['discount'] += $discount->getAmount(); + } else { + $discountPerRule[$key]['discount'] = $discount->getAmount(); + } + $discountPerRule[$key]['rule'] = $rule; + } + } + } + return $discountPerRule; + } +} diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/AppliedCoupons.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/AppliedCoupons.php new file mode 100644 index 0000000000000..fa232f4d9cd6c --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/AppliedCoupons.php @@ -0,0 +1,52 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteGraphQl\Model\Resolver; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Quote\Api\CouponManagementInterface; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; + +/** + * @inheritdoc + */ +class AppliedCoupons implements ResolverInterface +{ + /** + * @var CouponManagementInterface + */ + private $couponManagement; + + /** + * @param CouponManagementInterface $couponManagement + */ + public function __construct( + CouponManagementInterface $couponManagement + ) { + $this->couponManagement = $couponManagement; + } + + /** + * @inheritdoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!isset($value['model'])) { + throw new LocalizedException(__('"model" value should be specified')); + } + $cart = $value['model']; + $cartId = $cart->getId(); + $appliedCoupons = []; + $appliedCoupon = $this->couponManagement->get($cartId); + if ($appliedCoupon) { + $appliedCoupons[] = [ 'code' => $appliedCoupon ]; + } + return !empty($appliedCoupons) ? $appliedCoupons : null; + } +} diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php index a591c74e78db3..b66327ac1dbba 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php @@ -55,7 +55,6 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value // But the totals should be calculated even if no address is set $this->totals = $this->totalsCollector->collectQuoteTotals($cartItem->getQuote()); } - $currencyCode = $cartItem->getQuote()->getQuoteCurrencyCode(); return [ @@ -71,6 +70,41 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value 'currency' => $currencyCode, 'value' => $cartItem->getRowTotalInclTax(), ], + 'total_item_discount' => [ + 'currency' => $currencyCode, + 'value' => $cartItem->getDiscountAmount(), + ], + 'discounts' => $this->getDiscountValues($cartItem, $currencyCode) ]; } + + /** + * Get Discount Values + * + * @param Item $cartItem + * @param string $currencyCode + * @return array + */ + private function getDiscountValues($cartItem, $currencyCode) + { + $itemDiscounts = $cartItem->getExtensionAttributes()->getDiscounts(); + if ($itemDiscounts) { + $discountValues=[]; + foreach ($itemDiscounts as $value) { + $discount = []; + $amount = []; + /* @var \Magento\SalesRule\Model\Rule\Action\Discount\Data $discountData */ + $discountData = $value['discount']; + /* @var \Magento\SalesRule\Model\Rule $rule */ + $rule = $value['rule']; + $discount['label'] = $rule->getStoreLabel($cartItem->getQuote()->getStore()) ?: __('Discount'); + $amount['value'] = $discountData->getAmount(); + $amount['currency'] = $currencyCode; + $discount['amount'] = $amount; + $discountValues[] = $discount; + } + return $discountValues; + } + return null; + } } diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/Discounts.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/Discounts.php new file mode 100644 index 0000000000000..2c3f6d0e69f4a --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/Discounts.php @@ -0,0 +1,75 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteGraphQl\Model\Resolver; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Quote\Model\Quote; +use Magento\QuoteGraphQl\Model\Cart\DiscountAggregator; + +/** + * @inheritdoc + */ +class Discounts implements ResolverInterface +{ + /** + * @var DiscountAggregator + */ + private $discountAggregator; + + /** + * @param DiscountAggregator|null $discountAggregator + */ + public function __construct( + DiscountAggregator $discountAggregator + ) { + $this->discountAggregator = $discountAggregator; + } + + /** + * @inheritdoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!isset($value['model'])) { + throw new LocalizedException(__('"model" value should be specified')); + } + $quote = $value['model']; + + return $this->getDiscountValues($quote); + } + + /** + * Get Discount Values + * + * @param Quote $quote + * @return array + */ + private function getDiscountValues(Quote $quote) + { + $discountValues=[]; + $totalDiscounts = $this->discountAggregator->aggregateDiscountPerRule($quote); + if ($totalDiscounts) { + foreach ($totalDiscounts as $value) { + $discount = []; + $amount = []; + /* @var \Magento\SalesRule\Model\Rule $rule*/ + $rule = $value['rule']; + $discount['label'] = $rule->getStoreLabel($quote->getStore()) ?: __('Discount'); + $amount['value'] = $value['discount']; + $amount['currency'] = $quote->getQuoteCurrencyCode(); + $discount['amount'] = $amount; + $discountValues[] = $discount; + } + return $discountValues; + } + return null; + } +} diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddress/SelectedShippingMethod.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddress/SelectedShippingMethod.php index c6f25dd78823b..429fda816efd3 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddress/SelectedShippingMethod.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddress/SelectedShippingMethod.php @@ -31,36 +31,37 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value /** @var Address $address */ $address = $value['model']; $rates = $address->getAllShippingRates(); - $carrierTitle = null; - $methodTitle = null; + $carrierTitle = ''; + $methodTitle = ''; - if (count($rates) > 0 && !empty($address->getShippingMethod())) { - list($carrierCode, $methodCode) = explode('_', $address->getShippingMethod(), 2); + if (!count($rates) || empty($address->getShippingMethod())) { + return null; + } - /** @var Rate $rate */ - foreach ($rates as $rate) { - if ($rate->getCode() == $address->getShippingMethod()) { - $carrierTitle = $rate->getCarrierTitle(); - $methodTitle = $rate->getMethodTitle(); - break; - } - } + list($carrierCode, $methodCode) = explode('_', $address->getShippingMethod(), 2); - $data = [ - 'carrier_code' => $carrierCode, - 'method_code' => $methodCode, - 'carrier_title' => $carrierTitle, - 'method_title' => $methodTitle, - 'amount' => [ - 'value' => $address->getShippingAmount(), - 'currency' => $address->getQuote()->getQuoteCurrencyCode(), - ], - /** @deprecated The field should not be used on the storefront */ - 'base_amount' => null, - ]; - } else { - $data = null; + /** @var Rate $rate */ + foreach ($rates as $rate) { + if ($rate->getCode() == $address->getShippingMethod()) { + $carrierTitle = $rate->getCarrierTitle(); + $methodTitle = $rate->getMethodTitle(); + break; + } } + + $data = [ + 'carrier_code' => $carrierCode, + 'method_code' => $methodCode, + 'carrier_title' => $carrierTitle, + 'method_title' => $methodTitle, + 'amount' => [ + 'value' => $address->getShippingAmount(), + 'currency' => $address->getQuote()->getQuoteCurrencyCode(), + ], + /** @deprecated The field should not be used on the storefront */ + 'base_amount' => null, + ]; + return $data; } } diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls index 075d2f8147340..2edb3f1e196ab 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls @@ -150,9 +150,10 @@ type CartPrices { grand_total: Money subtotal_including_tax: Money subtotal_excluding_tax: Money - discount: CartDiscount + discount: CartDiscount @deprecated(reason: "Use discounts instead ") subtotal_with_discount_excluding_tax: Money applied_taxes: [CartTaxItem] + discounts: [Discount] @doc(description:"An array of applied discounts") @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\Discounts") } type CartTaxItem { @@ -191,7 +192,8 @@ type PlaceOrderOutput { type Cart { items: [CartItemInterface] @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\CartItems") - applied_coupon: AppliedCoupon @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\AppliedCoupon") + applied_coupon: AppliedCoupon @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\AppliedCoupon") @doc(description:"An array of coupons that have been applied to the cart") @deprecated(reason: "Use applied_coupons instead ") + applied_coupons: [AppliedCoupon] @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\AppliedCoupons") @doc(description:"An array of `AppliedCoupon` objects. Each object contains the `code` text attribute, which specifies the coupon code") email: String @resolver (class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\CartEmail") shipping_addresses: [ShippingCartAddress]! @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\ShippingAddresses") billing_address: BillingCartAddress! @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\BillingAddress") @@ -242,11 +244,11 @@ type CartAddressCountry { } type SelectedShippingMethod { - carrier_code: String - method_code: String - carrier_title: String - method_title: String - amount: Money + carrier_code: String! + method_code: String! + carrier_title: String! + method_title: String! + amount: Money! base_amount: Money @deprecated(reason: "The field should not be used on the storefront") } @@ -321,10 +323,17 @@ interface CartItemInterface @typeResolver(class: "Magento\\QuoteGraphQl\\Model\\ product: ProductInterface! } +type Discount @doc(description:"Defines an individual discount. A discount can be applied to the cart as a whole or to an item.") { + amount: Money! @doc(description:"The amount of the discount") + label: String! @doc(description:"A description of the discount") +} + type CartItemPrices { price: Money! row_total: Money! row_total_including_tax: Money! + discounts: [Discount] @doc(description:"An array of discounts to be applied to the cart item") + total_item_discount: Money @doc(description:"The total of all discounts applied to the item") } type SelectedCustomizableOption { diff --git a/app/code/Magento/ReleaseNotification/Test/Unit/Model/Condition/CanViewNotificationTest.php b/app/code/Magento/ReleaseNotification/Test/Unit/Model/Condition/CanViewNotificationTest.php index 813c5f28bf4d9..a7f619863af56 100644 --- a/app/code/Magento/ReleaseNotification/Test/Unit/Model/Condition/CanViewNotificationTest.php +++ b/app/code/Magento/ReleaseNotification/Test/Unit/Model/Condition/CanViewNotificationTest.php @@ -12,6 +12,7 @@ use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Backend\Model\Auth\Session; use Magento\Framework\App\CacheInterface; +use Magento\User\Model\User; /** * Class CanViewNotificationTest @@ -36,6 +37,11 @@ class CanViewNotificationTest extends \PHPUnit\Framework\TestCase /** @var $cacheStorageMock \PHPUnit_Framework_MockObject_MockObject|CacheInterface */ private $cacheStorageMock; + /** + * @var User|\PHPUnit_Framework_MockObject_MockObject + */ + private $userMock; + public function setUp() { $this->cacheStorageMock = $this->getMockBuilder(CacheInterface::class) @@ -44,7 +50,6 @@ public function setUp() ->getMock(); $this->sessionMock = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() - ->setMethods(['getUser', 'getId']) ->getMock(); $this->viewerLoggerMock = $this->getMockBuilder(Logger::class) ->disableOriginalConstructor() @@ -52,6 +57,7 @@ public function setUp() $this->productMetadataMock = $this->getMockBuilder(ProductMetadataInterface::class) ->disableOriginalConstructor() ->getMock(); + $this->userMock = $this->createMock(User::class); $objectManager = new ObjectManager($this); $this->canViewNotification = $objectManager->getObject( CanViewNotification::class, @@ -68,8 +74,8 @@ public function testIsVisibleLoadDataFromCache() { $this->sessionMock->expects($this->once()) ->method('getUser') - ->willReturn($this->sessionMock); - $this->sessionMock->expects($this->once()) + ->willReturn($this->userMock); + $this->userMock->expects($this->once()) ->method('getId') ->willReturn(1); $this->cacheStorageMock->expects($this->once()) @@ -93,8 +99,8 @@ public function testIsVisible($expected, $version, $lastViewVersion) ->willReturn(false); $this->sessionMock->expects($this->once()) ->method('getUser') - ->willReturn($this->sessionMock); - $this->sessionMock->expects($this->once()) + ->willReturn($this->userMock); + $this->userMock->expects($this->once()) ->method('getId') ->willReturn(1); $this->productMetadataMock->expects($this->once()) diff --git a/app/code/Magento/Reports/etc/db_schema.xml b/app/code/Magento/Reports/etc/db_schema.xml index 1321ebba4d3d6..30accf36a053e 100644 --- a/app/code/Magento/Reports/etc/db_schema.xml +++ b/app/code/Magento/Reports/etc/db_schema.xml @@ -10,15 +10,15 @@ <table name="report_compared_product_index" resource="default" engine="innodb" comment="Reports Compared Product Index Table"> <column xsi:type="bigint" name="index_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Index Id"/> + comment="Index ID"/> <column xsi:type="int" name="visitor_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Visitor Id"/> + comment="Visitor ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="timestamp" name="added_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Added At"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -54,15 +54,15 @@ <table name="report_viewed_product_index" resource="default" engine="innodb" comment="Reports Viewed Product Index Table"> <column xsi:type="bigint" name="index_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Index Id"/> + comment="Index ID"/> <column xsi:type="int" name="visitor_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Visitor Id"/> + comment="Visitor ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="timestamp" name="added_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Added At"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -97,7 +97,7 @@ </table> <table name="report_event_types" resource="default" engine="innodb" comment="Reports Event Type Table"> <column xsi:type="smallint" name="event_type_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Event Type Id"/> + comment="Event Type ID"/> <column xsi:type="varchar" name="event_name" nullable="false" length="64" comment="Event Name"/> <column xsi:type="smallint" name="customer_login" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Customer Login"/> @@ -107,19 +107,19 @@ </table> <table name="report_event" resource="default" engine="innodb" comment="Reports Event Table"> <column xsi:type="bigint" name="event_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Event Id"/> + comment="Event ID"/> <column xsi:type="timestamp" name="logged_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Logged At"/> <column xsi:type="smallint" name="event_type_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Event Type Id"/> + default="0" comment="Event Type ID"/> <column xsi:type="int" name="object_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Object Id"/> + default="0" comment="Object ID"/> <column xsi:type="int" name="subject_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Subject Id"/> + default="0" comment="Subject ID"/> <column xsi:type="smallint" name="subtype" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Subtype"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="event_id"/> </constraint> @@ -146,12 +146,12 @@ </table> <table name="report_viewed_product_aggregated_daily" resource="default" engine="innodb" comment="Most Viewed Products Aggregated Daily"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="product_name" nullable="true" length="255" comment="Product Name"/> <column xsi:type="decimal" name="product_price" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Product Price"/> @@ -182,12 +182,12 @@ </table> <table name="report_viewed_product_aggregated_monthly" resource="default" engine="innodb" comment="Most Viewed Products Aggregated Monthly"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="product_name" nullable="true" length="255" comment="Product Name"/> <column xsi:type="decimal" name="product_price" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Product Price"/> @@ -218,12 +218,12 @@ </table> <table name="report_viewed_product_aggregated_yearly" resource="default" engine="innodb" comment="Most Viewed Products Aggregated Yearly"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="product_name" nullable="true" length="255" comment="Product Name"/> <column xsi:type="decimal" name="product_price" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Product Price"/> diff --git a/app/code/Magento/Review/Model/Rss.php b/app/code/Magento/Review/Model/Rss.php index df8a5dbb96841..f5abdbb4d3c9e 100644 --- a/app/code/Magento/Review/Model/Rss.php +++ b/app/code/Magento/Review/Model/Rss.php @@ -3,11 +3,17 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Review\Model; +use Magento\Framework\App\ObjectManager; + /** - * Class Rss - * @package Magento\Catalog\Model\Rss\Product + * Model Rss + * + * Class \Magento\Catalog\Model\Rss\Product\Rss */ class Rss extends \Magento\Framework\Model\AbstractModel { @@ -24,18 +30,35 @@ class Rss extends \Magento\Framework\Model\AbstractModel protected $eventManager; /** + * Rss constructor. + * * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param ReviewFactory $reviewFactory + * @param \Magento\Framework\Model\Context|null $context + * @param \Magento\Framework\Registry|null $registry + * @param \Magento\Framework\Model\ResourceModel\AbstractResource|null $resource + * @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection + * @param array $data */ public function __construct( \Magento\Framework\Event\ManagerInterface $eventManager, - \Magento\Review\Model\ReviewFactory $reviewFactory + \Magento\Review\Model\ReviewFactory $reviewFactory, + \Magento\Framework\Model\Context $context = null, + \Magento\Framework\Registry $registry = null, + \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, + \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, + array $data = [] ) { $this->reviewFactory = $reviewFactory; $this->eventManager = $eventManager; + $context = $context ?? ObjectManager::getInstance()->get(\Magento\Framework\Model\Context::class); + $registry = $registry ?? ObjectManager::getInstance()->get(\Magento\Framework\Registry::class); + parent::__construct($context, $registry, $resource, $resourceCollection, $data); } /** + * Get Product Collection + * * @return $this|\Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection */ public function getProductCollection() diff --git a/app/code/Magento/Review/etc/db_schema.xml b/app/code/Magento/Review/etc/db_schema.xml index d1090d413384b..7a451dbbbcf98 100644 --- a/app/code/Magento/Review/etc/db_schema.xml +++ b/app/code/Magento/Review/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="review_entity" resource="default" engine="innodb" comment="Review entities"> <column xsi:type="smallint" name="entity_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Review entity id"/> + comment="Review entity ID"/> <column xsi:type="varchar" name="entity_code" nullable="false" length="32" comment="Review entity code"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="entity_id"/> @@ -17,7 +17,7 @@ </table> <table name="review_status" resource="default" engine="innodb" comment="Review statuses"> <column xsi:type="smallint" name="status_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Status id"/> + comment="Status ID"/> <column xsi:type="varchar" name="status_code" nullable="false" length="32" comment="Status code"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="status_id"/> @@ -25,13 +25,13 @@ </table> <table name="review" resource="default" engine="innodb" comment="Review base information"> <column xsi:type="bigint" name="review_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Review id"/> + comment="Review ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Review create date"/> <column xsi:type="smallint" name="entity_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Entity id"/> + default="0" comment="Entity ID"/> <column xsi:type="int" name="entity_pk_value" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Product id"/> + default="0" comment="Product ID"/> <column xsi:type="smallint" name="status_id" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Status code"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -53,16 +53,16 @@ </table> <table name="review_detail" resource="default" engine="innodb" comment="Review detail information"> <column xsi:type="bigint" name="detail_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Review detail id"/> + comment="Review detail ID"/> <column xsi:type="bigint" name="review_id" padding="20" unsigned="true" nullable="false" identity="false" - default="0" comment="Review id"/> + default="0" comment="Review ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - default="0" comment="Store id"/> + default="0" comment="Store ID"/> <column xsi:type="varchar" name="title" nullable="false" length="255" comment="Title"/> <column xsi:type="text" name="detail" nullable="false" comment="Detail description"/> <column xsi:type="varchar" name="nickname" nullable="false" length="128" comment="User nickname"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="detail_id"/> </constraint> @@ -85,11 +85,11 @@ </table> <table name="review_entity_summary" resource="default" engine="innodb" comment="Review aggregates"> <column xsi:type="bigint" name="primary_id" padding="20" unsigned="false" nullable="false" identity="true" - comment="Summary review entity id"/> + comment="Summary review entity ID"/> <column xsi:type="bigint" name="entity_pk_value" padding="20" unsigned="false" nullable="false" identity="false" - default="0" comment="Product id"/> + default="0" comment="Product ID"/> <column xsi:type="smallint" name="entity_type" padding="6" unsigned="false" nullable="false" identity="false" - default="0" comment="Entity type id"/> + default="0" comment="Entity type ID"/> <column xsi:type="smallint" name="reviews_count" padding="6" unsigned="false" nullable="false" identity="false" default="0" comment="Qty of reviews"/> <column xsi:type="smallint" name="rating_summary" padding="6" unsigned="false" nullable="false" identity="false" @@ -158,9 +158,9 @@ </table> <table name="rating_option" resource="default" engine="innodb" comment="Rating options"> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Rating Option Id"/> + comment="Rating Option ID"/> <column xsi:type="smallint" name="rating_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Rating Id"/> + default="0" comment="Rating ID"/> <column xsi:type="varchar" name="code" nullable="false" length="32" comment="Rating Option Code"/> <column xsi:type="smallint" name="value" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Rating Option Value"/> @@ -177,20 +177,20 @@ </table> <table name="rating_option_vote" resource="default" engine="innodb" comment="Rating option values"> <column xsi:type="bigint" name="vote_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Vote id"/> + comment="Vote ID"/> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Vote option id"/> + default="0" comment="Vote option ID"/> <column xsi:type="varchar" name="remote_ip" nullable="false" length="16" comment="Customer IP"/> <column xsi:type="bigint" name="remote_ip_long" padding="20" unsigned="false" nullable="false" identity="false" default="0" comment="Customer IP converted to long integer format"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - default="0" comment="Customer Id"/> + default="0" comment="Customer ID"/> <column xsi:type="bigint" name="entity_pk_value" padding="20" unsigned="true" nullable="false" identity="false" - default="0" comment="Product id"/> + default="0" comment="Product ID"/> <column xsi:type="smallint" name="rating_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Rating id"/> + default="0" comment="Rating ID"/> <column xsi:type="bigint" name="review_id" padding="20" unsigned="true" nullable="true" identity="false" - comment="Review id"/> + comment="Review ID"/> <column xsi:type="smallint" name="percent" padding="6" unsigned="false" nullable="false" identity="false" default="0" comment="Percent amount"/> <column xsi:type="smallint" name="value" padding="6" unsigned="false" nullable="false" identity="false" @@ -209,11 +209,11 @@ </table> <table name="rating_option_vote_aggregated" resource="default" engine="innodb" comment="Rating vote aggregated"> <column xsi:type="int" name="primary_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Vote aggregation id"/> + comment="Vote aggregation ID"/> <column xsi:type="smallint" name="rating_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Rating id"/> + default="0" comment="Rating ID"/> <column xsi:type="bigint" name="entity_pk_value" padding="20" unsigned="true" nullable="false" identity="false" - default="0" comment="Product id"/> + default="0" comment="Product ID"/> <column xsi:type="int" name="vote_count" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Vote dty"/> <column xsi:type="int" name="vote_value_sum" padding="10" unsigned="true" nullable="false" identity="false" @@ -223,7 +223,7 @@ <column xsi:type="smallint" name="percent_approved" padding="6" unsigned="false" nullable="true" identity="false" default="0" comment="Vote percent approved by admin"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="primary_id"/> </constraint> @@ -242,9 +242,9 @@ </table> <table name="rating_store" resource="default" engine="innodb" comment="Rating Store"> <column xsi:type="smallint" name="rating_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Rating id"/> + default="0" comment="Rating ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store id"/> + default="0" comment="Store ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rating_id"/> <column name="store_id"/> @@ -259,9 +259,9 @@ </table> <table name="rating_title" resource="default" engine="innodb" comment="Rating Title"> <column xsi:type="smallint" name="rating_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Rating Id"/> + default="0" comment="Rating ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="varchar" name="value" nullable="false" length="255" comment="Rating Label"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rating_id"/> diff --git a/app/code/Magento/Sales/Cron/CleanExpiredQuotes.php b/app/code/Magento/Sales/Cron/CleanExpiredQuotes.php index 021e7b66cd13f..a5c7f71df66c5 100644 --- a/app/code/Magento/Sales/Cron/CleanExpiredQuotes.php +++ b/app/code/Magento/Sales/Cron/CleanExpiredQuotes.php @@ -57,7 +57,6 @@ public function execute() $quotes->addFieldToFilter('store_id', $storeId); $quotes->addFieldToFilter('updated_at', ['to' => date("Y-m-d", time() - $lifetime)]); - $quotes->addFieldToFilter('is_active', 0); foreach ($this->getExpireQuotesAdditionalFilterFields() as $field => $condition) { $quotes->addFieldToFilter($field, $condition); diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminAssertProductInShoppingCartSectionActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminAssertProductInShoppingCartSectionActionGroup.xml new file mode 100644 index 0000000000000..4fd992418887a --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminAssertProductInShoppingCartSectionActionGroup.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAssertProductInShoppingCartSectionActionGroup"> + <annotations> + <description>Assert product in Shopping cart section in Customer's Activities block on Create Order Page.</description> + </annotations> + <arguments> + <argument name="product" type="string"/> + </arguments> + + <see selector="{{AdminCreateOrderShoppingCartSection.shoppingCartBlock}}" userInput="{{product}}" stepKey="seeProductInShoppingCart"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminMoveProductToItemsOrderedFromShoppingCartActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminMoveProductToItemsOrderedFromShoppingCartActionGroup.xml new file mode 100644 index 0000000000000..8512387d45e8a --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminMoveProductToItemsOrderedFromShoppingCartActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminMoveProductToItemsOrderedFromShoppingCartActionGroup"> + <annotations> + <description>Move product to the "Items Ordered" section from shopping cart.</description> + </annotations> + <arguments> + <argument name="product" type="string"/> + </arguments> + + <waitForElementVisible selector="{{AdminCreateOrderShoppingCartSection.addToOrderCheckBox(product)}}" stepKey="waitForAddToOrderCheckBox"/> + <click selector="{{AdminCreateOrderShoppingCartSection.addToOrderCheckBox(product)}}" stepKey="selectProduct"/> + <click selector="{{AdminCustomerCreateNewOrderSection.updateChangesBtn}}" stepKey="clickOnUpdateButton"/> + <waitForPageLoad stepKey="waitForAdminCreateOrderShoppingCartSectionPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontCustomerReorderActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontCustomerReorderActionGroup.xml new file mode 100644 index 0000000000000..03a14ca514091 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontCustomerReorderActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontCustomerReorderActionGroup"> + <annotations> + <description>Navigate to customer dashboard -> orders. Press 'reorder' button for specified order id. Notice: customer should be logged in.</description> + </annotations> + <arguments> + <argument name="orderNumber" type="string"/> + </arguments> + <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="goToCustomerDashboardPage"/> + <waitForPageLoad stepKey="waitForCustomerDashboardPageLoad"/> + <click selector="{{StorefrontCustomerSidebarSection.sidebarTab('My Orders')}}" stepKey="navigateToOrders"/> + <waitForPageLoad stepKey="waitForOrdersPageLoad"/> + <click selector="{{StorefrontCustomerOrdersGridSection.reorderBtn(orderNumber)}}" stepKey="clickReorderBtn"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormTotalSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormTotalSection.xml index 6f62ce199ecbb..f57b1f65eb94e 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormTotalSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormTotalSection.xml @@ -11,6 +11,6 @@ <section name="AdminOrderFormTotalSection"> <element name="subtotalRow" type="text" selector="#order-totals>table tr.row-totals:nth-of-type({{row}}) span.price" parameterized="true"/> <element name="total" type="text" selector="//tr[contains(@class,'row-totals')]/td[contains(text(), '{{total}}')]/following-sibling::td/span[contains(@class, 'price')]" parameterized="true"/> - <element name="grandTotal" type="text" selector="#order-totals>table tr.row-totals:nth-of-type(3) span.price"/> + <element name="grandTotal" type="text" selector="//tr[contains(@class,'row-totals')]/td/strong[contains(text(), 'Grand Total')]/parent::td/following-sibling::td//span[contains(@class, 'price')]"/> </section> -</sections> \ No newline at end of file +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/StorefrontCustomerOrdersGridSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontCustomerOrdersGridSection.xml index 415bac7fd051d..c0deb9ab55d2b 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/StorefrontCustomerOrdersGridSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontCustomerOrdersGridSection.xml @@ -10,5 +10,6 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="StorefrontCustomerOrdersGridSection"> <element name="orderView" type="button" selector="//td[text()='{{orderNumber}}']/following-sibling::td[@class='col actions']/a[contains(@class, 'view')]" parameterized="true" /> + <element name="reorderBtn" type="button" selector="//td[text()='{{orderNumber}}']/following-sibling::td[@class='col actions']/a[contains(@class, 'order')]" parameterized="true" /> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminProductInTheShoppingCartCouldBeReachedByAdminDuringOrderCreationWithMultiWebsiteConfigTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminProductInTheShoppingCartCouldBeReachedByAdminDuringOrderCreationWithMultiWebsiteConfigTest.xml new file mode 100644 index 0000000000000..b40e9d041a10e --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminProductInTheShoppingCartCouldBeReachedByAdminDuringOrderCreationWithMultiWebsiteConfigTest.xml @@ -0,0 +1,107 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminProductInTheShoppingCartCouldBeReachedByAdminDuringOrderCreationWithMultiWebsiteConfigTest"> + <annotations> + <stories value="Admin create order"/> + <title value="Product in the shopping cart could be reached by admin during order creation with multi website config"/> + <description value="Product in the shopping cart could be reached by admin during order creation with multi website config"/> + <severity value="MAJOR"/> + <testCaseId value="MC-6353"/> + <group value="sales"/> + <skip> + <issueId value="MC-20129"/> + </skip> + </annotations> + <before> + <magentoCLI command="config:set {{StorefrontEnableAddStoreCodeToUrls.path}} {{StorefrontEnableAddStoreCodeToUrls.value}}" stepKey="addStoreCodeToUrlEnable"/> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminCreateWebsiteActionGroup" stepKey="createWebsite"> + <argument name="newWebsiteName" value="{{customWebsite.name}}"/> + <argument name="websiteCode" value="{{customWebsite.code}}"/> + </actionGroup> + <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="createNewStore"> + <argument name="website" value="{{customWebsite.name}}"/> + <argument name="storeGroupName" value="{{customStoreGroup.name}}"/> + <argument name="storeGroupCode" value="{{customStoreGroup.code}}"/> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createCustomStoreView"> + <argument name="StoreGroup" value="customStoreGroup"/> + <argument name="customStore" value="customStore"/> + </actionGroup> + <actionGroup ref="goToProductPageViaID" stepKey="goToProductEditPage"> + <argument name="productId" value="$$createProduct.id$$"/> + </actionGroup> + <actionGroup ref="ProductSetWebsite" stepKey="assignProductToSecondWebsite"> + <argument name="website" value="{{customWebsite.name}}"/> + </actionGroup> + </before> + <after> + <magentoCLI command="config:set {{StorefrontDisableAddStoreCodeToUrls.path}} {{StorefrontDisableAddStoreCodeToUrls.value}}" stepKey="addStoreCodeToUrlDisable"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminDeleteCustomerActionGroup" stepKey="deleteCustomer"> + <argument name="customerEmail" value="Simple_US_Customer.email"/> + </actionGroup> + <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteWebsite"> + <argument name="websiteName" value="{{customWebsite.name}}"/> + </actionGroup> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Create customer account for Second Website--> + <actionGroup ref="StorefrontOpenCustomerAccountCreatePageUsingStoreCodeInUrlActionGroup" stepKey="goToCreateCustomerPage"/> + <actionGroup ref="StorefrontFillCustomerAccountCreationFormActionGroup" stepKey="fillCreateAccountForm"> + <argument name="customer" value="Simple_US_Customer"/> + </actionGroup> + <actionGroup ref="StorefrontClickCreateAnAccountCustomerAccountCreationFormActionGroup" stepKey="submitCreateAccountForm"/> + <actionGroup ref="AssertMessageCustomerCreateAccountActionGroup" stepKey="assertSuccessMessage"> + <argument name="message" value="Thank you for registering with {{customStoreGroup.name}}." /> + </actionGroup> + + <!--Open product page and add to cart--> + <actionGroup ref="StorefrontOpenProductPageUsingStoreCodeInUrlActionGroup" stepKey="openProductPageUsingStoreCodeInUrl"> + <argument name="product" value="$$createProduct$$"/> + <argument name="storeView" value="customStore"/> + </actionGroup> + <actionGroup ref="StorefrontAddToTheCartActionGroup" stepKey="addProductToCart"/> + + <!--Create new order for existing Customer And Store--> + <actionGroup ref="navigateToNewOrderPageExistingCustomer" stepKey="createNewOrder"> + <argument name="customer" value="Simple_US_Customer"/> + <argument name="storeView" value="customStore"/> + </actionGroup> + + <!--Assert product in Shopping cart section--> + <actionGroup ref="AdminAssertProductInShoppingCartSectionActionGroup" stepKey="seeProductInShoppingCart"> + <argument name="product" value="$$createProduct.name$$"/> + </actionGroup> + + <!--Move product to the order from shopping cart--> + <actionGroup ref="AdminMoveProductToItemsOrderedFromShoppingCartActionGroup" stepKey="addProductToItemsOrderedFromShoppingCart"> + <argument name="product" value="$$createProduct.name$$"/> + </actionGroup> + + <!--Fill customer address information--> + <actionGroup ref="fillOrderCustomerInformation" stepKey="fillCustomerAddress"> + <argument name="customer" value="Simple_US_Customer"/> + <argument name="address" value="US_Address_TX"/> + </actionGroup> + + <!--Select shipping method--> + <actionGroup ref="orderSelectFlatRateShipping" stepKey="selectFlatRateShipping"/> + + <!--Checkout select Check/Money Order payment--> + <actionGroup ref="SelectCheckMoneyPaymentMethod" stepKey="selectCheckMoneyPayment"/> + + <!--Submit Order and verify information--> + <actionGroup ref="AdminSubmitOrderActionGroup" stepKey="submitOrder"/> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml index b8772f24a2a42..0e58bb84988a2 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml @@ -129,7 +129,7 @@ <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="onCategoryPage"/> <waitForPageLoad stepKey="waitForPageLoad"/> <scrollTo selector="{{StorefrontCategoryMainSection.perPage}}" stepKey="scrollToLimiter"/> - <selectOption userInput="30" selector="{{StorefrontCategoryMainSection.perPage}}" stepKey="selectLimitOnPage"/> + <selectOption userInput="36" selector="{{StorefrontCategoryMainSection.perPage}}" stepKey="selectLimitOnPage"/> <waitForPageLoad stepKey="waitForLoadProducts"/> <scrollToTopOfPage stepKey="scrollToTopOfPage"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml index 9909fca44fe2c..3ff8a7791d88b 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml @@ -125,7 +125,7 @@ <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="onCategoryPage"/> <waitForPageLoad stepKey="waitForPageLoad"/> <scrollTo selector="{{StorefrontCategoryMainSection.perPage}}" stepKey="scrollToLimiter"/> - <selectOption userInput="30" selector="{{StorefrontCategoryMainSection.perPage}}" stepKey="selectLimitOnPage"/> + <selectOption userInput="36" selector="{{StorefrontCategoryMainSection.perPage}}" stepKey="selectLimitOnPage"/> <waitForPageLoad stepKey="waitForLoadProducts"/> <scrollToTopOfPage stepKey="scrollToTopOfPage"/> diff --git a/app/code/Magento/Sales/Test/Unit/Cron/CleanExpiredQuotesTest.php b/app/code/Magento/Sales/Test/Unit/Cron/CleanExpiredQuotesTest.php index e424cae85f223..ad6a3e03ba679 100644 --- a/app/code/Magento/Sales/Test/Unit/Cron/CleanExpiredQuotesTest.php +++ b/app/code/Magento/Sales/Test/Unit/Cron/CleanExpiredQuotesTest.php @@ -59,7 +59,7 @@ public function testExecute($lifetimes, $additionalFilterFields) $this->quoteFactoryMock->expects($this->exactly(count($lifetimes))) ->method('create') ->will($this->returnValue($quotesMock)); - $quotesMock->expects($this->exactly((3 + count($additionalFilterFields)) * count($lifetimes))) + $quotesMock->expects($this->exactly((2 + count($additionalFilterFields)) * count($lifetimes))) ->method('addFieldToFilter'); if (!empty($lifetimes)) { $quotesMock->expects($this->exactly(count($lifetimes))) diff --git a/app/code/Magento/Sales/etc/db_schema.xml b/app/code/Magento/Sales/etc/db_schema.xml index 1f781604491bf..eb508af8daf25 100644 --- a/app/code/Magento/Sales/etc/db_schema.xml +++ b/app/code/Magento/Sales/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="sales_order" resource="sales" engine="innodb" comment="Sales Flat Order"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="varchar" name="state" nullable="true" length="32" comment="State"/> <column xsi:type="varchar" name="status" nullable="true" length="32" comment="Status"/> <column xsi:type="varchar" name="coupon_code" nullable="true" length="255" comment="Coupon Code"/> @@ -19,9 +19,9 @@ <column xsi:type="smallint" name="is_virtual" padding="5" unsigned="true" nullable="true" identity="false" comment="Is Virtual"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="decimal" name="base_discount_amount" scale="4" precision="20" unsigned="false" nullable="true" comment="Base Discount Amount"/> <column xsi:type="decimal" name="base_discount_canceled" scale="4" precision="20" unsigned="false" @@ -145,7 +145,7 @@ <column xsi:type="smallint" name="customer_note_notify" padding="5" unsigned="true" nullable="true" identity="false" comment="Customer Note Notify"/> <column xsi:type="int" name="billing_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Billing Address Id"/> + comment="Billing Address ID"/> <column xsi:type="int" name="customer_group_id" padding="11" unsigned="false" nullable="true" identity="false"/> <column xsi:type="int" name="edit_increment" padding="11" unsigned="false" nullable="true" identity="false" comment="Edit Increment"/> @@ -158,11 +158,11 @@ <column xsi:type="int" name="payment_auth_expiration" padding="11" unsigned="false" nullable="true" identity="false" comment="Payment Authorization Expiration"/> <column xsi:type="int" name="quote_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Quote Address Id"/> + comment="Quote Address ID"/> <column xsi:type="int" name="quote_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Quote Id"/> + comment="Quote ID"/> <column xsi:type="int" name="shipping_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Shipping Address Id"/> + comment="Shipping Address ID"/> <column xsi:type="decimal" name="adjustment_negative" scale="4" precision="20" unsigned="false" nullable="true" comment="Adjustment Negative"/> <column xsi:type="decimal" name="adjustment_positive" scale="4" precision="20" unsigned="false" nullable="true" @@ -188,7 +188,7 @@ <column xsi:type="decimal" name="weight" scale="4" precision="12" unsigned="false" nullable="true" comment="Weight"/> <column xsi:type="datetime" name="customer_dob" on_update="false" nullable="true" comment="Customer Dob"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="32" comment="Increment Id"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="32" comment="Increment ID"/> <column xsi:type="varchar" name="applied_rule_ids" nullable="true" length="128" comment="Applied Rule Ids"/> <column xsi:type="varchar" name="base_currency_code" nullable="true" length="3" comment="Base Currency Code"/> <column xsi:type="varchar" name="customer_email" nullable="true" length="128" comment="Customer Email"/> @@ -201,21 +201,21 @@ <column xsi:type="varchar" name="customer_taxvat" nullable="true" length="32" comment="Customer Taxvat"/> <column xsi:type="varchar" name="discount_description" nullable="true" length="255" comment="Discount Description"/> - <column xsi:type="varchar" name="ext_customer_id" nullable="true" length="32" comment="Ext Customer Id"/> - <column xsi:type="varchar" name="ext_order_id" nullable="true" length="32" comment="Ext Order Id"/> + <column xsi:type="varchar" name="ext_customer_id" nullable="true" length="32" comment="Ext Customer ID"/> + <column xsi:type="varchar" name="ext_order_id" nullable="true" length="32" comment="Ext Order ID"/> <column xsi:type="varchar" name="global_currency_code" nullable="true" length="3" comment="Global Currency Code"/> <column xsi:type="varchar" name="hold_before_state" nullable="true" length="32" comment="Hold Before State"/> <column xsi:type="varchar" name="hold_before_status" nullable="true" length="32" comment="Hold Before Status"/> <column xsi:type="varchar" name="order_currency_code" nullable="true" length="3" comment="Order Currency Code"/> <column xsi:type="varchar" name="original_increment_id" nullable="true" length="32" - comment="Original Increment Id"/> - <column xsi:type="varchar" name="relation_child_id" nullable="true" length="32" comment="Relation Child Id"/> + comment="Original Increment ID"/> + <column xsi:type="varchar" name="relation_child_id" nullable="true" length="32" comment="Relation Child ID"/> <column xsi:type="varchar" name="relation_child_real_id" nullable="true" length="32" - comment="Relation Child Real Id"/> - <column xsi:type="varchar" name="relation_parent_id" nullable="true" length="32" comment="Relation Parent Id"/> + comment="Relation Child Real ID"/> + <column xsi:type="varchar" name="relation_parent_id" nullable="true" length="32" comment="Relation Parent ID"/> <column xsi:type="varchar" name="relation_parent_real_id" nullable="true" length="32" - comment="Relation Parent Real Id"/> + comment="Relation Parent Real ID"/> <column xsi:type="varchar" name="remote_ip" nullable="true" length="45" comment="Remote Ip"/> <column xsi:type="varchar" name="shipping_method" nullable="true" length="120"/> <column xsi:type="varchar" name="store_currency_code" nullable="true" length="3" comment="Store Currency Code"/> @@ -297,13 +297,13 @@ </table> <table name="sales_order_grid" resource="sales" engine="innodb" comment="Sales Flat Order Grid"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="varchar" name="status" nullable="true" length="32" comment="Status"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="store_name" nullable="true" length="255" comment="Store Name"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="decimal" name="base_grand_total" scale="4" precision="20" unsigned="false" nullable="true" comment="Base Grand Total"/> <column xsi:type="decimal" name="base_total_paid" scale="4" precision="20" unsigned="false" nullable="true" @@ -312,7 +312,7 @@ comment="Grand Total"/> <column xsi:type="decimal" name="total_paid" scale="4" precision="20" unsigned="false" nullable="true" comment="Total Paid"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="varchar" name="base_currency_code" nullable="true" length="3" comment="Base Currency Code"/> <column xsi:type="varchar" name="order_currency_code" nullable="true" length="255" comment="Order Currency Code"/> @@ -386,17 +386,17 @@ </table> <table name="sales_order_address" resource="sales" engine="innodb" comment="Sales Flat Order Address"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="int" name="customer_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Customer Address Id"/> + comment="Customer Address ID"/> <column xsi:type="int" name="quote_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Quote Address Id"/> + comment="Quote Address ID"/> <column xsi:type="int" name="region_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Region Id"/> + comment="Region ID"/> <column xsi:type="int" name="customer_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="varchar" name="fax" nullable="true" length="255" comment="Fax"/> <column xsi:type="varchar" name="region" nullable="true" length="255" comment="Region"/> <column xsi:type="varchar" name="postcode" nullable="true" length="255" comment="Postcode"/> @@ -405,17 +405,17 @@ <column xsi:type="varchar" name="city" nullable="true" length="255" comment="City"/> <column xsi:type="varchar" name="email" nullable="true" length="255" comment="Email"/> <column xsi:type="varchar" name="telephone" nullable="true" length="255" comment="Phone Number"/> - <column xsi:type="varchar" name="country_id" nullable="true" length="2" comment="Country Id"/> + <column xsi:type="varchar" name="country_id" nullable="true" length="2" comment="Country ID"/> <column xsi:type="varchar" name="firstname" nullable="true" length="255" comment="Firstname"/> <column xsi:type="varchar" name="address_type" nullable="true" length="255" comment="Address Type"/> <column xsi:type="varchar" name="prefix" nullable="true" length="255" comment="Prefix"/> <column xsi:type="varchar" name="middlename" nullable="true" length="255" comment="Middlename"/> <column xsi:type="varchar" name="suffix" nullable="true" length="255" comment="Suffix"/> <column xsi:type="varchar" name="company" nullable="true" length="255" comment="Company"/> - <column xsi:type="text" name="vat_id" nullable="true" comment="Vat Id"/> + <column xsi:type="text" name="vat_id" nullable="true" comment="Vat ID"/> <column xsi:type="smallint" name="vat_is_valid" padding="6" unsigned="false" nullable="true" identity="false" comment="Vat Is Valid"/> - <column xsi:type="text" name="vat_request_id" nullable="true" comment="Vat Request Id"/> + <column xsi:type="text" name="vat_request_id" nullable="true" comment="Vat Request ID"/> <column xsi:type="text" name="vat_request_date" nullable="true" comment="Vat Request Date"/> <column xsi:type="smallint" name="vat_request_success" padding="6" unsigned="false" nullable="true" identity="false" comment="Vat Request Success"/> @@ -431,9 +431,9 @@ </table> <table name="sales_order_status_history" resource="sales" engine="innodb" comment="Sales Flat Order Status History"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="int" name="is_customer_notified" padding="11" unsigned="false" nullable="true" identity="false" comment="Is Customer Notified"/> <column xsi:type="smallint" name="is_visible_on_front" padding="5" unsigned="true" nullable="false" @@ -459,21 +459,21 @@ </table> <table name="sales_order_item" resource="sales" engine="innodb" comment="Sales Flat Order Item"> <column xsi:type="int" name="item_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Item Id"/> + comment="Item ID"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Order Id"/> + default="0" comment="Order ID"/> <column xsi:type="int" name="parent_item_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Parent Item Id"/> + comment="Parent Item ID"/> <column xsi:type="int" name="quote_item_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Quote Item Id"/> + comment="Quote Item ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" comment="Updated At"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="product_type" nullable="true" length="255" comment="Product Type"/> <column xsi:type="text" name="product_options" nullable="true" comment="Product Options"/> <column xsi:type="decimal" name="weight" scale="4" precision="12" unsigned="false" nullable="true" default="0" @@ -549,7 +549,7 @@ nullable="true" comment="Base Tax Before Discount"/> <column xsi:type="decimal" name="tax_before_discount" scale="4" precision="20" unsigned="false" nullable="true" comment="Tax Before Discount"/> - <column xsi:type="varchar" name="ext_order_item_id" nullable="true" length="255" comment="Ext Order Item Id"/> + <column xsi:type="varchar" name="ext_order_item_id" nullable="true" length="255" comment="Ext Order Item ID"/> <column xsi:type="smallint" name="locked_do_invoice" padding="5" unsigned="true" nullable="true" identity="false" comment="Locked Do Invoice"/> <column xsi:type="smallint" name="locked_do_ship" padding="5" unsigned="true" nullable="true" identity="false" @@ -602,9 +602,9 @@ </table> <table name="sales_order_payment" resource="sales" engine="innodb" comment="Sales Flat Order Payment"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="decimal" name="base_shipping_captured" scale="4" precision="20" unsigned="false" nullable="true" comment="Base Shipping Captured"/> <column xsi:type="decimal" name="shipping_captured" scale="4" precision="20" unsigned="false" nullable="true" @@ -642,7 +642,7 @@ <column xsi:type="decimal" name="base_amount_canceled" scale="4" precision="20" unsigned="false" nullable="true" comment="Base Amount Canceled"/> <column xsi:type="int" name="quote_payment_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Quote Payment Id"/> + comment="Quote Payment ID"/> <column xsi:type="text" name="additional_data" nullable="true" comment="Additional Data"/> <column xsi:type="varchar" name="cc_exp_month" nullable="true" length="12" comment="Cc Exp Month"/> <column xsi:type="varchar" name="cc_ss_start_year" nullable="true" length="12" comment="Cc Ss Start Year"/> @@ -663,7 +663,7 @@ <column xsi:type="varchar" name="cc_ss_start_month" nullable="true" length="128" comment="Cc Ss Start Month"/> <column xsi:type="varchar" name="echeck_account_type" nullable="true" length="255" comment="Echeck Account Type"/> - <column xsi:type="varchar" name="last_trans_id" nullable="true" length="255" comment="Last Trans Id"/> + <column xsi:type="varchar" name="last_trans_id" nullable="true" length="255" comment="Last Trans ID"/> <column xsi:type="varchar" name="cc_cid_status" nullable="true" length="32" comment="Cc Cid Status"/> <column xsi:type="varchar" name="cc_owner" nullable="true" length="128" comment="Cc Owner"/> <column xsi:type="varchar" name="cc_type" nullable="true" length="32" comment="Cc Type"/> @@ -681,7 +681,7 @@ comment="Echeck Account Name"/> <column xsi:type="varchar" name="cc_avs_status" nullable="true" length="32" comment="Cc Avs Status"/> <column xsi:type="varchar" name="cc_number_enc" nullable="true" length="128"/> - <column xsi:type="varchar" name="cc_trans_id" nullable="true" length="32" comment="Cc Trans Id"/> + <column xsi:type="varchar" name="cc_trans_id" nullable="true" length="32" comment="Cc Trans ID"/> <column xsi:type="varchar" name="address_status" nullable="true" length="32" comment="Address Status"/> <column xsi:type="text" name="additional_information" nullable="true" comment="Additional Information"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -696,9 +696,9 @@ </table> <table name="sales_shipment" resource="sales" engine="innodb" comment="Sales Flat Shipment"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="decimal" name="total_weight" scale="4" precision="12" unsigned="false" nullable="true" comment="Total Weight"/> <column xsi:type="decimal" name="total_qty" scale="4" precision="12" unsigned="false" nullable="true" @@ -708,16 +708,16 @@ <column xsi:type="smallint" name="send_email" padding="5" unsigned="true" nullable="true" identity="false" comment="Send Email"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> + comment="Order ID"/> <column xsi:type="int" name="customer_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="int" name="shipping_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Shipping Address Id"/> + comment="Shipping Address ID"/> <column xsi:type="int" name="billing_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Billing Address Id"/> + comment="Billing Address ID"/> <column xsi:type="int" name="shipment_status" padding="11" unsigned="false" nullable="true" identity="false" comment="Shipment Status"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" @@ -762,15 +762,15 @@ </table> <table name="sales_shipment_grid" resource="sales" engine="innodb" comment="Sales Flat Shipment Grid"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Entity Id"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + comment="Entity ID"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> - <column xsi:type="varchar" name="order_increment_id" nullable="false" length="32" comment="Order Increment Id"/> + comment="Store ID"/> + <column xsi:type="varchar" name="order_increment_id" nullable="false" length="32" comment="Order Increment ID"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> + comment="Order ID"/> <column xsi:type="timestamp" name="order_created_at" on_update="true" nullable="false" - default="CURRENT_TIMESTAMP" comment="Order Increment Id"/> + default="CURRENT_TIMESTAMP" comment="Order Increment ID"/> <column xsi:type="varchar" name="customer_name" nullable="false" length="128" comment="Customer Name"/> <column xsi:type="decimal" name="total_qty" scale="4" precision="12" unsigned="false" nullable="true" comment="Total Qty"/> @@ -837,9 +837,9 @@ </table> <table name="sales_shipment_item" resource="sales" engine="innodb" comment="Sales Flat Shipment Item"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="decimal" name="row_total" scale="4" precision="20" unsigned="false" nullable="true" comment="Row Total"/> <column xsi:type="decimal" name="price" scale="4" precision="20" unsigned="false" nullable="true" @@ -848,9 +848,9 @@ comment="Weight"/> <column xsi:type="decimal" name="qty" scale="4" precision="12" unsigned="false" nullable="true" comment="Qty"/> <column xsi:type="int" name="product_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="int" name="order_item_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Order Item Id"/> + comment="Order Item ID"/> <column xsi:type="text" name="additional_data" nullable="true" comment="Additional Data"/> <column xsi:type="text" name="description" nullable="true" comment="Description"/> <column xsi:type="varchar" name="name" nullable="true" length="255" comment="Name"/> @@ -867,14 +867,14 @@ </table> <table name="sales_shipment_track" resource="sales" engine="innodb" comment="Sales Flat Shipment Track"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="decimal" name="weight" scale="4" precision="12" unsigned="false" nullable="true" comment="Weight"/> <column xsi:type="decimal" name="qty" scale="4" precision="12" unsigned="false" nullable="true" comment="Qty"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> + comment="Order ID"/> <column xsi:type="text" name="track_number" nullable="true" comment="Number"/> <column xsi:type="text" name="description" nullable="true" comment="Description"/> <column xsi:type="varchar" name="title" nullable="true" length="255" comment="Title"/> @@ -901,9 +901,9 @@ </table> <table name="sales_shipment_comment" resource="sales" engine="innodb" comment="Sales Flat Shipment Comment"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="int" name="is_customer_notified" padding="11" unsigned="false" nullable="true" identity="false" comment="Is Customer Notified"/> <column xsi:type="smallint" name="is_visible_on_front" padding="5" unsigned="true" nullable="false" @@ -926,9 +926,9 @@ </table> <table name="sales_invoice" resource="sales" engine="innodb" comment="Sales Flat Invoice"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="decimal" name="base_grand_total" scale="4" precision="20" unsigned="false" nullable="true" comment="Base Grand Total"/> <column xsi:type="decimal" name="shipping_tax_amount" scale="4" precision="20" unsigned="false" nullable="true" @@ -968,11 +968,11 @@ <column xsi:type="decimal" name="discount_amount" scale="4" precision="20" unsigned="false" nullable="true" comment="Discount Amount"/> <column xsi:type="int" name="billing_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Billing Address Id"/> + comment="Billing Address ID"/> <column xsi:type="smallint" name="is_used_for_refund" padding="5" unsigned="true" nullable="true" identity="false" comment="Is Used For Refund"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> + comment="Order ID"/> <column xsi:type="smallint" name="email_sent" padding="5" unsigned="true" nullable="true" identity="false" comment="Email Sent"/> <column xsi:type="smallint" name="send_email" padding="5" unsigned="true" nullable="true" identity="false" @@ -982,14 +982,14 @@ <column xsi:type="int" name="state" padding="11" unsigned="false" nullable="true" identity="false" comment="State"/> <column xsi:type="int" name="shipping_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Shipping Address Id"/> + comment="Shipping Address ID"/> <column xsi:type="varchar" name="store_currency_code" nullable="true" length="3" comment="Store Currency Code"/> - <column xsi:type="varchar" name="transaction_id" nullable="true" length="255" comment="Transaction Id"/> + <column xsi:type="varchar" name="transaction_id" nullable="true" length="255" comment="Transaction ID"/> <column xsi:type="varchar" name="order_currency_code" nullable="true" length="3" comment="Order Currency Code"/> <column xsi:type="varchar" name="base_currency_code" nullable="true" length="3" comment="Base Currency Code"/> <column xsi:type="varchar" name="global_currency_code" nullable="true" length="3" comment="Global Currency Code"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" @@ -1051,16 +1051,16 @@ </table> <table name="sales_invoice_grid" resource="sales" engine="innodb" comment="Sales Flat Invoice Grid"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Entity Id"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + comment="Entity ID"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="int" name="state" padding="11" unsigned="false" nullable="true" identity="false" comment="State"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="store_name" nullable="true" length="255" comment="Store Name"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> - <column xsi:type="varchar" name="order_increment_id" nullable="true" length="50" comment="Order Increment Id"/> + comment="Order ID"/> + <column xsi:type="varchar" name="order_increment_id" nullable="true" length="50" comment="Order Increment ID"/> <column xsi:type="timestamp" name="order_created_at" on_update="false" nullable="true" comment="Order Created At"/> <column xsi:type="varchar" name="customer_name" nullable="true" length="255" comment="Customer Name"/> @@ -1136,9 +1136,9 @@ </table> <table name="sales_invoice_item" resource="sales" engine="innodb" comment="Sales Flat Invoice Item"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="decimal" name="base_price" scale="4" precision="12" unsigned="false" nullable="true" comment="Base Price"/> <column xsi:type="decimal" name="tax_amount" scale="4" precision="12" unsigned="false" nullable="true" @@ -1167,9 +1167,9 @@ <column xsi:type="decimal" name="row_total_incl_tax" scale="4" precision="12" unsigned="false" nullable="true" comment="Row Total Incl Tax"/> <column xsi:type="int" name="product_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="int" name="order_item_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Order Item Id"/> + comment="Order Item ID"/> <column xsi:type="text" name="additional_data" nullable="true" comment="Additional Data"/> <column xsi:type="text" name="description" nullable="true" comment="Description"/> <column xsi:type="varchar" name="sku" nullable="true" length="255" comment="Sku"/> @@ -1192,9 +1192,9 @@ </table> <table name="sales_invoice_comment" resource="sales" engine="innodb" comment="Sales Flat Invoice Comment"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="smallint" name="is_customer_notified" padding="5" unsigned="true" nullable="true" identity="false" comment="Is Customer Notified"/> <column xsi:type="smallint" name="is_visible_on_front" padding="5" unsigned="true" nullable="false" @@ -1217,9 +1217,9 @@ </table> <table name="sales_creditmemo" resource="sales" engine="innodb" comment="Sales Flat Creditmemo"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="decimal" name="adjustment_positive" scale="4" precision="20" unsigned="false" nullable="true" comment="Adjustment Positive"/> <column xsi:type="decimal" name="base_shipping_tax_amount" scale="4" precision="20" unsigned="false" @@ -1269,7 +1269,7 @@ <column xsi:type="decimal" name="tax_amount" scale="4" precision="20" unsigned="false" nullable="true" comment="Tax Amount"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> + comment="Order ID"/> <column xsi:type="smallint" name="email_sent" padding="5" unsigned="true" nullable="true" identity="false" comment="Email Sent"/> <column xsi:type="smallint" name="send_email" padding="5" unsigned="true" nullable="true" identity="false" @@ -1279,18 +1279,18 @@ <column xsi:type="int" name="state" padding="11" unsigned="false" nullable="true" identity="false" comment="State"/> <column xsi:type="int" name="shipping_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Shipping Address Id"/> + comment="Shipping Address ID"/> <column xsi:type="int" name="billing_address_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Billing Address Id"/> + comment="Billing Address ID"/> <column xsi:type="int" name="invoice_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Invoice Id"/> + comment="Invoice ID"/> <column xsi:type="varchar" name="store_currency_code" nullable="true" length="3" comment="Store Currency Code"/> <column xsi:type="varchar" name="order_currency_code" nullable="true" length="3" comment="Order Currency Code"/> <column xsi:type="varchar" name="base_currency_code" nullable="true" length="3" comment="Base Currency Code"/> <column xsi:type="varchar" name="global_currency_code" nullable="true" length="3" comment="Global Currency Code"/> - <column xsi:type="varchar" name="transaction_id" nullable="true" length="255" comment="Transaction Id"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + <column xsi:type="varchar" name="transaction_id" nullable="true" length="255" comment="Transaction ID"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" @@ -1350,13 +1350,13 @@ </table> <table name="sales_creditmemo_grid" resource="sales" engine="innodb" comment="Sales Flat Creditmemo Grid"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Entity Id"/> - <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment Id"/> + comment="Entity ID"/> + <column xsi:type="varchar" name="increment_id" nullable="true" length="50" comment="Increment ID"/> <column xsi:type="timestamp" name="created_at" on_update="false" nullable="true" comment="Created At"/> <column xsi:type="timestamp" name="updated_at" on_update="false" nullable="true" comment="Updated At"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> - <column xsi:type="varchar" name="order_increment_id" nullable="true" length="50" comment="Order Increment Id"/> + comment="Order ID"/> + <column xsi:type="varchar" name="order_increment_id" nullable="true" length="50" comment="Order Increment ID"/> <column xsi:type="timestamp" name="order_created_at" on_update="false" nullable="true" comment="Order Created At"/> <column xsi:type="varchar" name="billing_name" nullable="true" length="255" comment="Billing Name"/> @@ -1366,13 +1366,13 @@ comment="Base Grand Total"/> <column xsi:type="varchar" name="order_status" nullable="true" length="32" comment="Order Status"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="billing_address" nullable="true" length="255" comment="Billing Address"/> <column xsi:type="varchar" name="shipping_address" nullable="true" length="255" comment="Shipping Address"/> <column xsi:type="varchar" name="customer_name" nullable="false" length="128" comment="Customer Name"/> <column xsi:type="varchar" name="customer_email" nullable="true" length="128" comment="Customer Email"/> <column xsi:type="smallint" name="customer_group_id" padding="6" unsigned="false" nullable="true" - identity="false" comment="Customer Group Id"/> + identity="false" comment="Customer Group ID"/> <column xsi:type="varchar" name="payment_method" nullable="true" length="32" comment="Payment Method"/> <column xsi:type="varchar" name="shipping_information" nullable="true" length="255" comment="Shipping Method Name"/> @@ -1440,9 +1440,9 @@ </table> <table name="sales_creditmemo_item" resource="sales" engine="innodb" comment="Sales Flat Creditmemo Item"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="decimal" name="base_price" scale="4" precision="12" unsigned="false" nullable="true" comment="Base Price"/> <column xsi:type="decimal" name="tax_amount" scale="4" precision="12" unsigned="false" nullable="true" @@ -1471,9 +1471,9 @@ <column xsi:type="decimal" name="row_total_incl_tax" scale="4" precision="12" unsigned="false" nullable="true" comment="Row Total Incl Tax"/> <column xsi:type="int" name="product_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="int" name="order_item_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Order Item Id"/> + comment="Order Item ID"/> <column xsi:type="text" name="additional_data" nullable="true" comment="Additional Data"/> <column xsi:type="text" name="description" nullable="true" comment="Description"/> <column xsi:type="varchar" name="sku" nullable="true" length="255" comment="Sku"/> @@ -1496,9 +1496,9 @@ </table> <table name="sales_creditmemo_comment" resource="sales" engine="innodb" comment="Sales Flat Creditmemo Comment"> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Entity Id"/> + comment="Entity ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="int" name="is_customer_notified" padding="11" unsigned="false" nullable="true" identity="false" comment="Is Customer Notified"/> <column xsi:type="smallint" name="is_visible_on_front" padding="5" unsigned="true" nullable="false" @@ -1520,10 +1520,10 @@ </index> </table> <table name="sales_invoiced_aggregated" resource="sales" engine="innodb" comment="Sales Invoiced Aggregated"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="true" length="50" comment="Order Status"/> <column xsi:type="int" name="orders_count" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Orders Count"/> @@ -1552,10 +1552,10 @@ </table> <table name="sales_invoiced_aggregated_order" resource="sales" engine="innodb" comment="Sales Invoiced Aggregated Order"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="false" length="50" comment="Order Status"/> <column xsi:type="int" name="orders_count" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Orders Count"/> @@ -1584,10 +1584,10 @@ </table> <table name="sales_order_aggregated_created" resource="sales" engine="innodb" comment="Sales Order Aggregated Created"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="false" length="50" comment="Order Status"/> <column xsi:type="int" name="orders_count" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Orders Count"/> @@ -1638,10 +1638,10 @@ </table> <table name="sales_order_aggregated_updated" resource="sales" engine="innodb" comment="Sales Order Aggregated Updated"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="false" length="50" comment="Order Status"/> <column xsi:type="int" name="orders_count" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Orders Count"/> @@ -1692,15 +1692,15 @@ </table> <table name="sales_payment_transaction" resource="sales" engine="innodb" comment="Sales Payment Transaction"> <column xsi:type="int" name="transaction_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Transaction Id"/> + comment="Transaction ID"/> <column xsi:type="int" name="parent_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Order Id"/> + default="0" comment="Order ID"/> <column xsi:type="int" name="payment_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Payment Id"/> - <column xsi:type="varchar" name="txn_id" nullable="true" length="100" comment="Txn Id"/> - <column xsi:type="varchar" name="parent_txn_id" nullable="true" length="100" comment="Parent Txn Id"/> + default="0" comment="Payment ID"/> + <column xsi:type="varchar" name="txn_id" nullable="true" length="100" comment="Txn ID"/> + <column xsi:type="varchar" name="parent_txn_id" nullable="true" length="100" comment="Parent Txn ID"/> <column xsi:type="varchar" name="txn_type" nullable="true" length="15" comment="Txn Type"/> <column xsi:type="smallint" name="is_closed" padding="5" unsigned="true" nullable="false" identity="false" default="1" comment="Is Closed"/> @@ -1732,10 +1732,10 @@ </index> </table> <table name="sales_refunded_aggregated" resource="sales" engine="innodb" comment="Sales Refunded Aggregated"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="false" length="50" comment="Order Status"/> <column xsi:type="int" name="orders_count" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Orders Count"/> @@ -1762,10 +1762,10 @@ </table> <table name="sales_refunded_aggregated_order" resource="sales" engine="innodb" comment="Sales Refunded Aggregated Order"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="true" length="50" comment="Order Status"/> <column xsi:type="int" name="orders_count" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="Orders Count"/> @@ -1791,10 +1791,10 @@ </index> </table> <table name="sales_shipping_aggregated" resource="sales" engine="innodb" comment="Sales Shipping Aggregated"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="true" length="50" comment="Order Status"/> <column xsi:type="varchar" name="shipping_description" nullable="true" length="255" comment="Shipping Description"/> @@ -1822,10 +1822,10 @@ </table> <table name="sales_shipping_aggregated_order" resource="sales" engine="innodb" comment="Sales Shipping Aggregated Order"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="true" length="50" comment="Order Status"/> <column xsi:type="varchar" name="shipping_description" nullable="true" length="255" comment="Shipping Description"/> @@ -1853,12 +1853,12 @@ </table> <table name="sales_bestsellers_aggregated_daily" resource="sales" engine="innodb" comment="Sales Bestsellers Aggregated Daily"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="product_name" nullable="true" length="255" comment="Product Name"/> <column xsi:type="decimal" name="product_price" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Product Price"/> @@ -1886,12 +1886,12 @@ </table> <table name="sales_bestsellers_aggregated_monthly" resource="sales" engine="innodb" comment="Sales Bestsellers Aggregated Monthly"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="product_name" nullable="true" length="255" comment="Product Name"/> <column xsi:type="decimal" name="product_price" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Product Price"/> @@ -1919,12 +1919,12 @@ </table> <table name="sales_bestsellers_aggregated_yearly" resource="sales" engine="innodb" comment="Sales Bestsellers Aggregated Yearly"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="product_name" nullable="true" length="255" comment="Product Name"/> <column xsi:type="decimal" name="product_price" scale="4" precision="12" unsigned="false" nullable="false" default="0" comment="Product Price"/> @@ -1952,9 +1952,9 @@ </table> <table name="sales_order_tax" resource="sales" engine="innodb" comment="Sales Order Tax Table"> <column xsi:type="int" name="tax_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Tax Id"/> + comment="Tax ID"/> <column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order Id"/> + comment="Order ID"/> <column xsi:type="varchar" name="code" nullable="true" length="255" comment="Code"/> <column xsi:type="varchar" name="title" nullable="true" length="255" comment="Title"/> <column xsi:type="decimal" name="percent" scale="4" precision="12" unsigned="false" nullable="true" @@ -1982,11 +1982,11 @@ </table> <table name="sales_order_tax_item" resource="sales" engine="innodb" comment="Sales Order Tax Item"> <column xsi:type="int" name="tax_item_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Tax Item Id"/> + comment="Tax Item ID"/> <column xsi:type="int" name="tax_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Tax Id"/> + comment="Tax ID"/> <column xsi:type="int" name="item_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Item Id"/> + comment="Item ID"/> <column xsi:type="decimal" name="tax_percent" scale="4" precision="12" unsigned="false" nullable="false" comment="Real Tax Percent For Item"/> <column xsi:type="decimal" name="amount" scale="4" precision="20" unsigned="false" nullable="false" @@ -2046,7 +2046,7 @@ <table name="sales_order_status_label" resource="sales" engine="innodb" comment="Sales Order Status Label Table"> <column xsi:type="varchar" name="status" nullable="false" length="32" comment="Status"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="label" nullable="false" length="128" comment="Label"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="status"/> diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/Orders.php b/app/code/Magento/SalesGraphQl/Model/Resolver/Orders.php index 4fd06e88878b4..8d81afeab4c90 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/Orders.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/Orders.php @@ -56,6 +56,7 @@ public function resolve( $items[] = [ 'id' => $order->getId(), 'increment_id' => $order->getIncrementId(), + 'order_number' => $order->getIncrementId(), 'created_at' => $order->getCreatedAt(), 'grand_total' => $order->getGrandTotal(), 'status' => $order->getStatus(), diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index 06146f805c644..a7c30f582e752 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -7,7 +7,8 @@ type Query { type CustomerOrder @doc(description: "Order mapping fields") { id: Int - increment_id: String + increment_id: String @deprecated(reason: "Use the order_number instaed.") + order_number: String! @doc(description: "The order number") created_at: String grand_total: Float status: String diff --git a/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote/NewActionHtml.php b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote/NewActionHtml.php index 89a0d6e579727..56c08864c90c4 100644 --- a/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote/NewActionHtml.php +++ b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote/NewActionHtml.php @@ -1,12 +1,19 @@ <?php /** - * * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ namespace Magento\SalesRule\Controller\Adminhtml\Promo\Quote; -class NewActionHtml extends \Magento\SalesRule\Controller\Adminhtml\Promo\Quote +use Magento\Framework\App\Action\HttpPostActionInterface; +use Magento\Rule\Model\Condition\AbstractCondition; +use Magento\SalesRule\Controller\Adminhtml\Promo\Quote; +use Magento\SalesRule\Model\Rule; + +/** + * New action html action + */ +class NewActionHtml extends Quote implements HttpPostActionInterface { /** * New action html action @@ -15,8 +22,10 @@ class NewActionHtml extends \Magento\SalesRule\Controller\Adminhtml\Promo\Quote */ public function execute() { - $id = $this->getRequest()->getParam('id'); - $formName = $this->getRequest()->getParam('form'); + $id = $this->getRequest() + ->getParam('id'); + $formName = $this->getRequest() + ->getParam('form_namespace'); $typeArr = explode('|', str_replace('-', '/', $this->getRequest()->getParam('type'))); $type = $typeArr[0]; @@ -27,7 +36,7 @@ public function execute() )->setType( $type )->setRule( - $this->_objectManager->create(\Magento\SalesRule\Model\Rule::class) + $this->_objectManager->create(Rule::class) )->setPrefix( 'actions' ); @@ -35,12 +44,14 @@ public function execute() $model->setAttribute($typeArr[1]); } - if ($model instanceof \Magento\Rule\Model\Condition\AbstractCondition) { + if ($model instanceof AbstractCondition) { $model->setJsFormObject($formName); + $model->setFormName($formName); $html = $model->asHtmlRecursive(); } else { $html = ''; } - $this->getResponse()->setBody($html); + $this->getResponse() + ->setBody($html); } } diff --git a/app/code/Magento/SalesRule/Model/Rule/Action/SimpleActionOptionsProvider.php b/app/code/Magento/SalesRule/Model/Rule/Action/SimpleActionOptionsProvider.php new file mode 100644 index 0000000000000..a0fd4bf576f61 --- /dev/null +++ b/app/code/Magento/SalesRule/Model/Rule/Action/SimpleActionOptionsProvider.php @@ -0,0 +1,30 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesRule\Model\Rule\Action; + +use Magento\Framework\Data\OptionSourceInterface; +use Magento\SalesRule\Model\Rule; + +/** + * Class SimpleActionOptionsProvider + */ +class SimpleActionOptionsProvider implements OptionSourceInterface +{ + /** + * @inheritdoc + */ + public function toOptionArray() + { + return [ + ['label' => __('Percent of product price discount'), 'value' => Rule::BY_PERCENT_ACTION], + ['label' => __('Fixed amount discount'), 'value' => Rule::BY_FIXED_ACTION], + ['label' => __('Fixed amount discount for whole cart'), 'value' => Rule::CART_FIXED_ACTION], + ['label' => __('Buy X get Y free (discount amount is Y)'), 'value' => Rule::BUY_X_GET_Y_ACTION] + ]; + } +} diff --git a/app/code/Magento/SalesRule/Model/Rule/Metadata/ValueProvider.php b/app/code/Magento/SalesRule/Model/Rule/Metadata/ValueProvider.php index fdd6c2b169a7d..e4aaaec98dc79 100644 --- a/app/code/Magento/SalesRule/Model/Rule/Metadata/ValueProvider.php +++ b/app/code/Magento/SalesRule/Model/Rule/Metadata/ValueProvider.php @@ -5,11 +5,14 @@ */ namespace Magento\SalesRule\Model\Rule\Metadata; -use Magento\SalesRule\Model\Rule; -use Magento\Store\Model\System\Store; use Magento\Customer\Api\GroupRepositoryInterface; use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Convert\DataObject; +use Magento\SalesRule\Model\Rule; +use Magento\SalesRule\Model\Rule\Action\SimpleActionOptionsProvider; +use Magento\SalesRule\Model\RuleFactory; +use Magento\Store\Model\System\Store; /** * Metadata provider for sales rule edit form. @@ -37,10 +40,15 @@ class ValueProvider protected $objectConverter; /** - * @var \Magento\SalesRule\Model\RuleFactory + * @var RuleFactory */ protected $salesRuleFactory; + /** + * @var SimpleActionOptionsProvider + */ + private $simpleActionOptionsProvider; + /** * Initialize dependencies. * @@ -48,20 +56,24 @@ class ValueProvider * @param GroupRepositoryInterface $groupRepository * @param SearchCriteriaBuilder $searchCriteriaBuilder * @param DataObject $objectConverter - * @param \Magento\SalesRule\Model\RuleFactory $salesRuleFactory + * @param RuleFactory $salesRuleFactory + * @param SimpleActionOptionsProvider|null $simpleActionOptionsProvider */ public function __construct( Store $store, GroupRepositoryInterface $groupRepository, SearchCriteriaBuilder $searchCriteriaBuilder, DataObject $objectConverter, - \Magento\SalesRule\Model\RuleFactory $salesRuleFactory + RuleFactory $salesRuleFactory, + SimpleActionOptionsProvider $simpleActionOptionsProvider = null ) { $this->store = $store; $this->groupRepository = $groupRepository; $this->searchCriteriaBuilder = $searchCriteriaBuilder; $this->objectConverter = $objectConverter; $this->salesRuleFactory = $salesRuleFactory; + $this->simpleActionOptionsProvider = $simpleActionOptionsProvider ?: + ObjectManager::getInstance()->get(SimpleActionOptionsProvider::class); } /** @@ -71,15 +83,10 @@ public function __construct( * @return array * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function getMetadataValues(\Magento\SalesRule\Model\Rule $rule) + public function getMetadataValues(Rule $rule) { $customerGroups = $this->groupRepository->getList($this->searchCriteriaBuilder->create())->getItems(); - $applyOptions = [ - ['label' => __('Percent of product price discount'), 'value' => Rule::BY_PERCENT_ACTION], - ['label' => __('Fixed amount discount'), 'value' => Rule::BY_FIXED_ACTION], - ['label' => __('Fixed amount discount for whole cart'), 'value' => Rule::CART_FIXED_ACTION], - ['label' => __('Buy X get Y free (discount amount is Y)'), 'value' => Rule::BUY_X_GET_Y_ACTION] - ]; + $applyOptions = $this->simpleActionOptionsProvider->toOptionArray(); $couponTypesOptions = []; $couponTypes = $this->salesRuleFactory->create()->getCouponTypes(); diff --git a/app/code/Magento/SalesRule/Model/RulesApplier.php b/app/code/Magento/SalesRule/Model/RulesApplier.php index f771a4f1e3892..1214e6642b440 100644 --- a/app/code/Magento/SalesRule/Model/RulesApplier.php +++ b/app/code/Magento/SalesRule/Model/RulesApplier.php @@ -6,12 +6,16 @@ namespace Magento\SalesRule\Model; use Magento\Quote\Model\Quote\Address; +use Magento\Quote\Model\Quote\Item\AbstractItem; use Magento\SalesRule\Model\Quote\ChildrenValidationLocator; use Magento\Framework\App\ObjectManager; +use Magento\SalesRule\Model\ResourceModel\Rule\Collection; use Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory; +use Magento\SalesRule\Model\Rule\Action\Discount\DataFactory; /** * Class RulesApplier + * * @package Magento\SalesRule\Model\Validator */ class RulesApplier @@ -39,29 +43,37 @@ class RulesApplier private $calculatorFactory; /** - * @param \Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory $calculatorFactory + * @var \Magento\SalesRule\Model\Rule\Action\Discount\DataFactory + */ + protected $discountFactory; + + /** + * @param CalculatorFactory $calculatorFactory * @param \Magento\Framework\Event\ManagerInterface $eventManager - * @param \Magento\SalesRule\Model\Utility $utility + * @param Utility $utility * @param ChildrenValidationLocator|null $childrenValidationLocator + * @param DataFactory $discountDataFactory */ public function __construct( \Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory $calculatorFactory, \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\SalesRule\Model\Utility $utility, - ChildrenValidationLocator $childrenValidationLocator = null + ChildrenValidationLocator $childrenValidationLocator = null, + DataFactory $discountDataFactory = null ) { $this->calculatorFactory = $calculatorFactory; $this->validatorUtility = $utility; $this->_eventManager = $eventManager; $this->childrenValidationLocator = $childrenValidationLocator ?: ObjectManager::getInstance()->get(ChildrenValidationLocator::class); + $this->discountFactory = $discountDataFactory ?: ObjectManager::getInstance()->get(DataFactory::class); } /** * Apply rules to current order item * - * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item - * @param \Magento\SalesRule\Model\ResourceModel\Rule\Collection $rules + * @param AbstractItem $item + * @param Collection $rules * @param bool $skipValidation * @param mixed $couponCode * @return array @@ -71,7 +83,7 @@ public function applyRules($item, $rules, $skipValidation, $couponCode) { $address = $item->getAddress(); $appliedRuleIds = []; - /* @var $rule \Magento\SalesRule\Model\Rule */ + /* @var $rule Rule */ foreach ($rules as $rule) { if (!$this->validatorUtility->canProcessRule($rule, $address)) { continue; @@ -79,7 +91,7 @@ public function applyRules($item, $rules, $skipValidation, $couponCode) if (!$skipValidation && !$rule->getActions()->validate($item)) { if (!$this->childrenValidationLocator->isChildrenValidationRequired($item)) { - continue; + continue; } $childItems = $item->getChildren(); $isContinue = true; @@ -110,7 +122,7 @@ public function applyRules($item, $rules, $skipValidation, $couponCode) * Add rule discount description label to address object * * @param Address $address - * @param \Magento\SalesRule\Model\Rule $rule + * @param Rule $rule * @return $this */ public function addDiscountDescription($address, $rule) @@ -123,6 +135,10 @@ public function addDiscountDescription($address, $rule) } else { if (strlen($address->getCouponCode())) { $label = $address->getCouponCode(); + + if ($rule->getDescription()) { + $label = $rule->getDescription(); + } } } @@ -136,8 +152,10 @@ public function addDiscountDescription($address, $rule) } /** - * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item - * @param \Magento\SalesRule\Model\Rule $rule + * Apply Rule + * + * @param AbstractItem $item + * @param Rule $rule * @param \Magento\Quote\Model\Quote\Address $address * @param mixed $couponCode * @return $this @@ -154,8 +172,10 @@ protected function applyRule($item, $rule, $address, $couponCode) } /** - * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item - * @param \Magento\SalesRule\Model\Rule $rule + * Get discount Data + * + * @param AbstractItem $item + * @param Rule $rule * @return \Magento\SalesRule\Model\Rule\Action\Discount\Data */ protected function getDiscountData($item, $rule) @@ -165,9 +185,9 @@ protected function getDiscountData($item, $rule) $discountCalculator = $this->calculatorFactory->create($rule->getSimpleAction()); $qty = $discountCalculator->fixQuantity($qty, $rule); $discountData = $discountCalculator->calculate($rule, $item, $qty); - $this->eventFix($discountData, $item, $rule, $qty); $this->validatorUtility->deltaRoundingFix($discountData, $item); + $this->setDiscountBreakdown($discountData, $item, $rule); /** * We can't use row total here because row total not include tax @@ -180,8 +200,35 @@ protected function getDiscountData($item, $rule) } /** + * Set Discount Breakdown + * * @param \Magento\SalesRule\Model\Rule\Action\Discount\Data $discountData * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item + * @param \Magento\SalesRule\Model\Rule $rule + * @return $this + */ + private function setDiscountBreakdown($discountData, $item, $rule) + { + if ($discountData->getAmount() > 0) { + /** @var \Magento\SalesRule\Model\Rule\Action\Discount\Data $discount */ + $discount = $this->discountFactory->create(); + $discount->setBaseOriginalAmount($discountData->getBaseOriginalAmount()); + $discount->setAmount($discountData->getAmount()); + $discount->setBaseAmount($discountData->getBaseAmount()); + $discount->setOriginalAmount($discountData->getOriginalAmount()); + $discountBreakdown = $item->getExtensionAttributes()->getDiscounts() ?? []; + $discountBreakdown[$rule->getId()]['discount'] = $discount; + $discountBreakdown[$rule->getId()]['rule'] = $rule; + $item->getExtensionAttributes()->setDiscounts($discountBreakdown); + } + return $this; + } + + /** + * Set Discount data + * + * @param \Magento\SalesRule\Model\Rule\Action\Discount\Data $discountData + * @param AbstractItem $item * @return $this */ protected function setDiscountData($discountData, $item) @@ -198,7 +245,7 @@ protected function setDiscountData($discountData, $item) * Set coupon code to address if $rule contains validated coupon * * @param Address $address - * @param \Magento\SalesRule\Model\Rule $rule + * @param Rule $rule * @param mixed $couponCode * @return $this */ @@ -208,7 +255,7 @@ public function maintainAddressCouponCode($address, $rule, $couponCode) Rule is a part of rules collection, which includes only rules with 'No Coupon' type or with validated coupon. As a result, if rule uses coupon code(s) ('Specific' or 'Auto' Coupon Type), it always contains validated coupon */ - if ($rule->getCouponType() != \Magento\SalesRule\Model\Rule::COUPON_TYPE_NO_COUPON) { + if ($rule->getCouponType() != Rule::COUPON_TYPE_NO_COUPON) { $address->setCouponCode($couponCode); } @@ -219,15 +266,15 @@ public function maintainAddressCouponCode($address, $rule, $couponCode) * Fire event to allow overwriting of discount amounts * * @param \Magento\SalesRule\Model\Rule\Action\Discount\Data $discountData - * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item - * @param \Magento\SalesRule\Model\Rule $rule + * @param AbstractItem $item + * @param Rule $rule * @param float $qty * @return $this */ protected function eventFix( \Magento\SalesRule\Model\Rule\Action\Discount\Data $discountData, - \Magento\Quote\Model\Quote\Item\AbstractItem $item, - \Magento\SalesRule\Model\Rule $rule, + AbstractItem $item, + Rule $rule, $qty ) { $quote = $item->getQuote(); @@ -249,11 +296,13 @@ protected function eventFix( } /** - * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item + * Set Applied Rule Ids + * + * @param AbstractItem $item * @param int[] $appliedRuleIds * @return $this */ - public function setAppliedRuleIds(\Magento\Quote\Model\Quote\Item\AbstractItem $item, array $appliedRuleIds) + public function setAppliedRuleIds(AbstractItem $item, array $appliedRuleIds) { $address = $item->getAddress(); $quote = $item->getQuote(); diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/SimpleActionOptionsProviderTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/SimpleActionOptionsProviderTest.php new file mode 100644 index 0000000000000..f1653dd043b50 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/SimpleActionOptionsProviderTest.php @@ -0,0 +1,43 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\SalesRule\Test\Unit\Model\Rule\Action; + +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\SalesRule\Model\Rule; +use Magento\SalesRule\Model\Rule\Action\SimpleActionOptionsProvider; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @covers Magento\SalesRule\Model\Rule\Action\SimpleActionOptionsProvider + */ +class SimpleActionOptionsProviderTest extends TestCase +{ + /** + * @var SimpleActionOptionsProvider|MockObject + */ + protected $model; + + protected function setUp() + { + $objectManager = new ObjectManager($this); + + $this->model = $objectManager->getObject(SimpleActionOptionsProvider::class); + } + + public function testToOptionArray() + { + $expected = [ + ['label' => __('Percent of product price discount'), 'value' => Rule::BY_PERCENT_ACTION], + ['label' => __('Fixed amount discount'), 'value' => Rule::BY_FIXED_ACTION], + ['label' => __('Fixed amount discount for whole cart'), 'value' => Rule::CART_FIXED_ACTION], + ['label' => __('Buy X get Y free (discount amount is Y)'), 'value' => Rule::BUY_X_GET_Y_ACTION] + ]; + + $this->assertEquals($expected, $this->model->toOptionArray()); + } +} diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Metadata/ValueProviderTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Metadata/ValueProviderTest.php index 0864b4a5e1480..d63ba150f4822 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Metadata/ValueProviderTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Metadata/ValueProviderTest.php @@ -5,52 +5,72 @@ */ namespace Magento\SalesRule\Test\Unit\Model\Rule\Metadata; +use Magento\Customer\Api\Data\GroupInterface; +use Magento\Customer\Api\Data\GroupSearchResultsInterface; +use Magento\Customer\Api\GroupRepositoryInterface; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\Convert\DataObject; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\SalesRule\Model\Rule; +use Magento\SalesRule\Model\Rule\Action\SimpleActionOptionsProvider; +use Magento\SalesRule\Model\Rule\Metadata\ValueProvider; +use Magento\SalesRule\Model\RuleFactory; +use Magento\Store\Model\System\Store; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; /** * @covers Magento\SalesRule\Model\Rule\Metadata\ValueProvider */ -class ValueProviderTest extends \PHPUnit\Framework\TestCase +class ValueProviderTest extends TestCase { /** - * @var \Magento\SalesRule\Model\Rule\Metadata\ValueProvider + * @var ValueProvider */ protected $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Store|MockObject */ protected $storeMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var GroupRepositoryInterface|MockObject */ protected $groupRepositoryMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var SearchCriteriaBuilder|MockObject */ protected $searchCriteriaBuilderMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var DataObject|MockObject */ protected $dataObjectMock; /** - * @var \Magento\SalesRule\Model\RuleFactory|\PHPUnit_Framework_MockObject_MockObject + * @var RuleFactory|MockObject */ protected $ruleFactoryMock; + /** + * @var SimpleActionOptionsProvider|MockObject + */ + private $simpleActionOptionsProviderMock; + protected function setUp() { - $this->searchCriteriaBuilderMock = $this->createMock(\Magento\Framework\Api\SearchCriteriaBuilder::class); - $this->storeMock = $this->createMock(\Magento\Store\Model\System\Store::class); - $this->groupRepositoryMock = $this->createMock(\Magento\Customer\Api\GroupRepositoryInterface::class); - $this->dataObjectMock = $this->createMock(\Magento\Framework\Convert\DataObject::class); - $searchCriteriaMock = $this->createMock(\Magento\Framework\Api\SearchCriteriaInterface::class); - $groupSearchResultsMock = $this->createMock(\Magento\Customer\Api\Data\GroupSearchResultsInterface::class); - $groupsMock = $this->createMock(\Magento\Customer\Api\Data\GroupInterface::class); + $expectedData = include __DIR__ . '/_files/MetaData.php'; + $this->searchCriteriaBuilderMock = $this->createMock(SearchCriteriaBuilder::class); + $this->storeMock = $this->createMock(Store::class); + $this->groupRepositoryMock = $this->createMock(GroupRepositoryInterface::class); + $this->dataObjectMock = $this->createMock(DataObject::class); + $this->simpleActionOptionsProviderMock = $this->createMock(SimpleActionOptionsProvider::class); + $searchCriteriaMock = $this->createMock(SearchCriteriaInterface::class); + $groupSearchResultsMock = $this->createMock(GroupSearchResultsInterface::class); + $groupsMock = $this->createMock(GroupInterface::class); $this->searchCriteriaBuilderMock->expects($this->once())->method('create')->willReturn($searchCriteriaMock); $this->groupRepositoryMock->expects($this->once())->method('getList')->with($searchCriteriaMock) @@ -59,15 +79,19 @@ protected function setUp() $this->storeMock->expects($this->once())->method('getWebsiteValuesForForm')->willReturn([]); $this->dataObjectMock->expects($this->once())->method('toOptionArray')->with([$groupsMock], 'id', 'code') ->willReturn([]); - $this->ruleFactoryMock = $this->createPartialMock(\Magento\SalesRule\Model\RuleFactory::class, ['create']); + $this->ruleFactoryMock = $this->createPartialMock(RuleFactory::class, ['create']); + $this->simpleActionOptionsProviderMock->method('toOptionArray')->willReturn( + $expectedData['actions']['children']['simple_action']['arguments']['data']['config']['options'] + ); $this->model = (new ObjectManager($this))->getObject( - \Magento\SalesRule\Model\Rule\Metadata\ValueProvider::class, + ValueProvider::class, [ 'store' => $this->storeMock, 'groupRepository' => $this->groupRepositoryMock, 'searchCriteriaBuilder' => $this->searchCriteriaBuilderMock, 'objectConverter' => $this->dataObjectMock, 'salesRuleFactory' => $this->ruleFactoryMock, + 'simpleActionOptionsProvider' => $this->simpleActionOptionsProviderMock ] ); } @@ -76,8 +100,8 @@ public function testGetMetadataValues() { $expectedData = include __DIR__ . '/_files/MetaData.php'; - /** @var \Magento\SalesRule\Model\Rule|\PHPUnit_Framework_MockObject_MockObject $ruleMock */ - $ruleMock = $this->createMock(\Magento\SalesRule\Model\Rule::class); + /** @var Rule|MockObject $ruleMock */ + $ruleMock = $this->createMock(Rule::class); $this->ruleFactoryMock->expects($this->once()) ->method('create') ->willReturn($ruleMock); diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php index 217a8dba273c4..4260e6b415091 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/RulesApplierTest.php @@ -6,55 +6,81 @@ namespace Magento\SalesRule\Test\Unit\Model; +use Magento\Framework\Event\Manager; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Address; +use Magento\Quote\Model\Quote\Item; +use Magento\Quote\Model\Quote\Item\AbstractItem; +use Magento\Rule\Model\Action\Collection; +use Magento\SalesRule\Model\Quote\ChildrenValidationLocator; +use Magento\SalesRule\Model\Rule; +use Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory; +use Magento\SalesRule\Model\Rule\Action\Discount\Data; +use Magento\SalesRule\Model\Rule\Action\Discount\DiscountInterface; +use Magento\SalesRule\Model\RulesApplier; +use Magento\SalesRule\Model\Utility; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class RulesApplierTest extends \PHPUnit\Framework\TestCase +class RulesApplierTest extends TestCase { /** - * @var \Magento\SalesRule\Model\RulesApplier + * @var RulesApplier */ protected $rulesApplier; /** - * @var \Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory|\PHPUnit_Framework_MockObject_MockObject + * @var CalculatorFactory|PHPUnit_Framework_MockObject_MockObject */ protected $calculatorFactory; /** - * @var \Magento\Framework\Event\Manager|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\SalesRule\Model\Rule\Action\Discount\DataFactory|\PHPUnit_Framework_MockObject_MockObject + */ + protected $discountFactory; + + /** + * @var Manager|PHPUnit_Framework_MockObject_MockObject */ protected $eventManager; /** - * @var \Magento\SalesRule\Model\Utility|\PHPUnit_Framework_MockObject_MockObject + * @var Utility|PHPUnit_Framework_MockObject_MockObject */ protected $validatorUtility; /** - * @var \Magento\SalesRule\Model\Quote\ChildrenValidationLocator|\PHPUnit_Framework_MockObject_MockObject + * @var ChildrenValidationLocator|PHPUnit_Framework_MockObject_MockObject */ protected $childrenValidationLocator; protected function setUp() { $this->calculatorFactory = $this->createMock( - \Magento\SalesRule\Model\Rule\Action\Discount\CalculatorFactory::class + CalculatorFactory::class + ); + $this->discountFactory = $this->createPartialMock( + \Magento\SalesRule\Model\Rule\Action\Discount\DataFactory::class, + ['create'] ); $this->eventManager = $this->createPartialMock(\Magento\Framework\Event\Manager::class, ['dispatch']); $this->validatorUtility = $this->createPartialMock( - \Magento\SalesRule\Model\Utility::class, + Utility::class, ['canProcessRule', 'minFix', 'deltaRoundingFix', 'getItemQty'] ); $this->childrenValidationLocator = $this->createPartialMock( - \Magento\SalesRule\Model\Quote\ChildrenValidationLocator::class, + ChildrenValidationLocator::class, ['isChildrenValidationRequired'] ); - $this->rulesApplier = new \Magento\SalesRule\Model\RulesApplier( + $this->rulesApplier = new RulesApplier( $this->calculatorFactory, $this->eventManager, $this->validatorUtility, - $this->childrenValidationLocator + $this->childrenValidationLocator, + $this->discountFactory ); } @@ -73,21 +99,36 @@ public function testApplyRulesWhenRuleWithStopRulesProcessingIsUsed($isChildren, $ruleId = 1; $appliedRuleIds = [$ruleId => $ruleId]; - + $discountData = $this->getMockBuilder(\Magento\SalesRule\Model\Rule\Action\Discount\Data::class) + ->setConstructorArgs( + [ + 'amount' => 0, + 'baseAmount' => 0, + 'originalAmount' => 0, + 'baseOriginalAmount' => 0 + ] + ) + ->getMock(); + $this->discountFactory->expects($this->any()) + ->method('create') + ->with($this->anything()) + ->will($this->returnValue($discountData)); /** - * @var \Magento\SalesRule\Model\Rule|\PHPUnit_Framework_MockObject_MockObject $ruleWithStopFurtherProcessing + * @var Rule|PHPUnit_Framework_MockObject_MockObject $ruleWithStopFurtherProcessing */ $ruleWithStopFurtherProcessing = $this->createPartialMock( - \Magento\SalesRule\Model\Rule::class, + Rule::class, ['getStoreLabel', 'getCouponType', 'getRuleId', '__wakeup', 'getActions'] ); - /** @var \Magento\SalesRule\Model\Rule|\PHPUnit_Framework_MockObject_MockObject $ruleThatShouldNotBeRun */ + /** + * @var Rule|PHPUnit_Framework_MockObject_MockObject $ruleThatShouldNotBeRun + */ $ruleThatShouldNotBeRun = $this->createPartialMock( - \Magento\SalesRule\Model\Rule::class, + Rule::class, ['getStopRulesProcessing', '__wakeup'] ); - $actionMock = $this->createPartialMock(\Magento\Rule\Model\Action\Collection::class, ['validate']); + $actionMock = $this->createPartialMock(Collection::class, ['validate']); $ruleWithStopFurtherProcessing->setName('ruleWithStopFurtherProcessing'); $ruleThatShouldNotBeRun->setName('ruleThatShouldNotBeRun'); @@ -140,6 +181,40 @@ public function testApplyRulesWhenRuleWithStopRulesProcessingIsUsed($isChildren, $this->assertEquals($appliedRuleIds, $result); } + public function testAddCouponDescriptionWithRuleDescriptionIsUsed() + { + $ruleId = 1; + $ruleDescription = 'Rule description'; + + /** + * @var Rule|PHPUnit_Framework_MockObject_MockObject $rule + */ + $rule = $this->createPartialMock( + Rule::class, + ['getStoreLabel', 'getCouponType', 'getRuleId', '__wakeup', 'getActions'] + ); + + $rule->setDescription($ruleDescription); + + /** + * @var Address|PHPUnit_Framework_MockObject_MockObject $address + */ + $address = $this->createPartialMock( + Address::class, + [ + 'getQuote', + 'setCouponCode', + 'setAppliedRuleIds', + '__wakeup' + ] + ); + $description = $address->getDiscountDescriptionArray(); + $description[$ruleId] = $rule->getDescription(); + $address->setDiscountDescriptionArray($description[$ruleId]); + + $this->assertEquals($address->getDiscountDescriptionArray(), $description[$ruleId]); + } + /** * @return array */ @@ -152,29 +227,48 @@ public function dataProviderChildren() } /** - * @return \Magento\Quote\Model\Quote\Item\AbstractItem|\PHPUnit_Framework_MockObject_MockObject + * @return AbstractItem|PHPUnit_Framework_MockObject_MockObject */ protected function getPreparedItem() { - /** @var \Magento\Quote\Model\Quote\Address|\PHPUnit_Framework_MockObject_MockObject $address */ - $address = $this->createPartialMock(\Magento\Quote\Model\Quote\Address::class, [ + /** + * @var Address|PHPUnit_Framework_MockObject_MockObject $address + */ + $address = $this->createPartialMock( + Address::class, + [ 'getQuote', 'setCouponCode', 'setAppliedRuleIds', '__wakeup' - ]); - /** @var \Magento\Quote\Model\Quote\Item\AbstractItem|\PHPUnit_Framework_MockObject_MockObject $item */ - $item = $this->createPartialMock(\Magento\Quote\Model\Quote\Item::class, [ + ] + ); + /** + * @var AbstractItem|PHPUnit_Framework_MockObject_MockObject $item + */ + $item = $this->createPartialMock( + Item::class, + [ 'setDiscountAmount', 'setBaseDiscountAmount', 'setDiscountPercent', 'getAddress', 'setAppliedRuleIds', '__wakeup', - 'getChildren' - ]); + 'getChildren', + 'getExtensionAttributes' + ] + ); + $itemExtension = $this->getMockBuilder( + \Magento\Framework\Api\ExtensionAttributesInterface::class + )->setMethods(['setDiscounts', 'getDiscounts'])->getMock(); + $itemExtension->method('getDiscounts')->willReturn([]); + $itemExtension->expects($this->any()) + ->method('setDiscounts') + ->willReturn([]); $quote = $this->createPartialMock(\Magento\Quote\Model\Quote::class, ['getStore', '__wakeUp']); $item->expects($this->any())->method('getAddress')->will($this->returnValue($address)); + $item->expects($this->any())->method('getExtensionAttributes')->will($this->returnValue($itemExtension)); $address->expects($this->any()) ->method('getQuote') ->will($this->returnValue($quote)); @@ -190,10 +284,10 @@ protected function applyRule($item, $rule) { $qty = 2; $discountCalc = $this->createPartialMock( - \Magento\SalesRule\Model\Rule\Action\Discount\DiscountInterface::class, + DiscountInterface::class, ['fixQuantity', 'calculate'] ); - $discountData = $this->getMockBuilder(\Magento\SalesRule\Model\Rule\Action\Discount\Data::class) + $discountData = $this->getMockBuilder(Data::class) ->setConstructorArgs( [ 'amount' => 30, diff --git a/app/code/Magento/SalesRule/etc/db_schema.xml b/app/code/Magento/SalesRule/etc/db_schema.xml index 5a4877bbf825e..e100121bea345 100644 --- a/app/code/Magento/SalesRule/etc/db_schema.xml +++ b/app/code/Magento/SalesRule/etc/db_schema.xml @@ -58,7 +58,7 @@ </table> <table name="salesrule_coupon" resource="default" engine="innodb" comment="Salesrule Coupon"> <column xsi:type="int" name="coupon_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Coupon Id"/> + comment="Coupon ID"/> <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Rule ID"/> <column xsi:type="varchar" name="code" nullable="true" length="255" comment="Code"/> @@ -94,9 +94,9 @@ </table> <table name="salesrule_coupon_usage" resource="default" engine="innodb" comment="Salesrule Coupon Usage"> <column xsi:type="int" name="coupon_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Coupon Id"/> + comment="Coupon ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="int" name="times_used" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Times Used"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -115,11 +115,11 @@ </table> <table name="salesrule_customer" resource="default" engine="innodb" comment="Salesrule Customer"> <column xsi:type="int" name="rule_customer_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Rule Customer Id"/> + comment="Rule Customer ID"/> <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Rule ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Customer Id"/> + default="0" comment="Customer ID"/> <column xsi:type="smallint" name="times_used" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Times Used"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -141,11 +141,11 @@ </table> <table name="salesrule_label" resource="default" engine="innodb" comment="Salesrule Label"> <column xsi:type="int" name="label_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Label Id"/> + comment="Label ID"/> <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Rule ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="label" nullable="true" length="255" comment="Label"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="label_id"/> @@ -166,11 +166,11 @@ <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Rule ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="int" name="customer_group_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Customer Group Id"/> + comment="Customer Group ID"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Attribute Id"/> + comment="Attribute ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_id"/> <column name="website_id"/> @@ -200,10 +200,10 @@ </index> </table> <table name="salesrule_coupon_aggregated" resource="sales" engine="innodb" comment="Coupon Aggregated"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" nullable="false" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="true" length="50" comment="Order Status"/> <column xsi:type="varchar" name="coupon_code" nullable="true" length="50" comment="Coupon Code"/> <column xsi:type="int" name="coupon_uses" padding="11" unsigned="false" nullable="false" identity="false" @@ -242,10 +242,10 @@ </table> <table name="salesrule_coupon_aggregated_updated" resource="sales" engine="innodb" comment="Salesrule Coupon Aggregated Updated"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" nullable="false" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="true" length="50" comment="Order Status"/> <column xsi:type="varchar" name="coupon_code" nullable="true" length="50" comment="Coupon Code"/> <column xsi:type="int" name="coupon_uses" padding="11" unsigned="false" nullable="false" identity="false" @@ -284,10 +284,10 @@ </table> <table name="salesrule_coupon_aggregated_order" resource="default" engine="innodb" comment="Coupon Aggregated Order"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" nullable="false" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="order_status" nullable="true" length="50" comment="Order Status"/> <column xsi:type="varchar" name="coupon_code" nullable="true" length="50" comment="Coupon Code"/> <column xsi:type="int" name="coupon_uses" padding="11" unsigned="false" nullable="false" identity="false" @@ -322,7 +322,7 @@ <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Rule ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Website Id"/> + comment="Website ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_id"/> <column name="website_id"/> @@ -341,7 +341,7 @@ <column xsi:type="int" name="rule_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Rule ID"/> <column xsi:type="int" name="customer_group_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Customer Group Id"/> + comment="Customer Group ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="rule_id"/> <column name="customer_group_id"/> diff --git a/app/code/Magento/SalesRule/etc/extension_attributes.xml b/app/code/Magento/SalesRule/etc/extension_attributes.xml new file mode 100644 index 0000000000000..202ced4204f73 --- /dev/null +++ b/app/code/Magento/SalesRule/etc/extension_attributes.xml @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd"> + <extension_attributes for="Magento\Quote\Api\Data\CartItemInterface"> + <attribute code="discounts" type="string" /> + </extension_attributes> +</config> \ No newline at end of file diff --git a/app/code/Magento/SalesSequence/etc/db_schema.xml b/app/code/Magento/SalesSequence/etc/db_schema.xml index 7ad48badf7b80..5ae72319c5a69 100644 --- a/app/code/Magento/SalesSequence/etc/db_schema.xml +++ b/app/code/Magento/SalesSequence/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="sales_sequence_profile" resource="sales" engine="innodb" comment="sales_sequence_profile" onCreate="skip-migration"> <column xsi:type="int" name="profile_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Id"/> + comment="ID"/> <column xsi:type="int" name="meta_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Meta_id"/> <column xsi:type="varchar" name="prefix" nullable="true" length="32" comment="Prefix"/> @@ -37,10 +37,10 @@ </table> <table name="sales_sequence_meta" resource="sales" engine="innodb" comment="sales_sequence_meta" onCreate="skip-migration"> <column xsi:type="int" name="meta_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Id"/> + comment="ID"/> <column xsi:type="varchar" name="entity_type" nullable="false" length="32" comment="Prefix"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="sequence_table" nullable="false" length="64" comment="table for sequence"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="meta_id"/> diff --git a/app/code/Magento/Search/Model/QueryFactory.php b/app/code/Magento/Search/Model/QueryFactory.php index 2122451742402..4186b5c3055f4 100644 --- a/app/code/Magento/Search/Model/QueryFactory.php +++ b/app/code/Magento/Search/Model/QueryFactory.php @@ -5,13 +5,15 @@ */ namespace Magento\Search\Model; -use Magento\Search\Helper\Data; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Helper\Context; use Magento\Framework\ObjectManagerInterface; use Magento\Framework\Stdlib\StringUtils as StdlibString; +use Magento\Search\Helper\Data; /** + * Search Query Factory + * * @api * @since 100.0.2 */ @@ -72,7 +74,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function get() { @@ -82,9 +84,7 @@ public function get() $rawQueryText = $this->getRawQueryText(); $preparedQueryText = $this->getPreparedQueryText($rawQueryText, $maxQueryLength); $query = $this->create()->loadByQueryText($preparedQueryText); - if (!$query->getId()) { - $query->setQueryText($preparedQueryText); - } + $query->setQueryText($preparedQueryText); $query->setIsQueryTextExceeded($this->isQueryTooLong($rawQueryText, $maxQueryLength)); $query->setIsQueryTextShort($this->isQueryTooShort($rawQueryText, $minQueryLength)); $this->query = $query; @@ -117,6 +117,8 @@ private function getRawQueryText() } /** + * Prepare query text + * * @param string $queryText * @param int|string $maxQueryLength * @return string @@ -130,6 +132,8 @@ private function getPreparedQueryText($queryText, $maxQueryLength) } /** + * Check if the provided text exceeds the provided length + * * @param string $queryText * @param int|string $maxQueryLength * @return bool @@ -140,6 +144,8 @@ private function isQueryTooLong($queryText, $maxQueryLength) } /** + * Check if the provided text is shorter than the provided length + * * @param string $queryText * @param int|string $minQueryLength * @return bool diff --git a/app/code/Magento/Search/Test/Mftf/Test/StorefrontVerifySearchSuggestionByProductDescriptionTest.xml b/app/code/Magento/Search/Test/Mftf/Test/StorefrontVerifySearchSuggestionByProductDescriptionTest.xml index 0ec33c48f259e..61a89b4610d6a 100644 --- a/app/code/Magento/Search/Test/Mftf/Test/StorefrontVerifySearchSuggestionByProductDescriptionTest.xml +++ b/app/code/Magento/Search/Test/Mftf/Test/StorefrontVerifySearchSuggestionByProductDescriptionTest.xml @@ -28,6 +28,7 @@ <!-- Delete all search terms --> <comment userInput="Delete all search terms" stepKey="deleteAllSearchTermsComment"/> <actionGroup ref="DeleteAllSearchTerms" stepKey="deleteAllSearchTerms"/> + <actionGroup ref="deleteAllProductsUsingProductGrid" stepKey="deleteAllProducts"/> <!-- Create product with description --> <comment userInput="Create product with description" stepKey="createProductWithDescriptionComment"/> <createData entity="SimpleProductWithDescription" stepKey="simpleProduct"/> diff --git a/app/code/Magento/Search/Test/Unit/Model/QueryFactoryTest.php b/app/code/Magento/Search/Test/Unit/Model/QueryFactoryTest.php index 3df457b0d4497..f66c1c7dd9e3f 100644 --- a/app/code/Magento/Search/Test/Unit/Model/QueryFactoryTest.php +++ b/app/code/Magento/Search/Test/Unit/Model/QueryFactoryTest.php @@ -5,14 +5,14 @@ */ namespace Magento\Search\Test\Unit\Model; -use Magento\Search\Helper\Data; use Magento\Framework\App\Helper\Context; use Magento\Framework\App\RequestInterface; use Magento\Framework\ObjectManagerInterface; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; -use Magento\Search\Model\QueryFactory; use Magento\Framework\Stdlib\StringUtils; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Search\Helper\Data; use Magento\Search\Model\Query; +use Magento\Search\Model\QueryFactory; /** * Class QueryFactoryTest tests Magento\Search\Model\QueryFactory @@ -67,7 +67,7 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); $this->query = $this->getMockBuilder(Query::class) - ->setMethods(['setIsQueryTextExceeded', 'setIsQueryTextShort', 'loadByQueryText', 'getId', 'setQueryText']) + ->setMethods(['setIsQueryTextExceeded', 'setIsQueryTextShort', 'loadByQueryText', 'getId']) ->disableOriginalConstructor() ->getMock(); @@ -124,7 +124,6 @@ public function testGetNewQuery() $isQueryTextExceeded = false; $isQueryTextShort = false; - $this->mockSetQueryTextNeverExecute($cleanedRawText); $this->mockString($cleanedRawText); $this->mockQueryLengths($maxQueryLength, $minQueryLength); $this->mockGetRawQueryText($rawQueryText); @@ -135,6 +134,7 @@ public function testGetNewQuery() $result = $this->model->get(); $this->assertSame($this->query, $result); + $this->assertSearchQuery($cleanedRawText); } /** @@ -150,7 +150,6 @@ public function testGetQueryTwice() $isQueryTextExceeded = false; $isQueryTextShort = false; - $this->mockSetQueryTextNeverExecute($cleanedRawText); $this->mockString($cleanedRawText); $this->mockQueryLengths($maxQueryLength, $minQueryLength); $this->mockGetRawQueryText($rawQueryText); @@ -163,6 +162,7 @@ public function testGetQueryTwice() $result = $this->model->get(); $this->assertSame($this->query, $result, 'After second execution queries are not same'); + $this->assertSearchQuery($cleanedRawText); } /** @@ -184,7 +184,6 @@ public function testGetTooLongQuery() ->withConsecutive([$cleanedRawText, 0, $maxQueryLength]) ->willReturn($subRawText); - $this->mockSetQueryTextNeverExecute($cleanedRawText); $this->mockString($cleanedRawText); $this->mockQueryLengths($maxQueryLength, $minQueryLength); $this->mockGetRawQueryText($rawQueryText); @@ -194,6 +193,7 @@ public function testGetTooLongQuery() $result = $this->model->get(); $this->assertSame($this->query, $result); + $this->assertSearchQuery($subRawText); } /** @@ -209,7 +209,6 @@ public function testGetTooShortQuery() $isQueryTextExceeded = false; $isQueryTextShort = true; - $this->mockSetQueryTextNeverExecute($cleanedRawText); $this->mockString($cleanedRawText); $this->mockQueryLengths($maxQueryLength, $minQueryLength); $this->mockGetRawQueryText($rawQueryText); @@ -219,6 +218,7 @@ public function testGetTooShortQuery() $result = $this->model->get(); $this->assertSame($this->query, $result); + $this->assertSearchQuery($cleanedRawText); } /** @@ -234,7 +234,6 @@ public function testGetQueryWithoutId() $isQueryTextExceeded = false; $isQueryTextShort = false; - $this->mockSetQueryTextOnceExecute($cleanedRawText); $this->mockString($cleanedRawText); $this->mockQueryLengths($maxQueryLength, $minQueryLength); $this->mockGetRawQueryText($rawQueryText); @@ -244,6 +243,35 @@ public function testGetQueryWithoutId() $result = $this->model->get(); $this->assertSame($this->query, $result); + $this->assertSearchQuery($cleanedRawText); + } + + /** + * Test for inaccurate match of search query in query_text table + * + * Because of inaccurate string comparison of utf8_general_ci, + * the search_query result text may be different from the original text (e.g organos, Organos, Órganos) + */ + public function testInaccurateQueryTextMatch() + { + $queryId = 1; + $maxQueryLength = 100; + $minQueryLength = 3; + $rawQueryText = 'Órganos'; + $cleanedRawText = 'Órganos'; + $isQueryTextExceeded = false; + $isQueryTextShort = false; + + $this->mockString($cleanedRawText); + $this->mockQueryLengths($maxQueryLength, $minQueryLength); + $this->mockGetRawQueryText($rawQueryText); + $this->mockSimpleQuery($cleanedRawText, $queryId, $isQueryTextExceeded, $isQueryTextShort, 'Organos'); + + $this->mockCreateQuery(); + + $result = $this->model->get(); + $this->assertSame($this->query, $result); + $this->assertSearchQuery($cleanedRawText); } /** @@ -305,15 +333,25 @@ private function mockCreateQuery() * @param int $queryId * @param bool $isQueryTextExceeded * @param bool $isQueryTextShort + * @param string $matchedQueryText * @return void */ - private function mockSimpleQuery($cleanedRawText, $queryId, $isQueryTextExceeded, $isQueryTextShort) - { + private function mockSimpleQuery( + string $cleanedRawText, + ?int $queryId, + bool $isQueryTextExceeded, + bool $isQueryTextShort, + string $matchedQueryText = null + ) { + if (null === $matchedQueryText) { + $matchedQueryText = $cleanedRawText; + } $this->query->expects($this->once()) ->method('loadByQueryText') ->withConsecutive([$cleanedRawText]) ->willReturnSelf(); - $this->query->expects($this->once()) + $this->query->setData(['query_text' => $matchedQueryText]); + $this->query->expects($this->any()) ->method('getId') ->willReturn($queryId); $this->query->expects($this->once()) @@ -328,23 +366,8 @@ private function mockSimpleQuery($cleanedRawText, $queryId, $isQueryTextExceeded * @param string $cleanedRawText * @return void */ - private function mockSetQueryTextNeverExecute($cleanedRawText) + private function assertSearchQuery($cleanedRawText) { - $this->query->expects($this->never()) - ->method('setQueryText') - ->withConsecutive([$cleanedRawText]) - ->willReturnSelf(); - } - - /** - * @param string $cleanedRawText - * @return void - */ - private function mockSetQueryTextOnceExecute($cleanedRawText) - { - $this->query->expects($this->once()) - ->method('setQueryText') - ->withConsecutive([$cleanedRawText]) - ->willReturnSelf(); + $this->assertEquals($cleanedRawText, $this->query->getQueryText()); } } diff --git a/app/code/Magento/Search/etc/db_schema.xml b/app/code/Magento/Search/etc/db_schema.xml index 754af7d246d6d..ab4b54298c2a3 100644 --- a/app/code/Magento/Search/etc/db_schema.xml +++ b/app/code/Magento/Search/etc/db_schema.xml @@ -49,12 +49,12 @@ </table> <table name="search_synonyms" resource="default" engine="innodb" comment="table storing various synonyms groups"> <column xsi:type="bigint" name="group_id" padding="20" unsigned="true" nullable="false" identity="true" - comment="Synonyms Group Id"/> + comment="Synonyms Group ID"/> <column xsi:type="text" name="synonyms" nullable="false" comment="list of synonyms making up this group"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id - identifies the store view these synonyms belong to"/> + default="0" comment="Store ID - identifies the store view these synonyms belong to"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website Id - identifies the website id these synonyms belong to"/> + default="0" comment="Website ID - identifies the website ID these synonyms belong to"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="group_id"/> </constraint> diff --git a/app/code/Magento/Security/etc/db_schema.xml b/app/code/Magento/Security/etc/db_schema.xml index ce7143582ce69..5052f5642cb53 100644 --- a/app/code/Magento/Security/etc/db_schema.xml +++ b/app/code/Magento/Security/etc/db_schema.xml @@ -10,7 +10,7 @@ <table name="admin_user_session" resource="default" engine="innodb" comment="Admin User sessions table"> <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/> - <column xsi:type="varchar" name="session_id" nullable="false" length="128" comment="Session id value"/> + <column xsi:type="varchar" name="session_id" nullable="false" length="128" comment="Session ID value"/> <column xsi:type="int" name="user_id" padding="10" unsigned="true" nullable="true" identity="false" comment="Admin User ID"/> <column xsi:type="smallint" name="status" padding="5" unsigned="true" nullable="false" identity="false" diff --git a/app/code/Magento/SendFriend/composer.json b/app/code/Magento/SendFriend/composer.json index f06f1b4a9e3e3..064b45e97d6c5 100644 --- a/app/code/Magento/SendFriend/composer.json +++ b/app/code/Magento/SendFriend/composer.json @@ -11,7 +11,8 @@ "magento/module-customer": "*", "magento/module-store": "*", "magento/module-captcha": "*", - "magento/module-authorization": "*" + "magento/module-authorization": "*", + "magento/module-theme": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Shipping/Test/Mftf/Data/FlatRateShippingMethodData.xml b/app/code/Magento/Shipping/Test/Mftf/Data/FlatRateShippingMethodData.xml index 6d877dac5cbf4..6ab1d71933826 100644 --- a/app/code/Magento/Shipping/Test/Mftf/Data/FlatRateShippingMethodData.xml +++ b/app/code/Magento/Shipping/Test/Mftf/Data/FlatRateShippingMethodData.xml @@ -51,7 +51,7 @@ <data key="value">5.00</data> </entity> <entity name="flatRateHandlingFeeDefault" type="handling_fee"> - <data key="value">F</data> + <data key="value">0</data> </entity> <entity name="flatRateSpecificerrmsgDefault" type="specificerrmsg"> <data key="value">This shipping method is not available. To use this shipping method, please contact us.</data> diff --git a/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Generate.php b/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Generate.php index 8eeeb5bf6bc12..5cfc7349888f3 100644 --- a/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Generate.php +++ b/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Generate.php @@ -49,7 +49,13 @@ public function execute() // if sitemap record exists if ($sitemap->getId()) { try { + $this->appEmulation->startEnvironmentEmulation( + $sitemap->getStoreId(), + \Magento\Framework\App\Area::AREA_FRONTEND, + true + ); $sitemap->generateXml(); + $this->appEmulation->stopEnvironmentEmulation(); $this->messageManager->addSuccessMessage( __('The sitemap "%1" has been generated.', $sitemap->getSitemapFilename()) ); diff --git a/app/code/Magento/Sitemap/etc/db_schema.xml b/app/code/Magento/Sitemap/etc/db_schema.xml index b3c028b626b73..adf1f11124f52 100644 --- a/app/code/Magento/Sitemap/etc/db_schema.xml +++ b/app/code/Magento/Sitemap/etc/db_schema.xml @@ -9,13 +9,13 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="sitemap" resource="default" engine="innodb" comment="XML Sitemap"> <column xsi:type="int" name="sitemap_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Sitemap Id"/> + comment="Sitemap ID"/> <column xsi:type="varchar" name="sitemap_type" nullable="true" length="32" comment="Sitemap Type"/> <column xsi:type="varchar" name="sitemap_filename" nullable="true" length="32" comment="Sitemap Filename"/> <column xsi:type="varchar" name="sitemap_path" nullable="true" length="255" comment="Sitemap Path"/> <column xsi:type="timestamp" name="sitemap_time" on_update="false" nullable="true" comment="Sitemap Time"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store id"/> + default="0" comment="Store ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="sitemap_id"/> </constraint> diff --git a/app/code/Magento/Store/Model/Store.php b/app/code/Magento/Store/Model/Store.php index 0bc371da0aab9..faa26b24a5505 100644 --- a/app/code/Magento/Store/Model/Store.php +++ b/app/code/Magento/Store/Model/Store.php @@ -53,7 +53,7 @@ class Store extends AbstractExtensibleModel implements const ENTITY = 'store'; /** - * Custom entry point param + * Parameter used to determine app context. */ const CUSTOM_ENTRY_POINT_PARAM = 'custom_entry_point'; @@ -104,7 +104,7 @@ class Store extends AbstractExtensibleModel implements const ADMIN_CODE = 'admin'; /** - * Cache tag + * Tag to use to cache stores. */ const CACHE_TAG = 'store'; @@ -423,6 +423,9 @@ public function __construct( /** * @inheritdoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -435,6 +438,9 @@ public function __sleep() * Init not serializable fields * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml index 94856bb083da8..63dc4b0ded4f9 100644 --- a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml @@ -18,6 +18,7 @@ <amOnPage url="{{AdminSystemStorePage.url}}" stepKey="navigateToStoresIndex"/> <waitForPageLoad stepKey="waitStoreIndexPageLoad"/> + <click selector="{{AdminStoresGridSection.resetButton}}" stepKey="resetSearchFilter"/> <fillField selector="{{AdminStoresGridSection.storeFilterTextField}}" userInput="{{customStore.name}}" stepKey="fillStoreViewFilterField"/> <click selector="{{AdminStoresGridSection.searchButton}}" stepKey="clickSearch"/> <click selector="{{AdminStoresGridSection.storeNameInFirstRow}}" stepKey="clickStoreViewInGrid"/> diff --git a/app/code/Magento/Store/etc/db_schema.xml b/app/code/Magento/Store/etc/db_schema.xml index 6eea94b8deec7..5b2e178ff24b8 100644 --- a/app/code/Magento/Store/etc/db_schema.xml +++ b/app/code/Magento/Store/etc/db_schema.xml @@ -9,13 +9,13 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="store_website" resource="default" engine="innodb" comment="Websites"> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Website Id"/> + comment="Website ID"/> <column xsi:type="varchar" name="code" nullable="true" length="32" comment="Code"/> <column xsi:type="varchar" name="name" nullable="true" length="64" comment="Website Name"/> <column xsi:type="smallint" name="sort_order" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Sort Order"/> <column xsi:type="smallint" name="default_group_id" padding="5" unsigned="true" nullable="false" - identity="false" default="0" comment="Default Group Id"/> + identity="false" default="0" comment="Default Group ID"/> <column xsi:type="smallint" name="is_default" padding="5" unsigned="true" nullable="true" identity="false" default="0" comment="Defines Is Website Default"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -33,14 +33,14 @@ </table> <table name="store_group" resource="default" engine="innodb" comment="Store Groups"> <column xsi:type="smallint" name="group_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Group Id"/> + comment="Group ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website Id"/> + default="0" comment="Website ID"/> <column xsi:type="varchar" name="name" nullable="false" length="255" comment="Store Group Name"/> <column xsi:type="int" name="root_category_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Root Category Id"/> + default="0" comment="Root Category ID"/> <column xsi:type="smallint" name="default_store_id" padding="5" unsigned="true" nullable="false" - identity="false" default="0" comment="Default Store Id"/> + identity="false" default="0" comment="Default Store ID"/> <column xsi:type="varchar" name="code" nullable="true" length="32" comment="Store group unique code"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="group_id"/> @@ -59,12 +59,12 @@ </table> <table name="store" resource="default" engine="innodb" comment="Stores"> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="true" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="code" nullable="true" length="32" comment="Code"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website Id"/> + default="0" comment="Website ID"/> <column xsi:type="smallint" name="group_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Group Id"/> + default="0" comment="Group ID"/> <column xsi:type="varchar" name="name" nullable="false" length="255" comment="Store Name"/> <column xsi:type="smallint" name="sort_order" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Store Sort Order"/> diff --git a/app/code/Magento/StoreGraphQl/etc/graphql/di.xml b/app/code/Magento/StoreGraphQl/etc/graphql/di.xml index 3a0143821d8b9..f3771b704c3e9 100644 --- a/app/code/Magento/StoreGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/StoreGraphQl/etc/graphql/di.xml @@ -23,4 +23,11 @@ </argument> </arguments> </type> + <type name="Magento\StoreGraphQl\Model\Resolver\Store\StoreConfigDataProvider"> + <arguments> + <argument name="extendedConfigData" xsi:type="array"> + <item name="store_name" xsi:type="string">store/information/name</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/StoreGraphQl/etc/schema.graphqls b/app/code/Magento/StoreGraphQl/etc/schema.graphqls index 376635e5c8f75..aaef3aa13dbaf 100644 --- a/app/code/Magento/StoreGraphQl/etc/schema.graphqls +++ b/app/code/Magento/StoreGraphQl/etc/schema.graphqls @@ -30,4 +30,5 @@ type StoreConfig @doc(description: "The type contains information about a store secure_base_link_url : String @doc(description: "Secure base link URL for the store") secure_base_static_url : String @doc(description: "Secure base static URL for the store") secure_base_media_url : String @doc(description: "Secure base media URL for the store") + store_name : String @doc(description: "Name of the store") } diff --git a/app/code/Magento/Swatches/Block/Product/Renderer/Configurable.php b/app/code/Magento/Swatches/Block/Product/Renderer/Configurable.php index 0848f566f67bb..a2cae7f7b5a20 100644 --- a/app/code/Magento/Swatches/Block/Product/Renderer/Configurable.php +++ b/app/code/Magento/Swatches/Block/Product/Renderer/Configurable.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types = 1); namespace Magento\Swatches\Block\Product\Renderer; use Magento\Catalog\Block\Product\Context; @@ -57,6 +58,11 @@ class Configurable extends \Magento\ConfigurableProduct\Block\Product\View\Type\ */ const SWATCH_THUMBNAIL_NAME = 'swatchThumb'; + /** + * Config path which contains number of swatches per product + */ + private const XML_PATH_SWATCHES_PER_PRODUCT = 'catalog/frontend/swatches_per_product'; + /** * @var Product */ @@ -200,7 +206,7 @@ public function getJsonSwatchConfig() public function getNumberSwatchesPerProduct() { return $this->_scopeConfig->getValue( - 'catalog/frontend/swatches_per_product', + self::XML_PATH_SWATCHES_PER_PRODUCT, ScopeInterface::SCOPE_STORE ); } diff --git a/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php b/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php index ebbb1775aa7f8..0acd7ef315700 100644 --- a/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php +++ b/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php @@ -14,6 +14,8 @@ /** * Plugin model for Catalog Resource Attribute + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class EavAttribute { @@ -29,6 +31,11 @@ class EavAttribute */ const BASE_OPTION_TITLE = 'option'; + /** + * Prefix added to option value added through API + */ + private const API_OPTION_PREFIX = 'id_'; + /** * @var \Magento\Swatches\Model\ResourceModel\Swatch\CollectionFactory */ @@ -189,7 +196,9 @@ protected function processSwatchOptions(Attribute $attribute) if (!empty($optionsArray) && is_array($optionsArray)) { $optionsArray = $this->prepareOptionIds($optionsArray); - $attributeSavedOptions = $attribute->getSource()->getAllOptions(); + $adminStoreAttribute = clone $attribute; + $adminStoreAttribute->setStoreId(self::DEFAULT_STORE_ID); + $attributeSavedOptions = $adminStoreAttribute->getSource()->getAllOptions(); $this->prepareOptionLinks($optionsArray, $attributeSavedOptions); } @@ -227,10 +236,9 @@ protected function prepareOptionLinks(array $optionsArray, array $attributeSaved { $dependencyArray = []; if (is_array($optionsArray['value'])) { - $optionCounter = 1; - foreach (array_keys($optionsArray['value']) as $baseOptionId) { - $dependencyArray[$baseOptionId] = $attributeSavedOptions[$optionCounter]['value']; - $optionCounter++; + $options = array_column($attributeSavedOptions, 'value', 'label'); + foreach ($optionsArray['value'] as $id => $labels) { + $dependencyArray[$id] = $options[$labels[self::DEFAULT_STORE_ID]]; } } @@ -285,7 +293,7 @@ protected function processVisualSwatch(Attribute $attribute) * Clean swatch option values after switching to the dropdown type. * * @param array $attributeOptions - * @param int|null $swatchType + * @param int|null $swatchType * @throws \Magento\Framework\Exception\LocalizedException */ private function cleanEavAttributeOptionSwatchValues(array $attributeOptions, int $swatchType = null) @@ -309,6 +317,8 @@ private function cleanTextSwatchValuesAfterSwitch(array $attributeOptions) } /** + * Get the visual swatch type based on its value + * * @param string $value * @return int */ @@ -368,7 +378,7 @@ protected function processTextualSwatch(Attribute $attribute) */ protected function getAttributeOptionId($optionId) { - if (substr($optionId, 0, 6) == self::BASE_OPTION_TITLE) { + if (substr($optionId, 0, 6) == self::BASE_OPTION_TITLE || substr($optionId, 0, 3) == self::API_OPTION_PREFIX) { $optionId = isset($this->dependencyArray[$optionId]) ? $this->dependencyArray[$optionId] : null; } return $optionId; @@ -447,13 +457,10 @@ protected function saveDefaultSwatchOptionValue(Attribute $attribute) if (!empty($defaultValue)) { /** @var \Magento\Swatches\Model\Swatch $swatch */ $swatch = $this->swatchFactory->create(); - // created and removed on frontend option not exists in dependency array - if (substr($defaultValue, 0, 6) == self::BASE_OPTION_TITLE && - isset($this->dependencyArray[$defaultValue]) - ) { - $defaultValue = $this->dependencyArray[$defaultValue]; - } - $swatch->getResource()->saveDefaultSwatchOption($attribute->getId(), $defaultValue); + $swatch->getResource()->saveDefaultSwatchOption( + $attribute->getId(), + $this->getAttributeOptionId($defaultValue) + ); } } @@ -503,6 +510,10 @@ protected function isOptionsValid(array $options, Attribute $attribute) } /** + * Modifies Attribute::usesSource() response + * + * Returns true if attribute type is swatch + * * @param Attribute $attribute * @param bool $result * @return bool diff --git a/app/code/Magento/Swatches/Plugin/Eav/Model/Entity/Attribute/OptionManagement.php b/app/code/Magento/Swatches/Plugin/Eav/Model/Entity/Attribute/OptionManagement.php new file mode 100644 index 0000000000000..795c48f12ebcc --- /dev/null +++ b/app/code/Magento/Swatches/Plugin/Eav/Model/Entity/Attribute/OptionManagement.php @@ -0,0 +1,92 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types = 1); + +namespace Magento\Swatches\Plugin\Eav\Model\Entity\Attribute; + +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Eav\Model\AttributeRepository; +use Magento\Store\Model\Store; +use Magento\Swatches\Helper\Data; + +/** + * OptionManagement Plugin + */ +class OptionManagement +{ + /** + * @var AttributeRepository + */ + private $attributeRepository; + /** + * @var Data + */ + private $swatchHelper; + + /** + * @param AttributeRepository $attributeRepository + * @param Data $swatchHelper + */ + public function __construct( + AttributeRepository $attributeRepository, + Data $swatchHelper + ) { + $this->attributeRepository = $attributeRepository; + $this->swatchHelper = $swatchHelper; + } + + /** + * Add swatch value to the attribute option + * + * @param \Magento\Catalog\Model\Product\Attribute\OptionManagement $subject + * @param string $attributeCode + * @param \Magento\Eav\Api\Data\AttributeOptionInterface $option + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeAdd( + \Magento\Catalog\Model\Product\Attribute\OptionManagement $subject, + ?string $attributeCode, + \Magento\Eav\Api\Data\AttributeOptionInterface $option + ) { + if (empty($attributeCode)) { + return; + } + $attribute = $this->attributeRepository->get( + ProductAttributeInterface::ENTITY_TYPE_CODE, + $attributeCode + ); + if (!$attribute || !$this->swatchHelper->isSwatchAttribute($attribute)) { + return; + } + $optionId = $this->getOptionId($option); + $optionsValue = $option->getValue(); + if ($this->swatchHelper->isVisualSwatch($attribute)) { + $attribute->setData('swatchvisual', ['value' => [$optionId => $optionsValue]]); + } else { + $options = []; + $options['value'][$optionId][Store::DEFAULT_STORE_ID] = $optionsValue; + if (is_array($option->getStoreLabels())) { + foreach ($option->getStoreLabels() as $label) { + if (!isset($options['value'][$optionId][$label->getStoreId()])) { + $options['value'][$optionId][$label->getStoreId()] = null; + } + } + } + $attribute->setData('swatchtext', $options); + } + } + + /** + * Returns option id + * + * @param \Magento\Eav\Api\Data\AttributeOptionInterface $option + * @return string + */ + private function getOptionId(\Magento\Eav\Api\Data\AttributeOptionInterface $option) : string + { + return 'id_' . ($option->getValue() ?: 'new_option'); + } +} diff --git a/app/code/Magento/Swatches/Test/Mftf/Section/StorefrontProductInfoMainSection.xml b/app/code/Magento/Swatches/Test/Mftf/Section/StorefrontProductInfoMainSection.xml index 6fdf2276f39d9..5b714f01fd46f 100644 --- a/app/code/Magento/Swatches/Test/Mftf/Section/StorefrontProductInfoMainSection.xml +++ b/app/code/Magento/Swatches/Test/Mftf/Section/StorefrontProductInfoMainSection.xml @@ -16,5 +16,6 @@ <element name="nthSwatchOptionText" type="button" selector="div.swatch-option.text:nth-of-type({{n}})" parameterized="true"/> <element name="productSwatch" type="button" selector="//div[@class='swatch-option'][@aria-label='{{var1}}']" parameterized="true"/> <element name="visualSwatchOption" type="button" selector=".swatch-option[option-tooltip-value='#{{visualSwatchOption}}']" parameterized="true"/> + <element name="swatchOptionTooltip" type="block" selector="div.swatch-option-tooltip"/> </section> </sections> diff --git a/app/code/Magento/Swatches/Test/Mftf/Test/AdminDisablingSwatchTooltipsTest.xml b/app/code/Magento/Swatches/Test/Mftf/Test/AdminDisablingSwatchTooltipsTest.xml new file mode 100644 index 0000000000000..3d69895b0c895 --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Test/AdminDisablingSwatchTooltipsTest.xml @@ -0,0 +1,160 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminDisablingSwatchTooltipsTest"> + <annotations> + <features value="Swatches"/> + <title value="Admin disabling swatch tooltips test."/> + <description value="Verify possibility to disable/enable swatch tooltips."/> + <severity value="AVERAGE"/> + <group value="Swatches"/> + </annotations> + <before> + <!-- Create category --> + <createData entity="ApiCategory" stepKey="createCategory"/> + + <!-- Log in --> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <!-- Clean up our modifications to the existing color attribute --> + <amOnPage url="{{AdminProductAttributeGridPage.url}}" stepKey="goToProductAttributes"/> + <waitForPageLoad stepKey="waitForProductAttributes"/> + <fillField selector="{{AdminProductAttributeGridSection.FilterByAttributeCode}}" userInput="color" + stepKey="fillFilter"/> + <click selector="{{AdminProductAttributeGridSection.Search}}" stepKey="clickSearch"/> + <click selector="{{AdminProductAttributeGridSection.AttributeCode('color')}}" stepKey="clickRowToEdit"/> + <click selector="{{AdminManageSwatchSection.nthDelete('1')}}" stepKey="deleteSwatch1"/> + <waitForPageLoad stepKey="waitToClickSave"/> + <click selector="{{AttributePropertiesSection.SaveAndEdit}}" stepKey="clickSaveAndEdit"/> + + <!-- Log out --> + <actionGroup ref="logout" stepKey="logOut"/> + + <!-- Delete category --> + <deleteData stepKey="deleteCategory" createDataKey="createCategory"/> + + <!-- Enable swatch tooltips --> + <magentoCLI command="config:set catalog/frontend/show_swatch_tooltip 1" stepKey="disableTooltips"/> + <magentoCLI command="cache:flush" stepKey="flushCacheAfterEnabling"/> + </after> + + <!-- Go to the edit page for the "color" attribute --> + <amOnPage url="{{AdminProductAttributeGridPage.url}}" stepKey="goToProductAttributes"/> + <waitForPageLoad stepKey="waitForProductAttributes"/> + <fillField selector="{{AdminProductAttributeGridSection.FilterByAttributeCode}}" userInput="color" + stepKey="fillFilter"/> + <click selector="{{AdminProductAttributeGridSection.Search}}" stepKey="clickSearch"/> + <click selector="{{AdminProductAttributeGridSection.AttributeCode('color')}}" stepKey="clickRowToEdit"/> + + <!-- Change to visual swatches --> + <selectOption selector="{{AdminNewAttributePanel.inputType}}" userInput="swatch_visual" + stepKey="selectVisualSwatch"/> + + <!-- Set swatch using the color picker --> + <click selector="{{AdminManageSwatchSection.addSwatch}}" stepKey="clickAddSwatch1"/> + <actionGroup ref="openSwatchMenuByIndex" stepKey="clickSwatch1"> + <argument name="index" value="0"/> + </actionGroup> + <click selector="{{AdminManageSwatchSection.nthChooseColor('1')}}" stepKey="clickChooseColor1"/> + <actionGroup ref="setColorPickerByHex" stepKey="fillHex1"> + <argument name="nthColorPicker" value="1"/> + <argument name="hexColor" value="e74c3c"/> + </actionGroup> + <fillField selector="{{AdminManageSwatchSection.adminInputByIndex('0')}}" userInput="red" stepKey="fillAdmin1"/> + <waitForPageLoad stepKey="waitToClickSave"/> + + <!-- Save --> + <click selector="{{AttributePropertiesSection.SaveAndEdit}}" stepKey="clickSaveAndEdit1"/> + <waitForElementVisible selector="{{AdminProductMessagesSection.successMessage}}" stepKey="waitForSuccess"/> + + <!-- Assert that the Save was successful after round trip to server --> + <actionGroup ref="assertSwatchColor" stepKey="assertSwatchAdmin"> + <argument name="nthSwatch" value="1"/> + <argument name="expectedStyle" value="background: rgb(231, 77, 60);"/> + </actionGroup> + + <!-- Create a configurable product to verify the storefront with --> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="amOnProductGridPage"/> + <waitForPageLoad stepKey="waitForProductGridPage"/> + <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="clickOnAddProductToggle"/> + <click selector="{{AdminProductGridActionSection.addConfigurableProduct}}" + stepKey="clickOnAddConfigurableProduct"/> + <fillField userInput="{{_defaultProduct.name}}" selector="{{AdminProductFormSection.productName}}" + stepKey="fillName"/> + <fillField userInput="{{_defaultProduct.sku}}" selector="{{AdminProductFormSection.productSku}}" + stepKey="fillSKU"/> + <fillField userInput="{{_defaultProduct.price}}" selector="{{AdminProductFormSection.productPrice}}" + stepKey="fillPrice"/> + <fillField userInput="{{_defaultProduct.quantity}}" selector="{{AdminProductFormSection.productQuantity}}" + stepKey="fillQuantity"/> + <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" + parameterArray="[$$createCategory.name$$]" stepKey="fillCategory"/> + <click selector="{{AdminProductSEOSection.sectionHeader}}" stepKey="openSeoSection"/> + <fillField userInput="{{_defaultProduct.urlKey}}" selector="{{AdminProductSEOSection.urlKeyInput}}" + stepKey="fillUrlKey"/> + + <!-- Create configurations based on the Swatch we created earlier --> + <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" + stepKey="clickCreateConfigurations"/> + <click selector="{{AdminCreateProductConfigurationsPanel.filters}}" stepKey="clickFilters"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.attributeCode}}" userInput="color" + stepKey="fillFilterAttributeCodeField"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applyFilters}}" stepKey="clickApplyFiltersButton"/> + <click selector="{{AdminCreateProductConfigurationsPanel.firstCheckbox}}" stepKey="clickOnFirstCheckbox"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton1"/> + <click selector="{{AdminCreateProductConfigurationsPanel.selectAll}}" stepKey="clickOnSelectAll"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton2"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applyUniquePricesByAttributeToEachSku}}" + stepKey="clickOnApplyUniquePricesByAttributeToEachSku"/> + <selectOption selector="{{AdminCreateProductConfigurationsPanel.selectAttribute}}" userInput="Color" + stepKey="selectAttributes"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.attribute1}}" userInput="10" + stepKey="fillAttributePrice1"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" + stepKey="clickOnApplySingleQuantityToEachSku"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.quantity}}" userInput="99" + stepKey="enterAttributeQuantity"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton3"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton4"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickOnSaveButton2"/> + <conditionalClick selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" + dependentSelector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" visible="true" + stepKey="clickOnConfirmInPopup"/> + <seeElement selector="{{AdminProductMessagesSection.successMessage}}" stepKey="seeSaveProductMessage"/> + <seeInTitle userInput="{{_defaultProduct.name}}" stepKey="seeProductNameInTitle"/> + + <!-- Go to the product page and see swatch options --> + <amOnPage url="{{_defaultProduct.urlKey}}.html" stepKey="amOnProductPage"/> + <waitForPageLoad stepKey="waitForProductPage"/> + + <!-- Verify that the storefront shows the swatches too --> + <actionGroup ref="assertStorefrontSwatchColor" stepKey="assertSwatchStorefront"> + <argument name="nthSwatch" value="1"/> + <argument name="expectedRgb" value="rgb(231, 77, 60)"/> + </actionGroup> + + <!-- Verify swatch tooltips are visible--> + <moveMouseOver selector="{{StorefrontProductInfoMainSection.nthSwatchOption('1')}}" stepKey="hoverEnabledSwatch"/> + <wait time="1" stepKey="waitForTooltip1"/> + <seeElement selector="{{StorefrontProductInfoMainSection.swatchOptionTooltip}}" stepKey="swatchTooltipVisible"/> + + <!-- Disable swatch tooltips --> + <magentoCLI command="config:set catalog/frontend/show_swatch_tooltip 0" stepKey="disableTooltips"/> + <magentoCLI command="cache:flush" stepKey="flushCacheAfterDisabling"/> + + <!-- Verify swatch tooltips are not visible --> + <reloadPage stepKey="refreshPage"/> + <waitForPageLoad stepKey="waitForPageReload"/> + <moveMouseOver selector="{{StorefrontProductInfoMainSection.nthSwatchOption('1')}}" stepKey="hoverDisabledSwatch"/> + <wait time="1" stepKey="waitForTooltip2"/> + <dontSeeElement selector="{{StorefrontProductInfoMainSection.swatchOptionTooltip}}" stepKey="swatchTooltipNotVisible"/> + </test> +</tests> diff --git a/app/code/Magento/Swatches/Test/Mftf/Test/AdminSaveConfigurableProductWithAttributesImagesAndSwatchesTest.xml b/app/code/Magento/Swatches/Test/Mftf/Test/AdminSaveConfigurableProductWithAttributesImagesAndSwatchesTest.xml new file mode 100644 index 0000000000000..f94314fe94806 --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Test/AdminSaveConfigurableProductWithAttributesImagesAndSwatchesTest.xml @@ -0,0 +1,110 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminSaveConfigurableProductWithAttributesImagesAndSwatchesTest"> + <annotations> + <features value="Catalog"/> + <stories value="Product attributes"/> + <title value="Saving configurable product with custom product attribute (images as swatches)"/> + <description value="Saving configurable product with custom product attribute (images as swatches)"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-13641"/> + <group value="catalog"/> + </annotations> + <before> + <!-- Login as admin --> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + + <!-- Create a new product attribute --> + <actionGroup ref="AdminOpenProductAttributePageActionGroup" stepKey="openProductAttributePage"/> + <click selector="{{AdminProductAttributeGridSection.createNewAttributeBtn}}" stepKey="createNewAttribute"/> + <!-- Set Catalog Input Type for Store Owner: Visual Swatch --> + <actionGroup ref="AdminFillProductAttributePropertiesActionGroup" stepKey="fillAttributeProperties"> + <argument name="attributeName" value="{{VisualSwatchProductAttribute.attribute_code}}"/> + <argument name="attributeType" value="{{VisualSwatchProductAttribute.frontend_input}}"/> + </actionGroup> + <!-- Add a few Swatches and add images to Manage Swatch (Values of Your Attribute) + 1. Set swatch #1 using the color picker --> + <click selector="{{AdminManageSwatchSection.addSwatch}}" stepKey="clickAddFirstSwatch"/> + <actionGroup ref="openSwatchMenuByIndex" stepKey="clickFirstSwatch"> + <argument name="index" value="0"/> + </actionGroup> + <click selector="{{AdminManageSwatchSection.nthChooseColor('1')}}" stepKey="clickChooseColor"/> + <actionGroup ref="setColorPickerByHex" stepKey="fillFirstHex"> + <argument name="nthColorPicker" value="1"/> + <argument name="hexColor" value="e74c3c"/> + </actionGroup> + <fillField selector="{{AdminManageSwatchSection.adminInputByIndex('0')}}" userInput="red" stepKey="fillFirstAdminField"/> + <!-- Set swatch #2 using upload file --> + <click selector="{{AdminManageSwatchSection.addSwatch}}" stepKey="clickAddSecondSwatch"/> + <actionGroup ref="openSwatchMenuByIndex" stepKey="clickSwatch2"> + <argument name="index" value="1"/> + </actionGroup> + <click selector="{{AdminManageSwatchSection.nthUploadFile('2')}}" stepKey="clickUploadFile2"/> + <attachFile selector="input[name='datafile']" userInput="{{placeholderSmallImage.file}}" stepKey="attachFile"/> + <fillField selector="{{AdminManageSwatchSection.adminInputByIndex('1')}}" userInput="adobe-small" stepKey="fillAdminLabel"/> + <!-- Set Scope: Global in Advanced Attribute Properties --> + <click selector="{{AttributePropertiesSection.AdvancedProperties}}" stepKey="expandAdvancedProperties"/> + <selectOption selector="{{AttributePropertiesSection.Scope}}" userInput="1" stepKey="selectGlobalScope"/> + <!-- Click "Save Attribute" button --> + <click selector="{{AttributePropertiesSection.SaveAndEdit}}" stepKey="clickSaveAndEdit"/> + <waitForElementVisible selector="{{AdminProductMessagesSection.successMessage}}" stepKey="waitForSuccessMessage"/> + </before> + <after> + <!-- Delete product attribute and clear grid filter --> + <actionGroup ref="deleteProductAttributeByAttributeCode" stepKey="deleteProductAttribute"> + <argument name="ProductAttributeCode" value="{{VisualSwatchProductAttribute.attribute_code}}"/> + </actionGroup> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearAttributesGridFilter"/> + <!--Clear products grid filter--> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="openProductIndexPage"/> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearProductsGridFilter"/> + <!-- Admin logout --> + <actionGroup ref="logout" stepKey="adminLogout"/> + </after> + + <!-- Add created product attribute to the Default set --> + <actionGroup ref="AdminOpenAttributeSetGridPageActionGroup" stepKey="openAttributeSetPage"/> + <actionGroup ref="AdminOpenAttributeSetByNameActionGroup" stepKey="openDefaultAttributeSet"/> + <actionGroup ref="AssignAttributeToGroup" stepKey="assignAttributeToGroup"> + <argument name="group" value="Product Details"/> + <argument name="attribute" value="{{VisualSwatchProductAttribute.attribute_code}}"/> + </actionGroup> + <actionGroup ref="SaveAttributeSet" stepKey="saveAttributeSet"/> + + <!-- Create configurable product --> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="openProductIndexPage"/> + <actionGroup ref="goToCreateProductPage" stepKey="goToCreateConfigurableProduct"> + <argument name="product" value="ApiConfigurableProduct"/> + </actionGroup> + <!-- Fill all the necessary information such as weight, name, SKU etc --> + <actionGroup ref="fillMainProductForm" stepKey="fillProductForm"> + <argument name="product" value="ApiConfigurableProduct"/> + </actionGroup> + <!-- Click "Create Configurations" button, select created product attribute using the same Quantity for all products. Click "Generate products" button --> + <actionGroup ref="generateConfigurationsByAttributeCode" stepKey="addAttributeToProduct"> + <argument name="attributeCode" value="{{VisualSwatchProductAttribute.attribute_code}}"/> + </actionGroup> + <!-- Using this action to concatenate 2 strings to have unique identifier for grid --> + <executeJS function="return '{{VisualSwatchProductAttribute.attribute_code}}: red'" stepKey="attributeCodeRed"/> + <executeJS function="return '{{VisualSwatchProductAttribute.attribute_code}}: {{placeholderSmallImage.name}}'" stepKey="attributeCodeAdobeSmall"/> + <!-- Add images for the products --> + <attachFile selector="{{AdminDataGridTableSection.rowTemplate({$attributeCodeRed})}}{{AdminProductFormConfigurationsSection.fileUploaderInput}}" userInput="{{MagentoLogo.file}}" stepKey="uploadImageForFirstProduct"/> + <attachFile selector="{{AdminDataGridTableSection.rowTemplate({$attributeCodeAdobeSmall})}}{{AdminProductFormConfigurationsSection.fileUploaderInput}}" userInput="{{TestImageAdobe.file}}" stepKey="uploadImageForSecondProduct"/> + + <!-- Click "Save" button --> + <actionGroup ref="saveProductForm" stepKey="clickSaveButton"/> + + <!-- Delete all created product --> + <actionGroup ref="deleteProductBySku" stepKey="deleteCreatedProducts"> + <argument name="sku" value="{{ApiConfigurableProduct.sku}}"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php b/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php index 317ea77107222..6bdb83c3a8129 100644 --- a/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php +++ b/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php @@ -6,87 +6,153 @@ namespace Magento\Swatches\Test\Unit\Model\Plugin; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Model\Entity\Attribute\Source\AbstractSource; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Swatches\Helper\Data; use Magento\Swatches\Model\Plugin\EavAttribute; +use Magento\Swatches\Model\ResourceModel\Swatch\Collection; +use Magento\Swatches\Model\ResourceModel\Swatch\CollectionFactory; use Magento\Swatches\Model\Swatch; +use Magento\Swatches\Model\SwatchAttributeType; +use Magento\Swatches\Model\SwatchFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; -class EavAttributeTest extends \PHPUnit\Framework\TestCase +/** + * Test plugin model for Catalog Resource Attribute + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings(PHPMD.ExcessiveClassLength) + */ +class EavAttributeTest extends TestCase { - const ATTRIBUTE_ID = 123; - const OPTION_ID = 'option 12'; - const STORE_ID = 'option 89'; - const ATTRIBUTE_DEFAULT_VALUE = 1; - const ATTRIBUTE_OPTION_VALUE = 2; - const ATTRIBUTE_SWATCH_VALUE = 3; + private const ATTRIBUTE_ID = 123; + private const OPTION_1_ID = 1; + private const OPTION_2_ID = 2; + private const ADMIN_STORE_ID = 0; + private const DEFAULT_STORE_ID = 1; + private const NEW_OPTION_KEY = 'option_2'; + private const ATTRIBUTE_DEFAULT_VALUE = [ + 0 => self::NEW_OPTION_KEY + ]; + private const VISUAL_ATTRIBUTE_OPTIONS = [ + 'value' => [ + self::OPTION_1_ID => [ + self::ADMIN_STORE_ID => 'Black', + self::DEFAULT_STORE_ID => 'Black', + ], + self::NEW_OPTION_KEY => [ + self::ADMIN_STORE_ID => 'White', + self::DEFAULT_STORE_ID => 'White', + ], + ] + ]; + private const VISUAL_SWATCH_OPTIONS = [ + 'value' => [ + self::OPTION_1_ID => '#000000', + self::NEW_OPTION_KEY => '#ffffff', + ] + ]; + private const VISUAL_SAVED_OPTIONS = [ + [ + 'value' => self::OPTION_1_ID, + 'label' => 'Black', + ], + [ + 'value' => self::OPTION_2_ID, + 'label' => 'White', + ] + ]; + private const TEXT_ATTRIBUTE_OPTIONS = [ + 'value' => [ + self::OPTION_1_ID => [ + self::ADMIN_STORE_ID => 'Small', + self::DEFAULT_STORE_ID => 'Small', + ], + self::NEW_OPTION_KEY => [ + self::ADMIN_STORE_ID => 'Medium', + self::DEFAULT_STORE_ID => 'Medium', + ], + ] + ]; + private const TEXT_SWATCH_OPTIONS = [ + 'value' => [ + self::OPTION_1_ID => [ + self::ADMIN_STORE_ID => 'S', + self::DEFAULT_STORE_ID => 'S', + ], + self::NEW_OPTION_KEY => [ + self::ADMIN_STORE_ID => 'M', + self::DEFAULT_STORE_ID => 'M', + ], + ] + ]; + private const TEXT_SAVED_OPTIONS = [ + [ + 'value' => self::OPTION_1_ID, + 'label' => 'Small', + ], + [ + 'value' => self::OPTION_2_ID, + 'label' => 'Medium', + ] + ]; /** @var EavAttribute */ private $eavAttribute; - /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute|\PHPUnit_Framework_MockObject_MockObject */ + /** @var Attribute|MockObject */ private $attribute; - /** @var \Magento\Swatches\Model\SwatchFactory|\PHPUnit_Framework_MockObject_MockObject */ + /** @var SwatchFactory|MockObject */ private $swatchFactory; - /** @var \Magento\Swatches\Model\ResourceModel\Swatch\CollectionFactory|\PHPUnit_Framework_MockObject_MockObject */ + /** @var CollectionFactory|MockObject */ private $collectionFactory; - /** @var \Magento\Swatches\Helper\Data|\PHPUnit_Framework_MockObject_MockObject */ + /** @var Data|MockObject */ private $swatchHelper; - /** @var \Magento\Eav\Model\Entity\Attribute\Source\AbstractSource|\PHPUnit_Framework_MockObject_MockObject */ + /** @var AbstractSource|MockObject */ private $abstractSource; - /** @var \Magento\Swatches\Model\Swatch|\PHPUnit_Framework_MockObject_MockObject */ - private $swatch; - - /** @var \Magento\Swatches\Model\ResourceModel\Swatch|\PHPUnit_Framework_MockObject_MockObject */ + /** @var \Magento\Swatches\Model\ResourceModel\Swatch|MockObject */ private $resource; - /** @var \Magento\Swatches\Model\ResourceModel\Swatch\Collection|\PHPUnit_Framework_MockObject_MockObject */ + /** @var Collection|MockObject */ private $collection; - /** @var array */ - private $optionIds = []; - - /** @var array */ - private $allOptions = []; - - /** @var array */ - private $dependencyArray = []; - + /** + * {@inheritDoc} + */ protected function setUp() { - $this->attribute = $this->createMock(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class); - $this->swatchFactory = $this->createPartialMock(\Magento\Swatches\Model\SwatchFactory::class, ['create']); - $this->swatchHelper = $this->createMock(\Magento\Swatches\Helper\Data::class); - $this->swatch = $this->createMock(\Magento\Swatches\Model\Swatch::class); - $this->resource = $this->createMock(\Magento\Swatches\Model\ResourceModel\Swatch::class); - $this->collection = - $this->createMock(\Magento\Swatches\Model\ResourceModel\Swatch\Collection::class); - $this->collectionFactory = $this->createPartialMock( - \Magento\Swatches\Model\ResourceModel\Swatch\CollectionFactory::class, + $objectManager = new ObjectManager($this); + $this->abstractSource = $this->createMock(AbstractSource::class); + $this->attribute = $this->createPartialMock( + Attribute::class, + ['getSource'] + ); + $this->attribute->setId(self::ATTRIBUTE_ID); + $this->swatchFactory = $this->createPartialMock( + SwatchFactory::class, ['create'] ); - $this->abstractSource = $this->createMock(\Magento\Eav\Model\Entity\Attribute\Source\AbstractSource::class); - - $serializer = $this->createPartialMock( - \Magento\Framework\Serialize\Serializer\Json::class, - ['serialize', 'unserialize'] + $this->swatchHelper = $objectManager->getObject( + Data::class, + [ + 'swatchTypeChecker' => $objectManager->getObject(SwatchAttributeType::class) + ] ); - - $serializer->expects($this->any()) - ->method('serialize')->willReturnCallback(function ($parameter) { - return json_encode($parameter); - }); - - $serializer->expects($this->any()) - ->method('unserialize')->willReturnCallback(function ($parameter) { - return json_decode($parameter, true); - }); - - $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->resource = $this->createMock(\Magento\Swatches\Model\ResourceModel\Swatch::class); + $this->collection = $this->createMock(Collection::class); + $this->collectionFactory = $this->createPartialMock(CollectionFactory::class, ['create']); + $serializer = $objectManager->getObject(Json::class); $this->eavAttribute = $objectManager->getObject( - \Magento\Swatches\Model\Plugin\EavAttribute::class, + EavAttribute::class, [ 'collectionFactory' => $this->collectionFactory, 'swatchFactory' => $this->swatchFactory, @@ -94,220 +160,128 @@ protected function setUp() 'serializer' => $serializer, ] ); - - $this->optionIds = [ - 'value' => ['option 89' => 'test 1', 'option 114' => 'test 2', 'option 170' => 'test 3'], - 'delete' => ['option 89' => 0, 'option 114' => 1, 'option 170' => 0], - ]; - $this->allOptions = [null, ['value' => 'option 12'], ['value' => 'option 154']]; - $this->dependencyArray = ['option 89', 'option 170']; + $this->attribute->expects($this->any()) + ->method('getSource') + ->willReturn($this->abstractSource); + $swatch = $this->createMock(Swatch::class); + $swatch->expects($this->any()) + ->method('getResource') + ->willReturn($this->resource); + $this->swatchFactory->expects($this->any()) + ->method('create') + ->willReturn($swatch); } + /** + * Test beforeSave plugin for visual swatch + */ public function testBeforeSaveVisualSwatch() { - $option = [ - 'value' => [ - 0 => 'option value', + $this->attribute->setData( + [ + 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optionvisual' => self::VISUAL_ATTRIBUTE_OPTIONS, + 'swatchvisual' => self::VISUAL_SWATCH_OPTIONS, ] - ]; - $this->attribute->expects($this->exactly(6))->method('getData')->withConsecutive( - ['defaultvisual'], - ['optionvisual'], - ['swatchvisual'], - ['optionvisual'], - ['option/delete/0'] - )->will($this->onConsecutiveCalls( - self::ATTRIBUTE_DEFAULT_VALUE, - self::ATTRIBUTE_OPTION_VALUE, - self::ATTRIBUTE_SWATCH_VALUE, - $option, - false - )); - - $this->attribute->expects($this->exactly(3))->method('setData') - ->withConsecutive( - ['option', self::ATTRIBUTE_OPTION_VALUE], - ['default', self::ATTRIBUTE_DEFAULT_VALUE], - ['swatch', self::ATTRIBUTE_SWATCH_VALUE] - ); - - $this->swatchHelper->expects($this->once())->method('assembleAdditionalDataEavAttribute') - ->with($this->attribute); - $this->swatchHelper->expects($this->atLeastOnce())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->never())->method('isTextSwatch'); + ); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attribute->getData('default')); + $this->assertEquals(self::VISUAL_ATTRIBUTE_OPTIONS, $this->attribute->getData('option')); + $this->assertEquals(self::VISUAL_SWATCH_OPTIONS, $this->attribute->getData('swatch')); } + /** + * Test beforeSave plugin for text swatch + */ public function testBeforeSaveTextSwatch() { - $option = [ - 'value' => [ - 0 => 'option value', + $this->attribute->setData( + [ + 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optiontext' => self::TEXT_ATTRIBUTE_OPTIONS, + 'swatchtext' => self::TEXT_SWATCH_OPTIONS, ] - ]; - $this->attribute->expects($this->exactly(6))->method('getData')->withConsecutive( - ['defaulttext'], - ['optiontext'], - ['swatchtext'], - ['optiontext'], - ['option/delete/0'] - )->will( - $this->onConsecutiveCalls( - self::ATTRIBUTE_DEFAULT_VALUE, - self::ATTRIBUTE_OPTION_VALUE, - self::ATTRIBUTE_SWATCH_VALUE, - $option, - false - ) ); - $this->attribute->expects($this->exactly(3))->method('setData') - ->withConsecutive( - ['option', self::ATTRIBUTE_OPTION_VALUE], - ['default', self::ATTRIBUTE_DEFAULT_VALUE], - ['swatch', self::ATTRIBUTE_SWATCH_VALUE] - ); - - $this->swatchHelper->expects($this->once())->method('assembleAdditionalDataEavAttribute') - ->with($this->attribute); - $this->swatchHelper->expects($this->atLeastOnce())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(false); - $this->swatchHelper->expects($this->atLeastOnce())->method('isTextSwatch') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attribute->getData('default')); + $this->assertEquals(self::TEXT_ATTRIBUTE_OPTIONS, $this->attribute->getData('option')); + $this->assertEquals(self::TEXT_SWATCH_OPTIONS, $this->attribute->getData('swatch')); } /** + * Test beforeSave plugin on empty label + * * @expectedException \Magento\Framework\Exception\InputException * @expectedExceptionMessage Admin is a required field in each row */ public function testBeforeSaveWithFailedValidation() { - $optionText = [ - 'value' => [ - 0 => '', + $options = self::VISUAL_ATTRIBUTE_OPTIONS; + $options['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID] = ''; + $this->attribute->setData( + [ + 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optionvisual' => $options, + 'swatchvisual' => self::VISUAL_SWATCH_OPTIONS, ] - ]; - $this->swatchHelper->expects($this->once()) - ->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - - $this->swatchHelper->expects($this->atLeastOnce()) - ->method('isVisualSwatch') - ->willReturn(true); - $this->attribute->expects($this->exactly(5)) - ->method('getData') - ->withConsecutive( - ['defaultvisual'], - ['optionvisual'], - ['swatchvisual'], - ['optionvisual'], - ['option/delete/0'] - ) - ->will( - $this->onConsecutiveCalls( - self::ATTRIBUTE_DEFAULT_VALUE, - self::ATTRIBUTE_OPTION_VALUE, - self::ATTRIBUTE_SWATCH_VALUE, - $optionText, - false - ) - ); + ); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); $this->eavAttribute->beforeBeforeSave($this->attribute); } /** - * @covers \Magento\Swatches\Model\Plugin\EavAttribute::beforeBeforeSave() + * Test beforeSave plugin on empty label of option being deleted */ - public function testBeforeSaveWithDeletedOption() + public function testValidationIsSkippedForDeletedOption() { - $optionText = [ - 'value' => [ - 0 => '', + $options = self::VISUAL_ATTRIBUTE_OPTIONS; + $options['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID] = ''; + $options['delete'][self::NEW_OPTION_KEY] = '1'; + $this->attribute->setData( + [ + 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optionvisual' => $options, + 'swatchvisual' => self::VISUAL_SWATCH_OPTIONS, ] - ]; - - $this->swatchHelper->expects($this->once()) - ->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); + ); - $this->swatchHelper->expects($this->atLeastOnce()) - ->method('isVisualSwatch') - ->willReturn(true); - $this->attribute->expects($this->exactly(6)) - ->method('getData') - ->withConsecutive( - ['defaultvisual'], - ['optionvisual'], - ['swatchvisual'], - ['optionvisual'], - ['option/delete/0'], - ['swatch_input_type'] - ) - ->will( - $this->onConsecutiveCalls( - self::ATTRIBUTE_DEFAULT_VALUE, - self::ATTRIBUTE_OPTION_VALUE, - self::ATTRIBUTE_SWATCH_VALUE, - $optionText, - true, - false - ) - ); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attribute->getData('default')); + $this->assertEquals($options, $this->attribute->getData('option')); + $this->assertEquals(self::VISUAL_SWATCH_OPTIONS, $this->attribute->getData('swatch')); } + /** + * Test beforeSave plugin for non a swatch attribute + */ public function testBeforeSaveNotSwatch() { $additionalData = [ - 'swatch_input_type' => 'visual', - 'update_product_preview_image' => 1, - 'use_product_image_for_swatch' => 0 - ]; - - $shortAdditionalData = [ + Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_VISUAL, 'update_product_preview_image' => 1, 'use_product_image_for_swatch' => 0 ]; - $this->attribute->expects($this->exactly(2))->method('getData')->withConsecutive( - [Swatch::SWATCH_INPUT_TYPE_KEY], - ['additional_data'] - )->willReturnOnConsecutiveCalls( - Swatch::SWATCH_INPUT_TYPE_DROPDOWN, - json_encode($additionalData) + $this->attribute->setData( + [ + Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_DROPDOWN, + 'additional_data' => json_encode($additionalData), + ] ); - $this->attribute - ->expects($this->once()) - ->method('setData') - ->with('additional_data', json_encode($shortAdditionalData)) - ->will($this->returnSelf()); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_DROPDOWN); - $this->swatchHelper->expects($this->never())->method('assembleAdditionalDataEavAttribute'); - $this->swatchHelper->expects($this->never())->method('isVisualSwatch'); - $this->swatchHelper->expects($this->never())->method('isTextSwatch'); + $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->swatchHelper->expects($this->once())->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(false); + unset($additionalData[Swatch::SWATCH_INPUT_TYPE_KEY]); - $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->assertEquals(json_encode($additionalData), $this->attribute->getData('additional_data')); } /** @@ -316,390 +290,383 @@ public function testBeforeSaveNotSwatch() public function visualSwatchProvider() { return [ - [Swatch::SWATCH_TYPE_EMPTY, null], - [Swatch::SWATCH_TYPE_VISUAL_COLOR, '#hex'], - [Swatch::SWATCH_TYPE_VISUAL_IMAGE, '/path'], + [Swatch::SWATCH_TYPE_EMPTY, 'black', 'white'], + [Swatch::SWATCH_TYPE_VISUAL_COLOR, '#000000', '#ffffff'], + [Swatch::SWATCH_TYPE_VISUAL_IMAGE, '/path/black.png', '/path/white.png'], ]; } /** - * @dataProvider visualSwatchProvider + * Test afterSave plugin for visual swatch + * + * @param string $swatchType + * @param string $swatch1 + * @param string $swatch2 * - * @param $swatchType - * @param $swatchValue + * @dataProvider visualSwatchProvider */ - public function testAfterAfterSaveVisualSwatch($swatchType, $swatchValue) + public function testAfterAfterSaveVisualSwatch(string $swatchType, string $swatch1, string $swatch2) { - $this->abstractSource->expects($this->once())->method('getAllOptions') - ->willReturn($this->allOptions); - $this->resource->expects($this->once())->method('saveDefaultSwatchOption') - ->with(self::ATTRIBUTE_ID, self::OPTION_ID); + $options = self::VISUAL_SWATCH_OPTIONS; + $options['value'][self::OPTION_1_ID] = $swatch1; + $options['value'][self::NEW_OPTION_KEY] = $swatch2; + $this->attribute->addData( + [ + 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optionvisual' => self::VISUAL_ATTRIBUTE_OPTIONS, + 'swatchvisual' => $options, + ] + ); - $this->swatch->expects($this->once())->method('getResource') - ->willReturn($this->resource); - $this->swatch->expects($this->once())->method('getId') - ->willReturn(EavAttribute::DEFAULT_STORE_ID); - $this->swatch->expects($this->once())->method('save'); - $this->swatch->expects($this->exactly(4))->method('setData') - ->withConsecutive( - ['option_id', self::OPTION_ID], - ['store_id', EavAttribute::DEFAULT_STORE_ID], - ['type', $swatchType], - ['value', $swatchValue] - ); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); + $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->abstractSource->expects($this->once()) + ->method('getAllOptions') + ->willReturn(self::VISUAL_SAVED_OPTIONS); + + $this->resource->expects($this->once()) + ->method('saveDefaultSwatchOption') + ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->collection->expects($this->exactly(2))->method('addFieldToFilter') + $this->collection->expects($this->exactly(4)) + ->method('addFieldToFilter') ->withConsecutive( - ['option_id', self::OPTION_ID], - ['store_id', EavAttribute::DEFAULT_STORE_ID] - )->willReturnSelf(); + ['option_id', self::OPTION_1_ID], + ['store_id', self::ADMIN_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::ADMIN_STORE_ID] + ) + ->willReturnSelf(); - $this->collection->expects($this->once())->method('getFirstItem') - ->willReturn($this->swatch); - $this->collectionFactory->expects($this->once())->method('create') + $this->collection->expects($this->exactly(2)) + ->method('getFirstItem') + ->willReturnOnConsecutiveCalls( + $this->createSwatchMock( + $swatchType, + $swatch1, + 1 + ), + $this->createSwatchMock( + $swatchType, + $swatch2, + null, + self::OPTION_2_ID, + self::ADMIN_STORE_ID + ) + ); + $this->collectionFactory->expects($this->exactly(2)) + ->method('create') ->willReturn($this->collection); - $this->attribute->expects($this->at(0))->method('getData') - ->willReturn($this->optionIds); - $this->attribute->expects($this->at(1))->method('getSource') - ->willReturn($this->abstractSource); - $this->attribute->expects($this->at(2))->method('getData') - ->with('default/0') - ->willReturn($this->dependencyArray[0]); - $this->attribute->expects($this->at(3))->method('getId') - ->willReturn(self::ATTRIBUTE_ID); - $this->attribute->expects($this->at(4))->method('getData') - ->with('swatch/value') - ->willReturn([self::STORE_ID => $swatchValue]); - $this->attribute->expects($this->at(5))->method('getData') - ->with('option/delete/' . self::OPTION_ID) - ->willReturn(false); - - $this->swatchFactory->expects($this->exactly(1))->method('create') - ->willReturn($this->swatch); - $this->swatchHelper->expects($this->exactly(2))->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->never())->method('isTextSwatch'); - $this->eavAttribute->afterAfterSave($this->attribute); } - public function testDefaultTextualSwatchAfterSave() + /** + * Test afterSave plugin for text swatch + */ + public function testAfterAfterSaveTextualSwatch() { - $this->abstractSource->expects($this->once())->method('getAllOptions') - ->willReturn($this->allOptions); + $this->attribute->addData( + [ + 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optiontext' => self::TEXT_ATTRIBUTE_OPTIONS, + 'swatchtext' => self::TEXT_SWATCH_OPTIONS, + ] + ); - $this->swatch->expects($this->any())->method('getId') - ->willReturn(EavAttribute::DEFAULT_STORE_ID); - $this->swatch->expects($this->any())->method('save'); - $this->swatch->expects($this->any())->method('isDeleted') - ->with(false); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); + $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->collection->expects($this->any())->method('addFieldToFilter') - ->willReturnSelf(); - $this->collection->expects($this->any())->method('getFirstItem') - ->willReturn($this->swatch); - $this->collectionFactory->expects($this->any())->method('create') - ->willReturn($this->collection); + $this->abstractSource->expects($this->once()) + ->method('getAllOptions') + ->willReturn(self::TEXT_SAVED_OPTIONS); - $this->attribute->expects($this->at(0))->method('getData') - ->willReturn($this->optionIds); - $this->attribute->expects($this->at(1))->method('getSource') - ->willReturn($this->abstractSource); - $this->attribute->expects($this->at(2))->method('getData') - ->with('default/0') - ->willReturn(null); - - $this->attribute->expects($this->at(3))->method('getData') - ->with('swatch/value') - ->willReturn( - [ - self::STORE_ID => [ - 1 => "test", - 2 => false, - 3 => null, - 4 => "", - ] - ] - ); + $this->resource->expects($this->once()) + ->method('saveDefaultSwatchOption') + ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->swatchHelper->expects($this->exactly(2))->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(false); - $this->swatchHelper->expects($this->once())->method('isTextSwatch') - ->with($this->attribute) - ->willReturn(true); - - $this->swatch->expects($this->any())->method('setData') + $this->collection->expects($this->exactly(8)) + ->method('addFieldToFilter') ->withConsecutive( - ['option_id', self::OPTION_ID], - ['store_id', 1], - ['type', Swatch::SWATCH_TYPE_TEXTUAL], - ['value', "test"] - ); - - $this->eavAttribute->afterAfterSave($this->attribute); - } - - public function testAfterAfterSaveTextualSwatch() - { - $this->abstractSource->expects($this->once())->method('getAllOptions') - ->willReturn($this->allOptions); - $this->resource->expects($this->once())->method('saveDefaultSwatchOption') - ->with(self::ATTRIBUTE_ID, self::OPTION_ID); + ['option_id', self::OPTION_1_ID], + ['store_id', self::ADMIN_STORE_ID], + ['option_id', self::OPTION_1_ID], + ['store_id', self::DEFAULT_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::ADMIN_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::DEFAULT_STORE_ID] + ) + ->willReturnSelf(); - $this->swatch->expects($this->once())->method('getResource') - ->willReturn($this->resource); - $this->swatch->expects($this->once())->method('getId') - ->willReturn(EavAttribute::DEFAULT_STORE_ID); - $this->swatch->expects($this->once())->method('save'); - $this->swatch->expects($this->once())->method('isDeleted') - ->with(false); - $this->swatch->expects($this->exactly(4))->method('setData') - ->withConsecutive( - ['option_id', self::OPTION_ID], - ['store_id', self::OPTION_ID], - ['type', Swatch::SWATCH_TYPE_TEXTUAL], - ['value', null] + $this->collection->expects($this->exactly(4)) + ->method('getFirstItem') + ->willReturnOnConsecutiveCalls( + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::OPTION_1_ID][self::ADMIN_STORE_ID], + 1 + ), + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::OPTION_1_ID][self::DEFAULT_STORE_ID], + 1 + ), + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID], + null, + self::OPTION_2_ID, + self::ADMIN_STORE_ID + ), + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY][self::DEFAULT_STORE_ID], + null, + self::OPTION_2_ID, + self::DEFAULT_STORE_ID + ) ); - - $this->collection->expects($this->exactly(2))->method('addFieldToFilter') - ->withConsecutive( - ['option_id', self::OPTION_ID], - ['store_id', self::OPTION_ID] - )->willReturnSelf(); - $this->collection->expects($this->once())->method('getFirstItem') - ->willReturn($this->swatch); - $this->collectionFactory->expects($this->once())->method('create') + $this->collectionFactory->expects($this->exactly(4)) + ->method('create') ->willReturn($this->collection); - $this->attribute->expects($this->at(0))->method('getData') - ->willReturn($this->optionIds); - $this->attribute->expects($this->at(1))->method('getSource') - ->willReturn($this->abstractSource); - $this->attribute->expects($this->at(2))->method('getData') - ->with('default/0') - ->willReturn($this->dependencyArray[0]); - $this->attribute->expects($this->at(3))->method('getId') - ->willReturn(self::ATTRIBUTE_ID); - $this->attribute->expects($this->at(4))->method('getData') - ->with('swatch/value') - ->willReturn([self::STORE_ID => [self::OPTION_ID => null]]); - $this->attribute->expects($this->at(5))->method('getData') - ->with('option/delete/' . self::OPTION_ID) - ->willReturn(false); - - $this->swatchFactory->expects($this->exactly(1))->method('create') - ->willReturn($this->swatch); - $this->swatchHelper->expects($this->exactly(2))->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(false); - $this->swatchHelper->expects($this->once())->method('isTextSwatch') - ->with($this->attribute) - ->willReturn(true); - $this->eavAttribute->afterAfterSave($this->attribute); } + /** + * Test afterSave plugin for deleted visual swatch option + */ public function testAfterAfterSaveVisualSwatchIsDelete() { - $this->abstractSource->expects($this->once())->method('getAllOptions') - ->willReturn($this->allOptions); - $this->resource->expects($this->once())->method('saveDefaultSwatchOption') - ->with(self::ATTRIBUTE_ID, self::OPTION_ID); + $options = self::VISUAL_ATTRIBUTE_OPTIONS; + $options['delete'][self::OPTION_1_ID] = '1'; + $this->attribute->addData( + [ + 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optionvisual' => $options, + 'swatchvisual' => self::VISUAL_SWATCH_OPTIONS, + ] + ); - $this->swatch->expects($this->once())->method('getResource') - ->willReturn($this->resource); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); + $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->abstractSource->expects($this->once()) + ->method('getAllOptions') + ->willReturn(self::VISUAL_SAVED_OPTIONS); - $this->attribute->expects($this->at(0))->method('getData') - ->willReturn($this->optionIds); - $this->attribute->expects($this->at(1))->method('getSource') - ->willReturn($this->abstractSource); - $this->attribute->expects($this->at(2))->method('getData') - ->with('default/0') - ->willReturn($this->dependencyArray[0]); - $this->attribute->expects($this->at(3))->method('getId') - ->willReturn(self::ATTRIBUTE_ID); - $this->attribute->expects($this->at(4))->method('getData') - ->with('swatch/value') - ->willReturn([self::STORE_ID => null]); - $this->attribute->expects($this->at(5))->method('getData') - ->with('option/delete/' . self::OPTION_ID) - ->willReturn(true); - - $this->swatchFactory->expects($this->once())->method('create') - ->willReturn($this->swatch); - $this->swatchHelper->expects($this->exactly(2))->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->never())->method('isTextSwatch'); + $this->resource->expects($this->once()) + ->method('saveDefaultSwatchOption') + ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); + + $this->collection->expects($this->exactly(2)) + ->method('addFieldToFilter') + ->withConsecutive( + ['option_id', self::OPTION_2_ID], + ['store_id', self::ADMIN_STORE_ID] + ) + ->willReturnSelf(); + + $this->collection->expects($this->exactly(1)) + ->method('getFirstItem') + ->willReturnOnConsecutiveCalls( + $this->createSwatchMock( + Swatch::SWATCH_TYPE_VISUAL_COLOR, + self::VISUAL_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY], + null, + self::OPTION_2_ID, + self::ADMIN_STORE_ID + ) + ); + $this->collectionFactory->expects($this->exactly(1)) + ->method('create') + ->willReturn($this->collection); $this->eavAttribute->afterAfterSave($this->attribute); } + /** + * Test afterSave plugin for deleted text swatch option + */ public function testAfterAfterSaveTextualSwatchIsDelete() { - $this->abstractSource->expects($this->once())->method('getAllOptions') - ->willReturn($this->allOptions); - $this->resource->expects($this->once())->method('saveDefaultSwatchOption') - ->with(self::ATTRIBUTE_ID, self::OPTION_ID); - - $this->swatch->expects($this->once())->method('getResource') - ->willReturn($this->resource); + $options = self::TEXT_ATTRIBUTE_OPTIONS; + $options['delete'][self::OPTION_1_ID] = '1'; + $this->attribute->addData( + [ + 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optiontext' => $options, + 'swatchtext' => self::TEXT_SWATCH_OPTIONS, + ] + ); - $this->attribute->expects($this->at(0))->method('getData') - ->willReturn($this->optionIds); - $this->attribute->expects($this->at(1))->method('getSource') - ->willReturn($this->abstractSource); - $this->attribute->expects($this->at(2))->method('getData') - ->with('default/0') - ->willReturn($this->dependencyArray[0]); - $this->attribute->expects($this->at(3))->method('getId') - ->willReturn(self::ATTRIBUTE_ID); - $this->attribute->expects($this->at(4))->method('getData') - ->with('swatch/value') - ->willReturn([self::STORE_ID => [self::OPTION_ID => null]]); - $this->attribute->expects($this->at(5))->method('getData') - ->with('option/delete/' . self::OPTION_ID) - ->willReturn(true); - - $this->swatchFactory->expects($this->once())->method('create') - ->willReturn($this->swatch); - $this->swatchHelper->expects($this->exactly(2))->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(false); - $this->swatchHelper->expects($this->once())->method('isTextSwatch') - ->with($this->attribute) - ->willReturn(true); + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); + $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->eavAttribute->afterAfterSave($this->attribute); - } + $this->abstractSource->expects($this->once()) + ->method('getAllOptions') + ->willReturn(self::TEXT_SAVED_OPTIONS); - public function testAfterAfterSaveIsSwatchExists() - { - $this->abstractSource->expects($this->once())->method('getAllOptions') - ->willReturn($this->allOptions); - $this->resource->expects($this->once())->method('saveDefaultSwatchOption') - ->with(self::ATTRIBUTE_ID, self::OPTION_ID); + $this->resource->expects($this->once()) + ->method('saveDefaultSwatchOption') + ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->swatch->expects($this->once())->method('getResource') - ->willReturn($this->resource); - $this->swatch->expects($this->once())->method('getId') - ->willReturn(1); - $this->swatch->expects($this->once())->method('save'); - $this->swatch->expects($this->once())->method('isDeleted') - ->with(false); - $this->swatch->expects($this->exactly(2))->method('setData') + $this->collection->expects($this->exactly(4)) + ->method('addFieldToFilter') ->withConsecutive( - ['type', Swatch::SWATCH_TYPE_TEXTUAL], - ['value', null] - ); + ['option_id', self::OPTION_2_ID], + ['store_id', self::ADMIN_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::DEFAULT_STORE_ID] + ) + ->willReturnSelf(); - $this->collection->expects($this->exactly(2))->method('addFieldToFilter') - ->withConsecutive( - ['option_id', self::OPTION_ID], - ['store_id', self::OPTION_ID] - )->willReturnSelf(); - $this->collection->expects($this->once())->method('getFirstItem') - ->willReturn($this->swatch); - $this->collectionFactory->expects($this->once())->method('create') + $this->collection->expects($this->exactly(2)) + ->method('getFirstItem') + ->willReturnOnConsecutiveCalls( + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID], + null, + self::OPTION_2_ID, + self::ADMIN_STORE_ID + ), + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY][self::DEFAULT_STORE_ID], + null, + self::OPTION_2_ID, + self::DEFAULT_STORE_ID + ) + ); + $this->collectionFactory->expects($this->exactly(2)) + ->method('create') ->willReturn($this->collection); - $this->attribute->expects($this->at(0))->method('getData') - ->willReturn($this->optionIds); - $this->attribute->expects($this->at(1))->method('getSource') - ->willReturn($this->abstractSource); - $this->attribute->expects($this->at(2))->method('getData') - ->with('default/0') - ->willReturn($this->dependencyArray[0]); - $this->attribute->expects($this->at(3))->method('getId') - ->willReturn(self::ATTRIBUTE_ID); - $this->attribute->expects($this->at(4))->method('getData') - ->with('swatch/value') - ->willReturn([self::STORE_ID => [self::OPTION_ID => null]]); - $this->attribute->expects($this->at(5))->method('getData') - ->with('option/delete/' . self::OPTION_ID) - ->willReturn(false); - - $this->swatchFactory->expects($this->exactly(1))->method('create') - ->willReturn($this->swatch); - $this->swatchHelper->expects($this->exactly(2))->method('isSwatchAttribute') - ->with($this->attribute) - ->willReturn(true); - $this->swatchHelper->expects($this->once())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(false); - $this->swatchHelper->expects($this->once())->method('isTextSwatch') - ->with($this->attribute) - ->willReturn(true); - $this->eavAttribute->afterAfterSave($this->attribute); } + /** + * Test afterSave plugin on empty swatch value + */ public function testAfterAfterSaveNotSwatchAttribute() { - $this->abstractSource->expects($this->once())->method('getAllOptions') - ->willReturn($this->allOptions); - - $this->swatch->expects($this->once())->method('getId') - ->willReturn(1); - $this->swatch->expects($this->once())->method('save'); - $this->swatch->expects($this->once())->method('isDeleted') - ->with(false); - $this->swatch->expects($this->exactly(2))->method('setData') - ->withConsecutive( - ['type', Swatch::SWATCH_TYPE_TEXTUAL], - ['value', null] - ); + $options = self::TEXT_SWATCH_OPTIONS; + $options['value'][self::OPTION_1_ID][self::ADMIN_STORE_ID] = null; + $options['value'][self::OPTION_1_ID][self::DEFAULT_STORE_ID] = null; + $options['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID] = null; + $options['value'][self::NEW_OPTION_KEY][self::DEFAULT_STORE_ID] = null; + $this->attribute->addData( + [ + 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, + 'optiontext' => self::TEXT_ATTRIBUTE_OPTIONS, + 'swatchtext' => $options, + ] + ); + + $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); + $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->collection->expects($this->exactly(2))->method('addFieldToFilter') + $this->abstractSource->expects($this->once()) + ->method('getAllOptions') + ->willReturn(self::TEXT_SAVED_OPTIONS); + + $this->resource->expects($this->once()) + ->method('saveDefaultSwatchOption') + ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); + + $this->collection->expects($this->exactly(8)) + ->method('addFieldToFilter') ->withConsecutive( - ['option_id', self::OPTION_ID], - ['store_id', self::OPTION_ID] - )->willReturnSelf(); - $this->collection->expects($this->once())->method('getFirstItem') - ->willReturn($this->swatch); - $this->collectionFactory->expects($this->once())->method('create') - ->willReturn($this->collection); + ['option_id', self::OPTION_1_ID], + ['store_id', self::ADMIN_STORE_ID], + ['option_id', self::OPTION_1_ID], + ['store_id', self::DEFAULT_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::ADMIN_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::DEFAULT_STORE_ID] + ) + ->willReturnSelf(); - $this->attribute->expects($this->at(0))->method('getData') - ->with('option') - ->willReturn($this->optionIds); - $this->attribute->expects($this->at(1))->method('getSource') - ->willReturn($this->abstractSource); - $this->attribute->expects($this->at(2))->method('getData') - ->with('swatch/value') - ->willReturn([self::STORE_ID => [self::OPTION_ID => null]]); - $this->attribute->expects($this->at(3))->method('getData') - ->with('option/delete/' . self::OPTION_ID) - ->willReturn(false); - - $this->swatchHelper->expects($this->exactly(2))->method('isSwatchAttribute') - ->with($this->attribute) - ->will($this->onConsecutiveCalls(true, false)); - $this->swatchHelper->expects($this->once())->method('isVisualSwatch') - ->with($this->attribute) - ->willReturn(false); - $this->swatchHelper->expects($this->once())->method('isTextSwatch') - ->with($this->attribute) - ->willReturn(true); + $this->collection->expects($this->exactly(4)) + ->method('getFirstItem') + ->willReturnOnConsecutiveCalls( + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + null, + 1 + ), + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + null, + 1 + ), + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + null, + null, + self::OPTION_2_ID, + self::ADMIN_STORE_ID + ), + $this->createSwatchMock( + Swatch::SWATCH_TYPE_TEXTUAL, + null, + null, + self::OPTION_2_ID, + self::DEFAULT_STORE_ID + ) + ); + $this->collectionFactory->expects($this->exactly(4)) + ->method('create') + ->willReturn($this->collection); $this->eavAttribute->afterAfterSave($this->attribute); } + + /** + * Create configured mock for swatch model + * + * @param string $type + * @param string|null $value + * @param int|null $id + * @param int|null $optionId + * @param int|null $storeId + * @return MockObject + */ + private function createSwatchMock( + string $type, + ?string $value, + ?int $id = null, + ?int $optionId = null, + ?int $storeId = null + ) { + $swatch = $this->createMock(Swatch::class); + $swatch->expects($this->any()) + ->method('getId') + ->willReturn($id); + $swatch->expects($this->any()) + ->method('getResource') + ->willReturn($this->resource); + $swatch->expects($this->once()) + ->method('save'); + if ($id) { + $swatch->expects($this->exactly(2)) + ->method('setData') + ->withConsecutive( + ['type', $type], + ['value', $value] + ); + } else { + $swatch->expects($this->exactly(4)) + ->method('setData') + ->withConsecutive( + ['option_id', $optionId], + ['store_id', $storeId], + ['type', $type], + ['value', $value] + ); + } + return $swatch; + } } diff --git a/app/code/Magento/Swatches/ViewModel/Product/Renderer/Configurable.php b/app/code/Magento/Swatches/ViewModel/Product/Renderer/Configurable.php new file mode 100644 index 0000000000000..849d79cc58d92 --- /dev/null +++ b/app/code/Magento/Swatches/ViewModel/Product/Renderer/Configurable.php @@ -0,0 +1,50 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types = 1); +namespace Magento\Swatches\ViewModel\Product\Renderer; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\View\Element\Block\ArgumentInterface; +use Magento\Store\Model\ScopeInterface; + +/** + * Class Configurable + */ +class Configurable implements ArgumentInterface +{ + /** + * Config path if swatch tooltips are enabled + */ + private const XML_PATH_SHOW_SWATCH_TOOLTIP = 'catalog/frontend/show_swatch_tooltip'; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * Configurable constructor. + * + * @param ScopeConfigInterface $scopeConfig + */ + public function __construct(ScopeConfigInterface $scopeConfig) + { + $this->scopeConfig = $scopeConfig; + } + + /** + * Get config if swatch tooltips should be rendered. + * + * @return string + */ + public function getShowSwatchTooltip() + { + return $this->scopeConfig->getValue( + self::XML_PATH_SHOW_SWATCH_TOOLTIP, + ScopeInterface::SCOPE_STORE + ); + } +} diff --git a/app/code/Magento/Swatches/etc/adminhtml/system.xml b/app/code/Magento/Swatches/etc/adminhtml/system.xml index 2cf40ae83cc3b..6fbf110fadcd3 100644 --- a/app/code/Magento/Swatches/etc/adminhtml/system.xml +++ b/app/code/Magento/Swatches/etc/adminhtml/system.xml @@ -17,6 +17,10 @@ <label>Show Swatches in Product List</label> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> </field> + <field id="show_swatch_tooltip" translate="label" type="select" sortOrder="320" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <label>Show Swatch Tooltip</label> + <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> + </field> </group> </section> </system> diff --git a/app/code/Magento/Swatches/etc/config.xml b/app/code/Magento/Swatches/etc/config.xml index 65b36558c2796..4140acc4974d6 100644 --- a/app/code/Magento/Swatches/etc/config.xml +++ b/app/code/Magento/Swatches/etc/config.xml @@ -11,6 +11,7 @@ <frontend> <swatches_per_product>16</swatches_per_product> <show_swatches_in_product_list>1</show_swatches_in_product_list> + <show_swatch_tooltip>1</show_swatch_tooltip> </frontend> </catalog> <general> diff --git a/app/code/Magento/Swatches/etc/di.xml b/app/code/Magento/Swatches/etc/di.xml index 5292bfafb6a0f..585cef924e928 100644 --- a/app/code/Magento/Swatches/etc/di.xml +++ b/app/code/Magento/Swatches/etc/di.xml @@ -81,4 +81,7 @@ </argument> </arguments> </type> + <type name="Magento\Catalog\Model\Product\Attribute\OptionManagement"> + <plugin name="swatches_product_attribute_optionmanagement_plugin" type="Magento\Swatches\Plugin\Eav\Model\Entity\Attribute\OptionManagement"/> + </type> </config> diff --git a/app/code/Magento/Swatches/view/frontend/layout/catalog_category_view.xml b/app/code/Magento/Swatches/view/frontend/layout/catalog_category_view.xml index c2dc36e83950c..86031a189d798 100644 --- a/app/code/Magento/Swatches/view/frontend/layout/catalog_category_view.xml +++ b/app/code/Magento/Swatches/view/frontend/layout/catalog_category_view.xml @@ -5,10 +5,19 @@ * See COPYING.txt for license details. */ --> -<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceBlock name="category.product.type.details.renderers"> - <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" name="category.product.type.details.renderers.configurable" as="configurable" template="Magento_Swatches::product/listing/renderer.phtml" ifconfig="catalog/frontend/show_swatches_in_product_list" /> + <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" + name="category.product.type.details.renderers.configurable" as="configurable" + template="Magento_Swatches::product/listing/renderer.phtml" + ifconfig="catalog/frontend/show_swatches_in_product_list"> + <arguments> + <argument name="configurable_view_model" + xsi:type="object">Magento\Swatches\ViewModel\Product\Renderer\Configurable</argument> + </arguments> + </block> </referenceBlock> </body> </page> diff --git a/app/code/Magento/Swatches/view/frontend/layout/catalog_product_view_type_configurable.xml b/app/code/Magento/Swatches/view/frontend/layout/catalog_product_view_type_configurable.xml index 6188f3957a11d..98346d6ae7e67 100644 --- a/app/code/Magento/Swatches/view/frontend/layout/catalog_product_view_type_configurable.xml +++ b/app/code/Magento/Swatches/view/frontend/layout/catalog_product_view_type_configurable.xml @@ -5,11 +5,18 @@ * See COPYING.txt for license details. */ --> -<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="product.info.options.configurable" remove="true"/> <referenceBlock name="product.info.options.wrapper"> - <block class="Magento\Swatches\Block\Product\Renderer\Configurable" name="product.info.options.swatches" as="swatch_options" before="-" /> + <block class="Magento\Swatches\Block\Product\Renderer\Configurable" name="product.info.options.swatches" + as="swatch_options" before="-"> + <arguments> + <argument name="configurable_view_model" + xsi:type="object">Magento\Swatches\ViewModel\Product\Renderer\Configurable</argument> + </arguments> + </block> </referenceBlock> </body> </page> diff --git a/app/code/Magento/Swatches/view/frontend/layout/catalog_widget_product_list.xml b/app/code/Magento/Swatches/view/frontend/layout/catalog_widget_product_list.xml index 91798cbd9947f..ce31f588c6c8c 100644 --- a/app/code/Magento/Swatches/view/frontend/layout/catalog_widget_product_list.xml +++ b/app/code/Magento/Swatches/view/frontend/layout/catalog_widget_product_list.xml @@ -3,10 +3,19 @@ ~ See COPYING.txt for license details. --> -<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceBlock name="category.product.type.widget.details.renderers"> - <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" name="category.product.type.details.renderers.configurable" as="configurable" template="Magento_Swatches::product/listing/renderer.phtml" ifconfig="catalog/frontend/show_swatches_in_product_list"/> + <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" + name="category.product.type.details.renderers.configurable" as="configurable" + template="Magento_Swatches::product/listing/renderer.phtml" + ifconfig="catalog/frontend/show_swatches_in_product_list"> + <arguments> + <argument name="configurable_view_model" + xsi:type="object">Magento\Swatches\ViewModel\Product\Renderer\Configurable</argument> + </arguments> + </block> </referenceBlock> </body> -</page> \ No newline at end of file +</page> diff --git a/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_advanced_result.xml b/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_advanced_result.xml index c2dc36e83950c..86031a189d798 100644 --- a/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_advanced_result.xml +++ b/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_advanced_result.xml @@ -5,10 +5,19 @@ * See COPYING.txt for license details. */ --> -<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceBlock name="category.product.type.details.renderers"> - <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" name="category.product.type.details.renderers.configurable" as="configurable" template="Magento_Swatches::product/listing/renderer.phtml" ifconfig="catalog/frontend/show_swatches_in_product_list" /> + <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" + name="category.product.type.details.renderers.configurable" as="configurable" + template="Magento_Swatches::product/listing/renderer.phtml" + ifconfig="catalog/frontend/show_swatches_in_product_list"> + <arguments> + <argument name="configurable_view_model" + xsi:type="object">Magento\Swatches\ViewModel\Product\Renderer\Configurable</argument> + </arguments> + </block> </referenceBlock> </body> </page> diff --git a/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_result_index.xml b/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_result_index.xml index 9285d34efcd4c..86031a189d798 100644 --- a/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_result_index.xml +++ b/app/code/Magento/Swatches/view/frontend/layout/catalogsearch_result_index.xml @@ -5,10 +5,19 @@ * See COPYING.txt for license details. */ --> -<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceBlock name="category.product.type.details.renderers"> - <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" name="category.product.type.details.renderers.configurable" as="configurable" template="Magento_Swatches::product/listing/renderer.phtml" ifconfig="catalog/frontend/show_swatches_in_product_list"/> + <block class="Magento\Swatches\Block\Product\Renderer\Listing\Configurable" + name="category.product.type.details.renderers.configurable" as="configurable" + template="Magento_Swatches::product/listing/renderer.phtml" + ifconfig="catalog/frontend/show_swatches_in_product_list"> + <arguments> + <argument name="configurable_view_model" + xsi:type="object">Magento\Swatches\ViewModel\Product\Renderer\Configurable</argument> + </arguments> + </block> </referenceBlock> </body> </page> diff --git a/app/code/Magento/Swatches/view/frontend/layout/wishlist_index_configure_type_configurable.xml b/app/code/Magento/Swatches/view/frontend/layout/wishlist_index_configure_type_configurable.xml index 9982eb98d84da..c8159f1a43fe3 100644 --- a/app/code/Magento/Swatches/view/frontend/layout/wishlist_index_configure_type_configurable.xml +++ b/app/code/Magento/Swatches/view/frontend/layout/wishlist_index_configure_type_configurable.xml @@ -5,11 +5,18 @@ * See COPYING.txt for license details. */ --> -<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceBlock name="product.info.options.configurable" remove="true"/> <referenceBlock name="product.info.options.wrapper"> - <block class="Magento\Swatches\Block\Product\Renderer\Configurable" name="product.info.options.swatches" as="swatch_options" before="-" /> + <block class="Magento\Swatches\Block\Product\Renderer\Configurable" name="product.info.options.swatches" + as="swatch_options" before="-"> + <arguments> + <argument name="configurable_view_model" + xsi:type="object">Magento\Swatches\ViewModel\Product\Renderer\Configurable</argument> + </arguments> + </block> </referenceBlock> </body> </page> diff --git a/app/code/Magento/Swatches/view/frontend/templates/product/listing/renderer.phtml b/app/code/Magento/Swatches/view/frontend/templates/product/listing/renderer.phtml index 777277a15d8cd..5838ba9625c6a 100644 --- a/app/code/Magento/Swatches/view/frontend/templates/product/listing/renderer.phtml +++ b/app/code/Magento/Swatches/view/frontend/templates/product/listing/renderer.phtml @@ -7,6 +7,8 @@ <?php /** @var $block \Magento\Swatches\Block\Product\Renderer\Listing\Configurable */ $productId = $block->getProduct()->getId(); +/** @var \Magento\Swatches\ViewModel\Product\Renderer\Configurable $configurableViewModel */ +$configurableViewModel = $block->getConfigurableViewModel() ?> <div class="swatch-opt-<?= $block->escapeHtmlAttr($productId) ?>" data-role="swatch-option-<?= $block->escapeHtmlAttr($productId) ?>"></div> @@ -22,7 +24,8 @@ $productId = $block->getProduct()->getId(); "jsonConfig": <?= /* @noEscape */ $block->getJsonConfig() ?>, "jsonSwatchConfig": <?= /* @noEscape */ $block->getJsonSwatchConfig() ?>, "mediaCallback": "<?= $block->escapeJs($block->escapeUrl($block->getMediaCallback())) ?>", - "jsonSwatchImageSizeConfig": <?= /* @noEscape */ $block->getJsonSwatchSizeConfig() ?> + "jsonSwatchImageSizeConfig": <?= /* @noEscape */ $block->getJsonSwatchSizeConfig() ?>, + "showTooltip": <?= $block->escapeJs($configurableViewModel->getShowSwatchTooltip()) ?> } } } @@ -39,4 +42,4 @@ $productId = $block->getProduct()->getId(); } } } -</script> \ No newline at end of file +</script> diff --git a/app/code/Magento/Swatches/view/frontend/templates/product/view/renderer.phtml b/app/code/Magento/Swatches/view/frontend/templates/product/view/renderer.phtml index c85a6908413b5..bfabd5f3ab38f 100644 --- a/app/code/Magento/Swatches/view/frontend/templates/product/view/renderer.phtml +++ b/app/code/Magento/Swatches/view/frontend/templates/product/view/renderer.phtml @@ -4,7 +4,11 @@ * See COPYING.txt for license details. */ ?> -<?php /** @var $block \Magento\Swatches\Block\Product\Renderer\Configurable */ ?> +<?php +/** @var $block \Magento\Swatches\Block\Product\Renderer\Configurable */ +/** @var \Magento\Swatches\ViewModel\Product\Renderer\Configurable $configurableViewModel */ +$configurableViewModel = $block->getConfigurableViewModel() +?> <div class="swatch-opt" data-role="swatch-options"></div> <script type="text/x-magento-init"> @@ -15,7 +19,8 @@ "jsonSwatchConfig": <?= /* @noEscape */ $swatchOptions = $block->getJsonSwatchConfig() ?>, "mediaCallback": "<?= $block->escapeJs($block->escapeUrl($block->getMediaCallback())) ?>", "gallerySwitchStrategy": "<?= $block->escapeJs($block->getVar('gallery_switch_strategy', 'Magento_ConfigurableProduct')) ?: 'replace'; ?>", - "jsonSwatchImageSizeConfig": <?= /* @noEscape */ $block->getJsonSwatchSizeConfig() ?> + "jsonSwatchImageSizeConfig": <?= /* @noEscape */ $block->getJsonSwatchSizeConfig() ?>, + "showTooltip": <?= $block->escapeJs($configurableViewModel->getShowSwatchTooltip()) ?> } }, "*" : { diff --git a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js index 99c4aa64084a6..a7bafb6bca502 100644 --- a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js +++ b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js @@ -385,7 +385,8 @@ define([ var $widget = this, container = this.element, classes = this.options.classes, - chooseText = this.options.jsonConfig.chooseText; + chooseText = this.options.jsonConfig.chooseText, + showTooltip = this.options.showTooltip; $widget.optionsMap = {}; @@ -452,10 +453,12 @@ define([ }); }); - // Connect Tooltip - container - .find('[option-type="1"], [option-type="2"], [option-type="0"], [option-type="3"]') - .SwatchRendererTooltip(); + if (showTooltip === 1) { + // Connect Tooltip + container + .find('[option-type="1"], [option-type="2"], [option-type="0"], [option-type="3"]') + .SwatchRendererTooltip(); + } // Hide all elements below more button $('.' + classes.moreButton).nextAll().hide(); @@ -754,7 +757,7 @@ define([ $widget.options.jsonConfig.optionPrices ]); - if (checkAdditionalData['update_product_preview_image'] === '1') { + if (parseInt(checkAdditionalData['update_product_preview_image'], 10) === 1) { $widget._loadMedia(); } diff --git a/app/code/Magento/Tax/etc/db_schema.xml b/app/code/Magento/Tax/etc/db_schema.xml index f5227a9ef3a66..1fe1a1fe33d8a 100644 --- a/app/code/Magento/Tax/etc/db_schema.xml +++ b/app/code/Magento/Tax/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="tax_class" resource="default" engine="innodb" comment="Tax Class"> <column xsi:type="smallint" name="class_id" padding="6" unsigned="false" nullable="false" identity="true" - comment="Class Id"/> + comment="Class ID"/> <column xsi:type="varchar" name="class_name" nullable="false" length="255" comment="Class Name"/> <column xsi:type="varchar" name="class_type" nullable="false" length="8" default="CUSTOMER" comment="Class Type"/> @@ -19,7 +19,7 @@ </table> <table name="tax_calculation_rule" resource="default" engine="innodb" comment="Tax Calculation Rule"> <column xsi:type="int" name="tax_calculation_rule_id" padding="11" unsigned="false" nullable="false" - identity="true" comment="Tax Calculation Rule Id"/> + identity="true" comment="Tax Calculation Rule ID"/> <column xsi:type="varchar" name="code" nullable="false" length="255" comment="Code"/> <column xsi:type="int" name="priority" padding="11" unsigned="false" nullable="false" identity="false" comment="Priority"/> @@ -40,10 +40,10 @@ </table> <table name="tax_calculation_rate" resource="default" engine="innodb" comment="Tax Calculation Rate"> <column xsi:type="int" name="tax_calculation_rate_id" padding="11" unsigned="false" nullable="false" - identity="true" comment="Tax Calculation Rate Id"/> - <column xsi:type="varchar" name="tax_country_id" nullable="false" length="2" comment="Tax Country Id"/> + identity="true" comment="Tax Calculation Rate ID"/> + <column xsi:type="varchar" name="tax_country_id" nullable="false" length="2" comment="Tax Country ID"/> <column xsi:type="int" name="tax_region_id" padding="11" unsigned="false" nullable="false" identity="false" - comment="Tax Region Id"/> + comment="Tax Region ID"/> <column xsi:type="varchar" name="tax_postcode" nullable="true" length="21" comment="Tax Postcode"/> <column xsi:type="varchar" name="code" nullable="false" length="255" comment="Code"/> <column xsi:type="decimal" name="rate" scale="4" precision="12" unsigned="false" nullable="false" @@ -75,15 +75,15 @@ </table> <table name="tax_calculation" resource="default" engine="innodb" comment="Tax Calculation"> <column xsi:type="int" name="tax_calculation_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Tax Calculation Id"/> + comment="Tax Calculation ID"/> <column xsi:type="int" name="tax_calculation_rate_id" padding="11" unsigned="false" nullable="false" - identity="false" comment="Tax Calculation Rate Id"/> + identity="false" comment="Tax Calculation Rate ID"/> <column xsi:type="int" name="tax_calculation_rule_id" padding="11" unsigned="false" nullable="false" - identity="false" comment="Tax Calculation Rule Id"/> + identity="false" comment="Tax Calculation Rule ID"/> <column xsi:type="smallint" name="customer_tax_class_id" padding="6" unsigned="false" nullable="false" - identity="false" comment="Customer Tax Class Id"/> + identity="false" comment="Customer Tax Class ID"/> <column xsi:type="smallint" name="product_tax_class_id" padding="6" unsigned="false" nullable="false" - identity="false" comment="Product Tax Class Id"/> + identity="false" comment="Product Tax Class ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="tax_calculation_id"/> </constraint> @@ -116,11 +116,11 @@ </table> <table name="tax_calculation_rate_title" resource="default" engine="innodb" comment="Tax Calculation Rate Title"> <column xsi:type="int" name="tax_calculation_rate_title_id" padding="11" unsigned="false" nullable="false" - identity="true" comment="Tax Calculation Rate Title Id"/> + identity="true" comment="Tax Calculation Rate Title ID"/> <column xsi:type="int" name="tax_calculation_rate_id" padding="11" unsigned="false" nullable="false" - identity="false" comment="Tax Calculation Rate Id"/> + identity="false" comment="Tax Calculation Rate ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="value" nullable="false" length="255" comment="Value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="tax_calculation_rate_title_id"/> @@ -140,10 +140,10 @@ </index> </table> <table name="tax_order_aggregated_created" resource="sales" engine="innodb" comment="Tax Order Aggregation"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="code" nullable="false" length="255" comment="Code"/> <column xsi:type="varchar" name="order_status" nullable="false" length="50" comment="Order Status"/> <column xsi:type="float" name="percent" unsigned="false" nullable="true" comment="Percent"/> @@ -170,10 +170,10 @@ </table> <table name="tax_order_aggregated_updated" resource="sales" engine="innodb" comment="Tax Order Aggregated Updated"> - <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="Id"/> + <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true" comment="ID"/> <column xsi:type="date" name="period" comment="Period"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="true" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="code" nullable="false" length="255" comment="Code"/> <column xsi:type="varchar" name="order_status" nullable="false" length="50" comment="Order Status"/> <column xsi:type="float" name="percent" unsigned="false" nullable="true" comment="Percent"/> diff --git a/app/code/Magento/TaxImportExport/i18n/en_US.csv b/app/code/Magento/TaxImportExport/i18n/en_US.csv index 95f94dcfd3b2c..56815947ed1fa 100644 --- a/app/code/Magento/TaxImportExport/i18n/en_US.csv +++ b/app/code/Magento/TaxImportExport/i18n/en_US.csv @@ -18,3 +18,5 @@ Rate,Rate CSV,CSV "Excel XML","Excel XML" "Import/Export Tax Rates","Import/Export Tax Rates" +"Please select a file to import!","Please select a file to import!" + diff --git a/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml b/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml index 7473612252bb2..1c6b267cd9289 100644 --- a/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml +++ b/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml @@ -31,7 +31,7 @@ </form> <?php endif; ?> <script> -require(['jquery', "mage/mage", "loadingPopup"], function(jQuery){ +require(['jquery', 'Magento_Ui/js/modal/alert', "mage/mage", "loadingPopup", 'mage/translate'], function(jQuery, uiAlert){ jQuery('#import-form').mage('form').mage('validation'); (function ($) { @@ -42,6 +42,10 @@ require(['jquery', "mage/mage", "loadingPopup"], function(jQuery){ }); $(this.form).submit(); + } else { + uiAlert({ + content: $.mage.__('Please select a file to import!') + }); } }); })(jQuery); diff --git a/app/code/Magento/Theme/Model/Design/Backend/File.php b/app/code/Magento/Theme/Model/Design/Backend/File.php index a9aaaedb726d6..8f81ace8c9047 100644 --- a/app/code/Magento/Theme/Model/Design/Backend/File.php +++ b/app/code/Magento/Theme/Model/Design/Backend/File.php @@ -247,7 +247,7 @@ private function getMime() */ private function getRelativeMediaPath(string $path): string { - return preg_replace('/\/(pub\/)?media\//', '', $path); + return preg_split('/\/(pub\/)?media\//', $path)[1] ?? preg_replace('/\/(pub\/)?media\//', '', $path); } /** diff --git a/app/code/Magento/Theme/Test/Mftf/ActionGroup/AdminChangeStorefrontThemeActionGroup.xml b/app/code/Magento/Theme/Test/Mftf/ActionGroup/AdminChangeStorefrontThemeActionGroup.xml new file mode 100644 index 0000000000000..0620b9b73ba96 --- /dev/null +++ b/app/code/Magento/Theme/Test/Mftf/ActionGroup/AdminChangeStorefrontThemeActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminChangeStorefrontThemeActionGroup"> + <arguments> + <argument name="theme" type="string"/> + <argument name="scopeColumn" type="string" defaultValue="Store View"/> + <argument name="scopeName" type="string" defaultValue="{{_defaultStore.name}}"/> + </arguments> + <amOnPage url="{{DesignConfigPage.url}}" stepKey="navigateToDesignConfigPage"/> + <click selector="{{AdminDesignConfigSection.scopeEditLinkByName(scopeColumn, scopeName)}}" stepKey="editScopeConfig"/> + <selectOption selector="{{AdminDesignConfigSection.appliedTheme}}" userInput="{{theme}}" stepKey="selectTheme"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSave"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitForSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the configuration." stepKey="seeSuccessMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Theme/Test/Mftf/ActionGroup/StorefrontCheckElementColorActionGroup.xml b/app/code/Magento/Theme/Test/Mftf/ActionGroup/StorefrontCheckElementColorActionGroup.xml new file mode 100644 index 0000000000000..66e98d5e41527 --- /dev/null +++ b/app/code/Magento/Theme/Test/Mftf/ActionGroup/StorefrontCheckElementColorActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontCheckElementColorActionGroup"> + <annotations> + <description>Checks element color on storefront.</description> + </annotations> + <arguments> + <argument name="selector" type="string"/> + <argument name="property" type="string"/> + <argument name="color" type="string"/> + </arguments> + + <executeJS function="return window.getComputedStyle(document.querySelector('{{selector}}')).getPropertyValue('{{property}}')" stepKey="getElementColor"/> + <assertEquals expected="{{color}}" expectedType="string" actualType="variable" actual="getElementColor" message="pass" stepKey="assertElementColor"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Theme/Test/Mftf/Data/DesignData.xml b/app/code/Magento/Theme/Test/Mftf/Data/DesignData.xml index ec28e8ed7a999..1a3c10745f5a7 100644 --- a/app/code/Magento/Theme/Test/Mftf/Data/DesignData.xml +++ b/app/code/Magento/Theme/Test/Mftf/Data/DesignData.xml @@ -11,4 +11,10 @@ <entity name="Layout" type="page_layout"> <data key="1column">1 column</data> </entity> + <entity name="MagentoBlankTheme" type="theme"> + <data key="name">Magento Blank</data> + </entity> + <entity name="MagentoLumaTheme" type="theme"> + <data key="name">Magento Luma</data> + </entity> </entities> diff --git a/app/code/Magento/Theme/Test/Mftf/Data/NavigationMenuColorData.xml b/app/code/Magento/Theme/Test/Mftf/Data/NavigationMenuColorData.xml new file mode 100644 index 0000000000000..7af07753d7c9c --- /dev/null +++ b/app/code/Magento/Theme/Test/Mftf/Data/NavigationMenuColorData.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="NavigationMenuColor" type="navigation_menu_color"> + <data key="gray">rgb(232, 232, 232)</data> + <data key="white">rgb(255, 255, 255)</data> + <data key="orange">rgb(255, 85, 1)</data> + </entity> +</entities> diff --git a/app/code/Magento/Theme/Test/Mftf/Section/AdminDesignConfigSection.xml b/app/code/Magento/Theme/Test/Mftf/Section/AdminDesignConfigSection.xml index c2652f33f7606..069068163ccaf 100644 --- a/app/code/Magento/Theme/Test/Mftf/Section/AdminDesignConfigSection.xml +++ b/app/code/Magento/Theme/Test/Mftf/Section/AdminDesignConfigSection.xml @@ -31,5 +31,7 @@ <element name="storesArrow" type="button" selector="#ZmF2aWNvbi9zdG9yZXM- > .jstree-icon" /> <element name="checkIfStoresArrowExpand" type="button" selector="//li[@id='ZmF2aWNvbi9zdG9yZXM-' and contains(@class,'jstree-closed')]" /> <element name="storeLink" type="button" selector="#ZmF2aWNvbi9zdG9yZXMvMQ-- > a"/> + <element name="appliedTheme" type="select" selector="select[name='theme_theme_id']"/> + <element name="scopeEditLinkByName" type="button" selector="//tr//td[count(//div[@data-role='grid-wrapper']//tr//th[normalize-space(.)= '{{scope}}']/preceding-sibling::th)+1][contains(.,'{{scopeName}}')]/..//a[contains(@class, 'action-menu-item')]" timeout="30" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Theme/Test/Mftf/Section/StorefrontNavigationMenuSection.xml b/app/code/Magento/Theme/Test/Mftf/Section/StorefrontNavigationMenuSection.xml new file mode 100644 index 0000000000000..5741b50f877f6 --- /dev/null +++ b/app/code/Magento/Theme/Test/Mftf/Section/StorefrontNavigationMenuSection.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontNavigationMenuSection"> + <element name="navigationMenu" type="block" selector=".section-items.nav-sections-items li"/> + <element name="subItemLevelHover" type="text" selector=".{{level}} .submenu a:hover" parameterized="true"/> + <element name="itemByNameAndLevel" type="text" selector="//a[span[contains(., '{{itemName}}')]]/following-sibling::ul[contains(@class,'{{itemLevel}}')]" parameterized="true"/> + <element name="subItemByLevel" type="text" selector="li.{{itemLevel}}.parent ul.{{itemLevel}}" parameterized="true"/> + <element name="itemActiveState" type="text" selector=".navigation .level0.active>.level-top"/> + <element name="subItemActiveState" type="text" selector=".navigation .level0 .submenu .active>a"/> + <element name="submenuLeftDirection" type="text" selector="ul.{{itemLevel}}.submenu-reverse" parameterized="true"/> + <element name="submenuRightDirection" type="text" selector="ul.{{itemLevel}}:not(.submenu-reverse)" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php b/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php index 94a6ab0ec565e..91f176efbc7b9 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php @@ -282,4 +282,35 @@ public function testBeforeSaveWithExistingFile() $this->fileBackend->getValue() ); } + + /** + * Test for getRelativeMediaPath method. + * + * @param string $path + * @param string $filename + * @dataProvider getRelativeMediaPathDataProvider + */ + public function testGetRelativeMediaPath(string $path, string $filename) + { + $reflection = new \ReflectionClass($this->fileBackend); + $method = $reflection->getMethod('getRelativeMediaPath'); + $method->setAccessible(true); + $this->assertEquals( + $filename, + $method->invoke($this->fileBackend, $path . $filename) + ); + } + + /** + * Data provider for testGetRelativeMediaPath. + * + * @return array + */ + public function getRelativeMediaPathDataProvider(): array + { + return [ + 'Normal path' => ['pub/media/', 'filename.jpg'], + 'Complex path' => ['somepath/pub/media/', 'filename.jpg'], + ]; + } } diff --git a/app/code/Magento/Theme/etc/db_schema.xml b/app/code/Magento/Theme/etc/db_schema.xml index 7f3a3fc607947..84b7654e69160 100644 --- a/app/code/Magento/Theme/etc/db_schema.xml +++ b/app/code/Magento/Theme/etc/db_schema.xml @@ -11,7 +11,7 @@ <column xsi:type="int" name="theme_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Theme identifier"/> <column xsi:type="int" name="parent_id" padding="11" unsigned="false" nullable="true" identity="false" - comment="Parent Id"/> + comment="Parent ID"/> <column xsi:type="varchar" name="theme_path" nullable="true" length="255" comment="Theme Path"/> <column xsi:type="varchar" name="theme_title" nullable="false" length="255" comment="Theme Title"/> <column xsi:type="varchar" name="preview_image" nullable="true" length="255" comment="Preview Image"/> @@ -28,7 +28,7 @@ <column xsi:type="int" name="theme_files_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Theme files identifier"/> <column xsi:type="int" name="theme_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Theme Id"/> + comment="Theme ID"/> <column xsi:type="varchar" name="file_path" nullable="true" length="255" comment="Relative path to file"/> <column xsi:type="varchar" name="file_type" nullable="false" length="32" comment="File Type"/> <column xsi:type="longtext" name="content" nullable="false" comment="File Content"/> @@ -43,9 +43,9 @@ </table> <table name="design_change" resource="default" engine="innodb" comment="Design Changes"> <column xsi:type="int" name="design_change_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Design Change Id"/> + comment="Design Change ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="varchar" name="design" nullable="true" length="255" comment="Design"/> <column xsi:type="date" name="date_from" comment="First Date of Design Activity"/> <column xsi:type="date" name="date_to" comment="Last Date of Design Activity"/> diff --git a/app/code/Magento/Translation/Test/Mftf/Test/StorefrontButtonsInlineTranslationTest.xml b/app/code/Magento/Translation/Test/Mftf/Test/StorefrontButtonsInlineTranslationTest.xml new file mode 100644 index 0000000000000..155e174310ea9 --- /dev/null +++ b/app/code/Magento/Translation/Test/Mftf/Test/StorefrontButtonsInlineTranslationTest.xml @@ -0,0 +1,68 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontButtonsInlineTranslationTest"> + <annotations> + <features value="Translation"/> + <stories value="Inline Translation"/> + <title value="[Inline Translation] Buttons inline translation"/> + <description value="[Inline Translation] Buttons inline translation"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-12735"/> + <group value="translation"/> + <skip> + <issueId value="MC-20127"/> + </skip> + </annotations> + <before> + <!-- Create Simple Product --> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + <!-- Enable Translate Inline For Storefront--> + <magentoCLI + command="config:set {{EnableTranslateInlineForStorefront.path}} {{EnableTranslateInlineForStorefront.value}}" + stepKey="enableTranslateInlineForStorefront"/> + <!-- Set developer mode --> + <magentoCLI command="deploy:mode:set developer" stepKey="setDeveloperMode"/> + <magentoCLI command="cache:flush" stepKey="flushCache"/> + </before> + <after> + <!-- Disable Translate Inline For Storefront --> + <magentoCLI + command="config:set {{DisableTranslateInlineForStorefront.path}} {{DisableTranslateInlineForStorefront.value}}" + stepKey="disableTranslateInlineForStorefront"/> + <!-- Set production mode --> + <magentoCLI command="deploy:mode:set production" stepKey="setProductionMode"/> + + <!-- Delete Simple Product --> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + </after> + + <!-- Add product to cart on storefront --> + <actionGroup ref="AddSimpleProductToCart" stepKey="addProductToCart"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + + <!-- Click on cart button on the top --> + <click selector="{{StorefrontMiniCartSection.show}}" stepKey="showMiniCart"/> + + <!-- Small cart popup appeared. --> + <waitForElementVisible selector="{{StorefrontMinicartSection.productName}}" stepKey="seeProductNameAppeared"/> + + <!-- Check button "Proceed to Checkout". There must be red borders and "book" icons on labels that can be translated. --> + <actionGroup ref="AssertElementInTranslateInlineModeActionGroup" stepKey="assertRedBordersAndBookIcon"> + <argument name="elementSelector" value="{{StorefrontMinicartSection.goToCheckout}}"/> + </actionGroup> + + <actionGroup ref="AdminTranslateElementActionGroup" stepKey="translateProceedToCheckoutButtonText"> + <argument name="elementSelector" value="{{StorefrontMinicartSection.goToCheckout}}"/> + <argument name="translateText" value="Proceed to Checkout Translated"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Translation/composer.json b/app/code/Magento/Translation/composer.json index c01791c88f99f..e88f44e7cd039 100644 --- a/app/code/Magento/Translation/composer.json +++ b/app/code/Magento/Translation/composer.json @@ -9,7 +9,8 @@ "magento/framework": "*", "magento/module-backend": "*", "magento/module-developer": "*", - "magento/module-store": "*" + "magento/module-store": "*", + "magento/module-theme": "*" }, "suggest": { "magento/module-deploy": "*" diff --git a/app/code/Magento/Translation/etc/db_schema.xml b/app/code/Magento/Translation/etc/db_schema.xml index a0d08467acf06..a8ce30a0b4fd9 100644 --- a/app/code/Magento/Translation/etc/db_schema.xml +++ b/app/code/Magento/Translation/etc/db_schema.xml @@ -9,11 +9,11 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="translation" resource="default" engine="innodb" comment="Translations"> <column xsi:type="int" name="key_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Key Id of Translation"/> + comment="Key ID of Translation"/> <column xsi:type="varchar" name="string" nullable="false" length="255" default="Translate String" comment="Translation String"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="varchar" name="translate" nullable="true" length="255" comment="Translate"/> <column xsi:type="varchar" name="locale" nullable="false" length="20" default="en_US" comment="Locale"/> <column xsi:type="bigint" name="crc_string" padding="20" unsigned="false" nullable="false" identity="false" diff --git a/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php b/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php index 470767af6d319..31d2fe786cfd8 100644 --- a/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php +++ b/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php @@ -111,14 +111,7 @@ public function getComponentName() public function convertDate($date, $hour = 0, $minute = 0, $second = 0, $setUtcTimeZone = true) { try { - $dateObj = $this->localeDate->date( - new \DateTime( - $date, - new \DateTimeZone($this->localeDate->getConfigTimezone()) - ), - $this->getLocale(), - true - ); + $dateObj = $this->localeDate->date($date, $this->getLocale(), true); $dateObj->setTime($hour, $minute, $second); //convert store date to default date in UTC timezone without DST if ($setUtcTimeZone) { diff --git a/app/code/Magento/Ui/etc/db_schema.xml b/app/code/Magento/Ui/etc/db_schema.xml index 13a384024f18a..552bd267e707a 100644 --- a/app/code/Magento/Ui/etc/db_schema.xml +++ b/app/code/Magento/Ui/etc/db_schema.xml @@ -11,7 +11,7 @@ <column xsi:type="int" name="bookmark_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Bookmark identifier"/> <column xsi:type="int" name="user_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="User Id"/> + comment="User ID"/> <column xsi:type="varchar" name="namespace" nullable="false" length="255" comment="Bookmark namespace"/> <column xsi:type="varchar" name="identifier" nullable="false" length="255" comment="Bookmark Identifier"/> <column xsi:type="smallint" name="current" padding="6" unsigned="false" nullable="false" identity="false" diff --git a/app/code/Magento/UrlRewrite/Test/Mftf/ActionGroup/AdminProductFormUpdateUrlKeyActionGroup.xml b/app/code/Magento/UrlRewrite/Test/Mftf/ActionGroup/AdminProductFormUpdateUrlKeyActionGroup.xml new file mode 100644 index 0000000000000..967aa4be7cdc8 --- /dev/null +++ b/app/code/Magento/UrlRewrite/Test/Mftf/ActionGroup/AdminProductFormUpdateUrlKeyActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminProductFormUpdateUrlKeyActionGroup"> + <annotations> + <description>Update UrlKey for Product on Custom Store View.</description> + </annotations> + <arguments> + <argument name="newUrlKey" defaultValue="newUrlKey" type="string"/> + </arguments> + <conditionalClick selector="{{AdminProductSEOSection.sectionHeader}}" dependentSelector="{{AdminProductSEOSection.useDefaultUrl}}" visible="false" stepKey="clickOnSearchEngineOptimization"/> + <waitForLoadingMaskToDisappear stepKey="waitLoadProductForm"/> + <uncheckOption selector="{{AdminProductSEOSection.useDefaultUrl}}" stepKey="uncheckDefaultUrl"/> + <fillField selector="{{AdminProductSEOSection.urlKeyInput}}" userInput="{{newUrlKey}}" stepKey="changeUrlKey"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/UrlRewrite/Test/Mftf/ActionGroup/StorefrontCheckProductUrlActionGroup.xml b/app/code/Magento/UrlRewrite/Test/Mftf/ActionGroup/StorefrontCheckProductUrlActionGroup.xml new file mode 100644 index 0000000000000..c97a6d9bb8f24 --- /dev/null +++ b/app/code/Magento/UrlRewrite/Test/Mftf/ActionGroup/StorefrontCheckProductUrlActionGroup.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!-- Check the simple product Url on the product page --> + <actionGroup name="StorefrontCheckProductUrlActionGroup"> + <annotations> + <description>Validates that the provided Simple Product Url is correct.</description> + </annotations> + <arguments> + <argument name="productUrl" type="string"/> + </arguments> + <seeInCurrentUrl url="{{StorefrontProductPage.url(productUrl)}}" stepKey="checkUrl"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/UrlRewrite/Test/Mftf/Test/AdminProductCreateUrlRewriteForCustomStoreViewTest.xml b/app/code/Magento/UrlRewrite/Test/Mftf/Test/AdminProductCreateUrlRewriteForCustomStoreViewTest.xml new file mode 100644 index 0000000000000..c6ee1a7da9602 --- /dev/null +++ b/app/code/Magento/UrlRewrite/Test/Mftf/Test/AdminProductCreateUrlRewriteForCustomStoreViewTest.xml @@ -0,0 +1,114 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminProductCreateUrlRewriteForCustomStoreViewTest"> + <annotations> + <features value="UrlRewrite"/> + <stories value="Create Product"/> + <title value="Product custom URL Key is preserved when assigned to a Category"/> + <description value="Verify Product custom URL Key (for custom Store View) is preserved when assigned to a Category (with custom URL Key) alongside with another Product without custom URL Key"/> + <testCaseId value="MC-6463"/> + <severity value="MAJOR"/> + <group value="catalog"/> + <group value="url_rewrite"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory" /> + </createData> + <createData entity="SimpleProduct" stepKey="createProductForUrlRewrite"> + <requiredEntity createDataKey="createCategory" /> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!-- Create second store view --> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createCustomStoreView"> + <argument name="customStore" value="customStore"/> + </actionGroup> + <magentoCLI command="indexer:reindex" stepKey="runReindex"/> + </before> + <after> + <deleteData createDataKey="createProduct" stepKey="deleteProduct" /> + <deleteData createDataKey="createProductForUrlRewrite" stepKey="deleteProductForUrlRewrite" /> + <deleteData createDataKey="createCategory" stepKey="deleteCategory" /> + <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteStoreView"> + <argument name="customStore" value="customStore"/> + </actionGroup> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearFilterForStores"/> + <actionGroup ref="logout" stepKey="logoutFromAdmin"/> + </after> + <!--Step 1. Navigate as Admin on Product Page for edit product`s Url Key--> + <actionGroup ref="navigateToCreatedProductEditPage" stepKey="goToProductForUrlRewrite"> + <argument name="product" value="$$createProductForUrlRewrite$$"/> + </actionGroup> + <!--Step 2. As Admin switch on Custom Store View from Precondition --> + <actionGroup ref="AdminSwitchStoreViewActionGroup" stepKey="switchToCustomStore"> + <argument name="storeView" value="customStore.name"/> + </actionGroup> + <!--Step 3. Set custom URL Key for product on Custom StoreView--> + <actionGroup ref="AdminProductFormUpdateUrlKeyActionGroup" stepKey="updateUrlKeyForProduct"> + <argument name="newUrlKey" value="U2"/> + </actionGroup> + <actionGroup ref="saveProductForm" stepKey="saveProductWithNewUrl"/> + <!--Step 4. Set URL Key for created category --> + <actionGroup ref="navigateToCreatedCategory" stepKey="navigateToCreatedSubCategory"> + <argument name="Category" value="$$createCategory$$"/> + </actionGroup> + <actionGroup ref="ChangeSeoUrlKey" stepKey="updateUrlKeyForCategory"> + <argument name="value" value="U1"/> + </actionGroup> + <!--Step 5. On Storefront Assert what URL Key for Category is changed and is correct as for Default Store View --> + <actionGroup ref="StorefrontNavigateCategoryPageActionGroup" stepKey="onCategoryPage"> + <argument name="category" value="$$createCategory$$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontUrlRewriteRedirect" stepKey="assertUrlCategoryOnDefaultStore"> + <argument name="category" value="$$createCategory.name$$"/> + <argument name="newRequestPath" value="u1.html"/> + </actionGroup> + <!--Step 6. On Storefront Assert what URL Key for product is correct(as initial URL) --> + <actionGroup ref="OpenProductFromCategoryPageActionGroup" stepKey="navigateToProductInDefaultStore"> + <argument name="category" value="$$createCategory$$"/> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + <actionGroup ref="StorefrontCheckProductUrlActionGroup" stepKey="checkProductUrl"> + <argument name="productUrl" value="$$createProduct.custom_attributes[url_key]$$"/> + </actionGroup> + <!--Step 7. On Storefront Assert what URL Key for product is correct for Default Store View (as initial URL) --> + <actionGroup ref="OpenProductFromCategoryPageActionGroup" stepKey="navigateToProductForUrlRewriteInDefaultStore"> + <argument name="category" value="$$createCategory$$"/> + <argument name="product" value="$$createProductForUrlRewrite$$"/> + </actionGroup> + <actionGroup ref="StorefrontCheckProductUrlActionGroup" stepKey="checkProductWithChangedUrl"> + <argument name="productUrl" value="$$createProductForUrlRewrite.custom_attributes[url_key]$$"/> + </actionGroup> + <!--Step 8. On Storefront switch on created Custom Store View --> + <actionGroup ref="StorefrontSwitchStoreViewActionGroup" stepKey="switchToCustomStoreViewOnStorefront"> + <argument name="storeView" value="customStore"/> + </actionGroup> + <!--Step 9. On Storefront Assert what URL Key for Category is changed and is correct for Custom Store View --> + <actionGroup ref="AssertStorefrontUrlRewriteRedirect" stepKey="assertUrlCategoryOnCustomStore"> + <argument name="category" value="$$createCategory.name$$"/> + <argument name="newRequestPath" value="u1.html"/> + </actionGroup> + <!--Step 10. On Storefront Assert what URL Key for product is correct for Custom Store View (as initial URL) --> + <actionGroup ref="OpenProductFromCategoryPageActionGroup" stepKey="navigateToProductInCustomStore"> + <argument name="category" value="$$createCategory$$"/> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + <actionGroup ref="StorefrontCheckProductUrlActionGroup" stepKey="checkProductUrlOnCustomStore"> + <argument name="productUrl" value="$$createProduct.custom_attributes[url_key]$$"/> + </actionGroup> + <!--Step 11. On Storefront Assert what URL Key for product is changed and is correct for Custom Store View --> + <actionGroup ref="AssertStorefrontProductRedirect" stepKey="assertProductUrlRewriteInStoreFront"> + <argument name="productName" value="$$createProductForUrlRewrite.name$$"/> + <argument name="productSku" value="$$createProductForUrlRewrite.sku$$"/> + <argument name="productRequestPath" value="u2.html"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/UrlRewrite/etc/db_schema.xml b/app/code/Magento/UrlRewrite/etc/db_schema.xml index 6e0014873202d..06d4949e63d9a 100644 --- a/app/code/Magento/UrlRewrite/etc/db_schema.xml +++ b/app/code/Magento/UrlRewrite/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="url_rewrite" resource="default" engine="innodb" comment="Url Rewrites"> <column xsi:type="int" name="url_rewrite_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Rewrite Id"/> + comment="Rewrite ID"/> <column xsi:type="varchar" name="entity_type" nullable="false" length="32" comment="Entity type code"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" comment="Entity ID"/> @@ -18,7 +18,7 @@ <column xsi:type="smallint" name="redirect_type" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Redirect Type"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Store Id"/> + comment="Store ID"/> <column xsi:type="varchar" name="description" nullable="true" length="255" comment="Description"/> <column xsi:type="smallint" name="is_autogenerated" padding="5" unsigned="true" nullable="false" identity="false" default="0" comment="Is rewrite generated automatically flag"/> diff --git a/app/code/Magento/UrlRewriteGraphQl/etc/schema.graphqls b/app/code/Magento/UrlRewriteGraphQl/etc/schema.graphqls index 92d237d3f01e1..ace3e0eae831e 100644 --- a/app/code/Magento/UrlRewriteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/UrlRewriteGraphQl/etc/schema.graphqls @@ -2,7 +2,7 @@ # See COPYING.txt for license details. type Query { - urlResolver(url: String!): EntityUrl @resolver(class: "Magento\\UrlRewriteGraphQl\\Model\\Resolver\\EntityUrl") @doc(description: "The urlResolver query returns the relative URL for a specified product, category or CMS page") @cache(cacheIdentity: "Magento\\UrlRewriteGraphQl\\Model\\Resolver\\UrlRewrite\\UrlResolverIdentity") + urlResolver(url: String!): EntityUrl @resolver(class: "Magento\\UrlRewriteGraphQl\\Model\\Resolver\\EntityUrl") @doc(description: "The urlResolver query returns the relative URL for a specified product, category or CMS page, using as input a url_key appended by the url_suffix, if one exists") @cache(cacheIdentity: "Magento\\UrlRewriteGraphQl\\Model\\Resolver\\UrlRewrite\\UrlResolverIdentity") } type EntityUrl @doc(description: "EntityUrl is an output object containing the `id`, `relative_url`, and `type` attributes") { diff --git a/app/code/Magento/User/Model/User.php b/app/code/Magento/User/Model/User.php index d79f2013241e6..b1f4786f847e2 100644 --- a/app/code/Magento/User/Model/User.php +++ b/app/code/Magento/User/Model/User.php @@ -216,6 +216,9 @@ protected function _construct() * Removing dependencies and leaving only entity's properties. * * @return string[] + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -244,6 +247,9 @@ public function __sleep() * Restoring required objects after serialization. * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { @@ -413,6 +419,10 @@ public function getRoles() */ public function getRole() { + if ($this->getData('extracted_role')) { + $this->_role = $this->getData('extracted_role'); + $this->unsetData('extracted_role'); + } if (null === $this->_role) { $this->_role = $this->_roleFactory->create(); $roles = $this->getRoles(); diff --git a/app/code/Magento/User/Test/Unit/Model/Authorization/AdminSessionUserContextTest.php b/app/code/Magento/User/Test/Unit/Model/Authorization/AdminSessionUserContextTest.php deleted file mode 100644 index 23681c4b8da26..0000000000000 --- a/app/code/Magento/User/Test/Unit/Model/Authorization/AdminSessionUserContextTest.php +++ /dev/null @@ -1,89 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ - -namespace Magento\User\Test\Unit\Model\Authorization; - -use Magento\Authorization\Model\UserContextInterface; - -/** - * Tests Magento\User\Model\Authorization\AdminSessionUserContext - */ -class AdminSessionUserContextTest extends \PHPUnit\Framework\TestCase -{ - /** - * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager - */ - protected $objectManager; - - /** - * @var \Magento\User\Model\Authorization\AdminSessionUserContext - */ - protected $adminSessionUserContext; - - /** - * @var \Magento\Backend\Model\Auth\Session - */ - protected $adminSession; - - protected function setUp() - { - $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - - $this->adminSession = $this->getMockBuilder(\Magento\Backend\Model\Auth\Session::class) - ->disableOriginalConstructor() - ->setMethods(['hasUser', 'getUser', 'getId']) - ->getMock(); - - $this->adminSessionUserContext = $this->objectManager->getObject( - \Magento\User\Model\Authorization\AdminSessionUserContext::class, - ['adminSession' => $this->adminSession] - ); - } - - public function testGetUserIdExist() - { - $userId = 1; - - $this->setupUserId($userId); - - $this->assertEquals($userId, $this->adminSessionUserContext->getUserId()); - } - - public function testGetUserIdDoesNotExist() - { - $userId = null; - - $this->setupUserId($userId); - - $this->assertEquals($userId, $this->adminSessionUserContext->getUserId()); - } - - public function testGetUserType() - { - $this->assertEquals(UserContextInterface::USER_TYPE_ADMIN, $this->adminSessionUserContext->getUserType()); - } - - /** - * @param int|null $userId - * @return void - */ - public function setupUserId($userId) - { - $this->adminSession->expects($this->once()) - ->method('hasUser') - ->will($this->returnValue($userId)); - - if ($userId) { - $this->adminSession->expects($this->once()) - ->method('getUser') - ->will($this->returnSelf()); - - $this->adminSession->expects($this->once()) - ->method('getId') - ->will($this->returnValue($userId)); - } - } -} diff --git a/app/code/Magento/User/Test/Unit/Model/UserTest.php b/app/code/Magento/User/Test/Unit/Model/UserTest.php index 670316c2500fc..ab06c8754b2f0 100644 --- a/app/code/Magento/User/Test/Unit/Model/UserTest.php +++ b/app/code/Magento/User/Test/Unit/Model/UserTest.php @@ -44,31 +44,6 @@ protected function setUp() ); } - /** - * @return void - */ - public function testSleep() - { - $excludedProperties = [ - '_eventManager', - '_cacheManager', - '_registry', - '_appState', - '_userData', - '_config', - '_validatorObject', - '_roleFactory', - '_encryptor', - '_transportBuilder', - '_storeManager', - '_validatorBeforeSave' - ]; - $actualResult = $this->model->__sleep(); - $this->assertNotEmpty($actualResult); - $expectedResult = array_intersect($actualResult, $excludedProperties); - $this->assertEmpty($expectedResult); - } - /** * @return void */ diff --git a/app/code/Magento/User/etc/db_schema.xml b/app/code/Magento/User/etc/db_schema.xml index c3356a96b94a7..e175b50108bd9 100644 --- a/app/code/Magento/User/etc/db_schema.xml +++ b/app/code/Magento/User/etc/db_schema.xml @@ -46,9 +46,9 @@ </table> <table name="admin_passwords" resource="default" engine="innodb" comment="Admin Passwords"> <column xsi:type="int" name="password_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Password Id"/> + comment="Password ID"/> <column xsi:type="int" name="user_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" - comment="User Id"/> + comment="User ID"/> <column xsi:type="varchar" name="password_hash" nullable="true" length="100" comment="Password Hash"/> <column xsi:type="int" name="expires" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Deprecated"/> diff --git a/app/code/Magento/Variable/etc/db_schema.xml b/app/code/Magento/Variable/etc/db_schema.xml index 239e3b49983c1..cd6d7d105a08a 100644 --- a/app/code/Magento/Variable/etc/db_schema.xml +++ b/app/code/Magento/Variable/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="variable" resource="default" engine="innodb" comment="Variables"> <column xsi:type="int" name="variable_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Variable Id"/> + comment="Variable ID"/> <column xsi:type="varchar" name="code" nullable="true" length="255" comment="Variable Code"/> <column xsi:type="varchar" name="name" nullable="true" length="255" comment="Variable Name"/> <constraint xsi:type="primary" referenceId="PRIMARY"> @@ -21,11 +21,11 @@ </table> <table name="variable_value" resource="default" engine="innodb" comment="Variable Value"> <column xsi:type="int" name="value_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Variable Value Id"/> + comment="Variable Value ID"/> <column xsi:type="int" name="variable_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Variable Id"/> + default="0" comment="Variable ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="text" name="plain_value" nullable="true" comment="Plain Text Value"/> <column xsi:type="text" name="html_value" nullable="true" comment="Html Value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> diff --git a/app/code/Magento/Vault/composer.json b/app/code/Magento/Vault/composer.json index 7dc2e0be78640..c37bc51f9d432 100644 --- a/app/code/Magento/Vault/composer.json +++ b/app/code/Magento/Vault/composer.json @@ -12,7 +12,8 @@ "magento/module-payment": "*", "magento/module-quote": "*", "magento/module-sales": "*", - "magento/module-store": "*" + "magento/module-store": "*", + "magento/module-theme": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Vault/etc/db_schema.xml b/app/code/Magento/Vault/etc/db_schema.xml index 8a7c8dc4aa9fb..7110978710048 100644 --- a/app/code/Magento/Vault/etc/db_schema.xml +++ b/app/code/Magento/Vault/etc/db_schema.xml @@ -11,7 +11,7 @@ <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/> <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="true" identity="false" - comment="Customer Id"/> + comment="Customer ID"/> <column xsi:type="varchar" name="public_hash" nullable="false" length="128" comment="Hash code for using on frontend"/> <column xsi:type="varchar" name="payment_method_code" nullable="false" length="128" @@ -42,9 +42,9 @@ <table name="vault_payment_token_order_payment_link" resource="default" engine="innodb" comment="Order payments to vault token"> <column xsi:type="int" name="order_payment_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Order payment Id"/> + comment="Order payment ID"/> <column xsi:type="int" name="payment_token_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Payment token Id"/> + comment="Payment token ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="order_payment_id"/> <column name="payment_token_id"/> diff --git a/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php b/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php index 3ddb2e441ef91..f38c0f0978536 100644 --- a/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php +++ b/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php @@ -33,9 +33,7 @@ class Generator extends AbstractSchemaGenerator */ const ERROR_SCHEMA = '#/definitions/error-response'; - /** - * Unauthorized description - */ + /** Unauthorized description */ const UNAUTHORIZED_DESCRIPTION = '401 Unauthorized'; /** Array signifier */ @@ -759,7 +757,8 @@ private function handleComplex($name, $type, $prefix, $isArray) $subPrefix ); } - return array_merge(...$queryNames); + + return empty($queryNames) ? [] : array_merge(...$queryNames); } /** diff --git a/app/code/Magento/Webapi/Test/Unit/Model/Rest/Swagger/GeneratorTest.php b/app/code/Magento/Webapi/Test/Unit/Model/Rest/Swagger/GeneratorTest.php index 172db875c6c49..67e361bb019d0 100644 --- a/app/code/Magento/Webapi/Test/Unit/Model/Rest/Swagger/GeneratorTest.php +++ b/app/code/Magento/Webapi/Test/Unit/Model/Rest/Swagger/GeneratorTest.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Webapi\Test\Unit\Model\Rest\Swagger; /** @@ -137,11 +138,7 @@ public function testGenerate($serviceMetadata, $typeData, $schema) ->willReturn($serviceMetadata); $this->typeProcessorMock->expects($this->any()) ->method('getTypeData') - ->willReturnMap( - [ - ['TestModule5V2EntityAllSoapAndRest', $typeData], - ] - ); + ->willReturnMap($typeData); $this->typeProcessorMock->expects($this->any()) ->method('isTypeSimple') @@ -169,6 +166,96 @@ public function testGenerate($serviceMetadata, $typeData, $schema) public function generateDataProvider() { return [ + [ + [ + 'methods' => [ + 'execute' => [ + 'method' => 'execute', + 'inputRequired' => false, + 'isSecure' => false, + 'resources' => [ + "anonymous" + ], + 'methodAlias' => 'execute', + 'parameters' => [], + 'documentation' => 'Do Magic!', + 'interface' => [ + 'in' => [ + 'parameters' => [ + 'searchRequest' => [ + 'type' => 'DreamVendorDreamModuleApiDataSearchRequestInterface', + 'required' => true, + 'documentation' => "" + ] + ] + ], + 'out' => [ + 'parameters' => [ + 'result' => [ + 'type' => 'DreamVendorDreamModuleApiDataSearchResultInterface', + 'documentation' => null, + 'required' => true + ] + ] + ] + ] + ] + ], + 'class' => 'DreamVendor\DreamModule\Api\ExecuteStuff', + 'description' => '', + 'routes' => [ + '/V1/dream-vendor/dream-module/execute-stuff' => [ + 'GET' => [ + 'method' => 'execute', + 'parameters' => [] + ] + ] + ] + ], + [ + [ + 'DreamVendorDreamModuleApiDataSearchRequestInterface', + [ + 'documentation' => '', + 'parameters' => [ + 'stuff' => [ + 'type' => 'DreamVendorDreamModuleApiDataStuffInterface', + 'required' => true, + 'documentation' => 'Empty Extension Point' + ] + ] + ] + ], + [ + 'DreamVendorDreamModuleApiDataSearchResultInterface', + [ + 'documentation' => '', + 'parameters' => [ + 'totalCount' => [ + 'type' => 'int', + 'required' => true, + 'documentation' => 'Processed count.' + ], + 'stuff' => [ + 'type' => 'DreamVendorDreamModuleApiDataStuffInterface', + 'required' => true, + 'documentation' => 'Empty Extension Point' + ] + ] + ] + ], + [ + 'DreamVendorDreamModuleApiDataStuffInterface', + [ + 'documentation' => '', + 'parameters' => [] + ] + ] + ], + // @codingStandardsIgnoreStart + '{"swagger":"2.0","info":{"version":"","title":""},"host":"magento.host","basePath":"/rest/default","schemes":["http://"],"tags":[{"name":"testModule5AllSoapAndRestV2","description":""}],"paths":{"/V1/dream-vendor/dream-module/execute-stuff":{"get":{"tags":["testModule5AllSoapAndRestV2"],"description":"Do Magic!","operationId":"operationNameGet","consumes":["application/json","application/xml"],"produces":["application/json","application/xml"],"responses":{"200":{"description":"200 Success.","schema":{"$ref":"#/definitions/dream-vendor-dream-module-api-data-search-result-interface"}},"default":{"description":"Unexpected error","schema":{"$ref":"#/definitions/error-response"}}}}}},"definitions":{"error-response":{"type":"object","properties":{"message":{"type":"string","description":"Error message"},"errors":{"$ref":"#/definitions/error-errors"},"code":{"type":"integer","description":"Error code"},"parameters":{"$ref":"#/definitions/error-parameters"},"trace":{"type":"string","description":"Stack trace"}},"required":["message"]},"error-errors":{"type":"array","description":"Errors list","items":{"$ref":"#/definitions/error-errors-item"}},"error-errors-item":{"type":"object","description":"Error details","properties":{"message":{"type":"string","description":"Error message"},"parameters":{"$ref":"#/definitions/error-parameters"}}},"error-parameters":{"type":"array","description":"Error parameters list","items":{"$ref":"#/definitions/error-parameters-item"}},"error-parameters-item":{"type":"object","description":"Error parameters item","properties":{"resources":{"type":"string","description":"ACL resource"},"fieldName":{"type":"string","description":"Missing or invalid field name"},"fieldValue":{"type":"string","description":"Incorrect field value"}}},"dream-vendor-dream-module-api-data-search-result-interface":{"type":"object","description":"","properties":{"total_count":{"type":"integer","description":"Processed count."},"stuff":{"$ref":"#/definitions/dream-vendor-dream-module-api-data-stuff-interface"}},"required":["total_count","stuff"]},"dream-vendor-dream-module-api-data-stuff-interface":{"type":"object","description":""}}}' + // @codingStandardsIgnoreEnd + ], [ [ 'methods' => [ @@ -213,12 +300,17 @@ public function generateDataProvider() ], ], [ - 'documentation' => 'Some Data Object', - 'parameters' => [ - 'price' => [ - 'type' => 'int', - 'required' => true, - 'documentation' => "" + [ + 'TestModule5V2EntityAllSoapAndRest', + [ + 'documentation' => 'Some Data Object', + 'parameters' => [ + 'price' => [ + 'type' => 'int', + 'required' => true, + 'documentation' => "" + ] + ] ] ] ], @@ -261,12 +353,17 @@ public function generateDataProvider() ], ], [ - 'documentation' => 'Some Data Object', - 'parameters' => [ - 'price' => [ - 'type' => 'int', - 'required' => true, - 'documentation' => "" + [ + 'TestModule5V2EntityAllSoapAndRest', + [ + 'documentation' => 'Some Data Object', + 'parameters' => [ + 'price' => [ + 'type' => 'int', + 'required' => true, + 'documentation' => "" + ] + ] ] ] ], diff --git a/app/code/Magento/Weee/etc/db_schema.xml b/app/code/Magento/Weee/etc/db_schema.xml index 1b07168247011..aed8318993acf 100644 --- a/app/code/Magento/Weee/etc/db_schema.xml +++ b/app/code/Magento/Weee/etc/db_schema.xml @@ -9,9 +9,9 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="weee_tax" resource="default" engine="innodb" comment="Weee Tax"> <column xsi:type="int" name="value_id" padding="11" unsigned="false" nullable="false" identity="true" - comment="Value Id"/> + comment="Value ID"/> <column xsi:type="smallint" name="website_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Website Id"/> + default="0" comment="Website ID"/> <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" comment="Entity ID"/> <column xsi:type="varchar" name="country" nullable="true" length="2" comment="Country"/> @@ -20,7 +20,7 @@ <column xsi:type="int" name="state" padding="11" unsigned="false" nullable="false" identity="false" default="0" comment="State"/> <column xsi:type="smallint" name="attribute_id" padding="5" unsigned="true" nullable="false" identity="false" - comment="Attribute Id"/> + comment="Attribute ID"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="value_id"/> </constraint> diff --git a/app/code/Magento/Widget/etc/db_schema.xml b/app/code/Magento/Widget/etc/db_schema.xml index a82e6aae20296..6146761f6f251 100644 --- a/app/code/Magento/Widget/etc/db_schema.xml +++ b/app/code/Magento/Widget/etc/db_schema.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="widget" resource="default" engine="innodb" comment="Preconfigured Widgets"> <column xsi:type="int" name="widget_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Widget Id"/> + comment="Widget ID"/> <column xsi:type="varchar" name="widget_code" nullable="true" length="255" comment="Widget code for template directive"/> <column xsi:type="varchar" name="widget_type" nullable="true" length="255" comment="Widget Type"/> @@ -23,10 +23,10 @@ </table> <table name="widget_instance" resource="default" engine="innodb" comment="Instances of Widget for Package Theme"> <column xsi:type="int" name="instance_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Instance Id"/> + comment="Instance ID"/> <column xsi:type="varchar" name="instance_type" nullable="true" length="255" comment="Instance Type"/> <column xsi:type="int" name="theme_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Theme id"/> + comment="Theme ID"/> <column xsi:type="varchar" name="title" nullable="true" length="255" comment="Widget Title"/> <column xsi:type="varchar" name="store_ids" nullable="false" length="255" default="0" comment="Store ids"/> <column xsi:type="text" name="widget_parameters" nullable="true" comment="Widget parameters"/> @@ -40,9 +40,9 @@ </table> <table name="widget_instance_page" resource="default" engine="innodb" comment="Instance of Widget on Page"> <column xsi:type="int" name="page_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Page Id"/> + comment="Page ID"/> <column xsi:type="int" name="instance_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Instance Id"/> + default="0" comment="Instance ID"/> <column xsi:type="varchar" name="page_group" nullable="true" length="25" comment="Block Group Type"/> <column xsi:type="varchar" name="layout_handle" nullable="true" length="255" comment="Layout Handle"/> <column xsi:type="varchar" name="block_reference" nullable="true" length="255" comment="Container"/> @@ -61,9 +61,9 @@ </table> <table name="widget_instance_page_layout" resource="default" engine="innodb" comment="Layout updates"> <column xsi:type="int" name="page_id" padding="10" unsigned="true" nullable="false" identity="false" default="0" - comment="Page Id"/> + comment="Page ID"/> <column xsi:type="int" name="layout_update_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Layout Update Id"/> + default="0" comment="Layout Update ID"/> <constraint xsi:type="foreign" referenceId="WIDGET_INSTANCE_PAGE_LAYOUT_PAGE_ID_WIDGET_INSTANCE_PAGE_PAGE_ID" table="widget_instance_page_layout" column="page_id" referenceTable="widget_instance_page" referenceColumn="page_id" onDelete="CASCADE"/> @@ -80,7 +80,7 @@ </table> <table name="layout_update" resource="default" engine="innodb" comment="Layout Updates"> <column xsi:type="int" name="layout_update_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Layout Update Id"/> + comment="Layout Update ID"/> <column xsi:type="varchar" name="handle" nullable="true" length="255" comment="Handle"/> <column xsi:type="text" name="xml" nullable="true" comment="Xml"/> <column xsi:type="smallint" name="sort_order" padding="6" unsigned="false" nullable="false" identity="false" @@ -96,13 +96,13 @@ </table> <table name="layout_link" resource="default" engine="innodb" comment="Layout Link"> <column xsi:type="int" name="layout_link_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Link Id"/> + comment="Link ID"/> <column xsi:type="smallint" name="store_id" padding="5" unsigned="true" nullable="false" identity="false" - default="0" comment="Store Id"/> + default="0" comment="Store ID"/> <column xsi:type="int" name="theme_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Theme id"/> + comment="Theme ID"/> <column xsi:type="int" name="layout_update_id" padding="10" unsigned="true" nullable="false" identity="false" - default="0" comment="Layout Update Id"/> + default="0" comment="Layout Update ID"/> <column xsi:type="boolean" name="is_temporary" nullable="false" default="false" comment="Defines whether Layout Update is Temporary"/> <constraint xsi:type="primary" referenceId="PRIMARY"> diff --git a/app/code/Magento/Wishlist/Model/ResourceModel/Item/Collection/Grid.php b/app/code/Magento/Wishlist/Model/ResourceModel/Item/Collection/Grid.php index fb6b647811abb..6ef55bbe81b73 100644 --- a/app/code/Magento/Wishlist/Model/ResourceModel/Item/Collection/Grid.php +++ b/app/code/Magento/Wishlist/Model/ResourceModel/Item/Collection/Grid.php @@ -6,10 +6,12 @@ namespace Magento\Wishlist\Model\ResourceModel\Item\Collection; -use Magento\Customer\Controller\RegistryConstants as RegistryConstants; +use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; +use Magento\Customer\Controller\RegistryConstants; +use Magento\Wishlist\Model\Item; /** - * Wishlist item collection grouped by customer id + * Wishlist item collection for grid grouped by customer id * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -88,30 +90,48 @@ public function __construct( } /** - * Initialize db select - * - * @return $this + * @inheritdoc */ protected function _initSelect() { parent::_initSelect(); - $this->addCustomerIdFilter( - $this->_registryManager->registry(RegistryConstants::CURRENT_CUSTOMER_ID) - ) - ->resetSortOrder() - ->addDaysInWishlist() + + $customerId = $this->_registryManager->registry(RegistryConstants::CURRENT_CUSTOMER_ID); + $this->addDaysInWishlist() ->addStoreData() - ->setVisibilityFilter() - ->setInStockFilter(); + ->addCustomerIdFilter($customerId) + ->resetSortOrder(); + return $this; } /** - * Add select order - * - * @param string $field - * @param string $direction - * @return \Magento\Framework\Data\Collection\AbstractDb + * @inheritdoc + */ + protected function _assignProducts() + { + /** @var ProductCollection $productCollection */ + $productCollection = $this->_productCollectionFactory->create() + ->addAttributeToSelect($this->_wishlistConfig->getProductAttributes()) + ->addIdFilter($this->_productIds); + + /** @var Item $item */ + foreach ($this as $item) { + $product = $productCollection->getItemById($item->getProductId()); + if ($product) { + $product->setCustomOptions([]); + $item->setProduct($product); + $item->setProductName($product->getName()); + $item->setName($product->getName()); + $item->setPrice($product->getPrice()); + } + } + + return $this; + } + + /** + * @inheritdoc */ public function setOrder($field, $direction = self::SORT_ORDER_DESC) { @@ -127,24 +147,7 @@ public function setOrder($field, $direction = self::SORT_ORDER_DESC) } /** - * Add quantity to filter - * - * @param string $field - * @param array $condition - * @return \Magento\Wishlist\Model\ResourceModel\Item\Collection - */ - private function addQtyFilter(string $field, array $condition) - { - return parent::addFieldToFilter('main_table.' . $field, $condition); - } - - /** - * Add field filter to collection - * - * @param string|array $field - * @param null|string|array $condition - * @see self::_getConditionSql for $condition - * @return \Magento\Framework\Data\Collection\AbstractDb + * @inheritdoc */ public function addFieldToFilter($field, $condition = null) { @@ -168,6 +171,19 @@ public function addFieldToFilter($field, $condition = null) return $this->addQtyFilter($field, $condition); } } + return parent::addFieldToFilter($field, $condition); } + + /** + * Add quantity to filter + * + * @param string $field + * @param array $condition + * @return \Magento\Wishlist\Model\ResourceModel\Item\Collection + */ + private function addQtyFilter(string $field, array $condition) + { + return parent::addFieldToFilter('main_table.' . $field, $condition); + } } diff --git a/app/code/Magento/Wishlist/Model/Wishlist.php b/app/code/Magento/Wishlist/Model/Wishlist.php index 9797ab58b0766..9b7ff5177afae 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist.php +++ b/app/code/Magento/Wishlist/Model/Wishlist.php @@ -7,10 +7,30 @@ namespace Magento\Wishlist\Model; +use Exception; +use InvalidArgumentException; use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ProductFactory; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\StockRegistryInterface; +use Magento\CatalogInventory\Model\Configuration; +use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\ObjectManager; +use Magento\Framework\DataObject; +use Magento\Framework\DataObject\IdentityInterface; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Math\Random; +use Magento\Framework\Model\AbstractModel; +use Magento\Framework\Model\Context; +use Magento\Framework\Registry; use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Stdlib\DateTime; +use Magento\Store\Model\ScopeInterface; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Wishlist\Helper\Data; use Magento\Wishlist\Model\ResourceModel\Item\CollectionFactory; use Magento\Wishlist\Model\ResourceModel\Wishlist as ResourceWishlist; use Magento\Wishlist\Model\ResourceModel\Wishlist\Collection; @@ -19,21 +39,21 @@ * Wishlist model * * @method int getShared() - * @method \Magento\Wishlist\Model\Wishlist setShared(int $value) + * @method Wishlist setShared(int $value) * @method string getSharingCode() - * @method \Magento\Wishlist\Model\Wishlist setSharingCode(string $value) + * @method Wishlist setSharingCode(string $value) * @method string getUpdatedAt() - * @method \Magento\Wishlist\Model\Wishlist setUpdatedAt(string $value) + * @method Wishlist setUpdatedAt(string $value) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) * * @api * @since 100.0.2 */ -class Wishlist extends \Magento\Framework\Model\AbstractModel implements \Magento\Framework\DataObject\IdentityInterface +class Wishlist extends AbstractModel implements IdentityInterface { /** - * Cache tag + * Wishlist cache tag name */ const CACHE_TAG = 'wishlist'; @@ -47,14 +67,14 @@ class Wishlist extends \Magento\Framework\Model\AbstractModel implements \Magent /** * Wishlist item collection * - * @var \Magento\Wishlist\Model\ResourceModel\Item\Collection + * @var ResourceModel\Item\Collection */ protected $_itemCollection; /** * Store filter for wishlist * - * @var \Magento\Store\Model\Store + * @var Store */ protected $_store; @@ -68,7 +88,7 @@ class Wishlist extends \Magento\Framework\Model\AbstractModel implements \Magent /** * Wishlist data * - * @var \Magento\Wishlist\Helper\Data + * @var Data */ protected $_wishlistData; @@ -80,12 +100,12 @@ class Wishlist extends \Magento\Framework\Model\AbstractModel implements \Magent protected $_catalogProduct; /** - * @var \Magento\Store\Model\StoreManagerInterface + * @var StoreManagerInterface */ protected $_storeManager; /** - * @var \Magento\Framework\Stdlib\DateTime\DateTime + * @var DateTime\DateTime */ protected $_date; @@ -100,17 +120,17 @@ class Wishlist extends \Magento\Framework\Model\AbstractModel implements \Magent protected $_wishlistCollectionFactory; /** - * @var \Magento\Catalog\Model\ProductFactory + * @var ProductFactory */ protected $_productFactory; /** - * @var \Magento\Framework\Math\Random + * @var Random */ protected $mathRandom; /** - * @var \Magento\Framework\Stdlib\DateTime + * @var DateTime */ protected $dateTime; @@ -129,46 +149,60 @@ class Wishlist extends \Magento\Framework\Model\AbstractModel implements \Magent */ private $serializer; + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var StockRegistryInterface|null + */ + private $stockRegistry; + /** * Constructor * - * @param \Magento\Framework\Model\Context $context - * @param \Magento\Framework\Registry $registry + * @param Context $context + * @param Registry $registry * @param \Magento\Catalog\Helper\Product $catalogProduct - * @param \Magento\Wishlist\Helper\Data $wishlistData + * @param Data $wishlistData * @param ResourceWishlist $resource * @param Collection $resourceCollection - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - * @param \Magento\Framework\Stdlib\DateTime\DateTime $date + * @param StoreManagerInterface $storeManager + * @param DateTime\DateTime $date * @param ItemFactory $wishlistItemFactory * @param CollectionFactory $wishlistCollectionFactory - * @param \Magento\Catalog\Model\ProductFactory $productFactory - * @param \Magento\Framework\Math\Random $mathRandom - * @param \Magento\Framework\Stdlib\DateTime $dateTime + * @param ProductFactory $productFactory + * @param Random $mathRandom + * @param DateTime $dateTime * @param ProductRepositoryInterface $productRepository * @param bool $useCurrentWebsite * @param array $data * @param Json|null $serializer + * @param StockRegistryInterface|null $stockRegistry + * @param ScopeConfigInterface|null $scopeConfig * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( - \Magento\Framework\Model\Context $context, - \Magento\Framework\Registry $registry, + Context $context, + Registry $registry, \Magento\Catalog\Helper\Product $catalogProduct, - \Magento\Wishlist\Helper\Data $wishlistData, + Data $wishlistData, ResourceWishlist $resource, Collection $resourceCollection, - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\Framework\Stdlib\DateTime\DateTime $date, + StoreManagerInterface $storeManager, + DateTime\DateTime $date, ItemFactory $wishlistItemFactory, CollectionFactory $wishlistCollectionFactory, - \Magento\Catalog\Model\ProductFactory $productFactory, - \Magento\Framework\Math\Random $mathRandom, - \Magento\Framework\Stdlib\DateTime $dateTime, + ProductFactory $productFactory, + Random $mathRandom, + DateTime $dateTime, ProductRepositoryInterface $productRepository, $useCurrentWebsite = true, array $data = [], - Json $serializer = null + Json $serializer = null, + StockRegistryInterface $stockRegistry = null, + ScopeConfigInterface $scopeConfig = null ) { $this->_useCurrentWebsite = $useCurrentWebsite; $this->_catalogProduct = $catalogProduct; @@ -183,6 +217,8 @@ public function __construct( $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class); parent::__construct($context, $registry, $resource, $resourceCollection, $data); $this->productRepository = $productRepository; + $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); + $this->stockRegistry = $stockRegistry ?: ObjectManager::getInstance()->get(StockRegistryInterface::class); } /** @@ -290,13 +326,13 @@ public function afterSave() /** * Add catalog product object data to wishlist * - * @param \Magento\Catalog\Model\Product $product + * @param Product $product * @param int $qty * @param bool $forciblySetQty * * @return Item */ - protected function _addCatalogProduct(\Magento\Catalog\Model\Product $product, $qty = 1, $forciblySetQty = false) + protected function _addCatalogProduct(Product $product, $qty = 1, $forciblySetQty = false) { $item = null; foreach ($this->getItemCollection() as $_item) { @@ -311,7 +347,7 @@ protected function _addCatalogProduct(\Magento\Catalog\Model\Product $product, $ $item = $this->_wishlistItemFactory->create(); $item->setProductId($product->getId()); $item->setWishlistId($this->getId()); - $item->setAddedAt((new \DateTime())->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT)); + $item->setAddedAt((new \DateTime())->format(DateTime::DATETIME_PHP_FORMAT)); $item->setStoreId($storeId); $item->setOptions($product->getCustomOptions()); $item->setProduct($product); @@ -334,6 +370,7 @@ protected function _addCatalogProduct(\Magento\Catalog\Model\Product $product, $ * Retrieve wishlist item collection * * @return \Magento\Wishlist\Model\ResourceModel\Item\Collection + * @throws NoSuchEntityException */ public function getItemCollection() { @@ -365,8 +402,9 @@ public function getItem($itemId) /** * Adding item to wishlist * - * @param Item $item - * @return $this + * @param Item $item + * @return $this + * @throws Exception */ public function addItem(Item $item) { @@ -383,13 +421,14 @@ public function addItem(Item $item) * * Returns new item or string on error. * - * @param int|\Magento\Catalog\Model\Product $product - * @param \Magento\Framework\DataObject|array|string|null $buyRequest + * @param int|Product $product + * @param DataObject|array|string|null $buyRequest * @param bool $forciblySetQty - * @throws \Magento\Framework\Exception\LocalizedException * @return Item|string * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * @throws LocalizedException + * @throws InvalidArgumentException */ public function addNewItem($product, $buyRequest = null, $forciblySetQty = false) { @@ -398,7 +437,7 @@ public function addNewItem($product, $buyRequest = null, $forciblySetQty = false * a) we have new instance and do not interfere with other products in wishlist * b) product has full set of attributes */ - if ($product instanceof \Magento\Catalog\Model\Product) { + if ($product instanceof Product) { $productId = $product->getId(); // Maybe force some store by wishlist internal properties $storeId = $product->hasWishlistStoreId() ? $product->getWishlistStoreId() : $product->getStoreId(); @@ -412,12 +451,17 @@ public function addNewItem($product, $buyRequest = null, $forciblySetQty = false } try { + /** @var Product $product */ $product = $this->productRepository->getById($productId, false, $storeId); } catch (NoSuchEntityException $e) { - throw new \Magento\Framework\Exception\LocalizedException(__('Cannot specify product.')); + throw new LocalizedException(__('Cannot specify product.')); + } + + if ($this->isInStock($productId)) { + throw new LocalizedException(__('Cannot add product without stock to wishlist.')); } - if ($buyRequest instanceof \Magento\Framework\DataObject) { + if ($buyRequest instanceof DataObject) { $_buyRequest = $buyRequest; } elseif (is_string($buyRequest)) { $isInvalidItemConfiguration = false; @@ -426,20 +470,20 @@ public function addNewItem($product, $buyRequest = null, $forciblySetQty = false if (!is_array($buyRequestData)) { $isInvalidItemConfiguration = true; } - } catch (\InvalidArgumentException $exception) { + } catch (Exception $exception) { $isInvalidItemConfiguration = true; } if ($isInvalidItemConfiguration) { - throw new \InvalidArgumentException('Invalid wishlist item configuration.'); + throw new InvalidArgumentException('Invalid wishlist item configuration.'); } - $_buyRequest = new \Magento\Framework\DataObject($buyRequestData); + $_buyRequest = new DataObject($buyRequestData); } elseif (is_array($buyRequest)) { - $_buyRequest = new \Magento\Framework\DataObject($buyRequest); + $_buyRequest = new DataObject($buyRequest); } else { - $_buyRequest = new \Magento\Framework\DataObject(); + $_buyRequest = new DataObject(); } - /* @var $product \Magento\Catalog\Model\Product */ + /* @var $product Product */ $cartCandidates = $product->getTypeInstance()->processConfiguration($_buyRequest, clone $product); /** @@ -486,6 +530,7 @@ public function addNewItem($product, $buyRequest = null, $forciblySetQty = false * * @param int $customerId * @return $this + * @throws LocalizedException */ public function setCustomerId($customerId) { @@ -496,6 +541,7 @@ public function setCustomerId($customerId) * Retrieve customer id * * @return int + * @throws LocalizedException */ public function getCustomerId() { @@ -506,6 +552,7 @@ public function getCustomerId() * Retrieve data for save * * @return array + * @throws LocalizedException */ public function getDataForSave() { @@ -520,6 +567,7 @@ public function getDataForSave() * Retrieve shared store ids for current website or all stores if $current is false * * @return array + * @throws NoSuchEntityException */ public function getSharedStoreIds() { @@ -554,6 +602,7 @@ public function setSharedStoreIds($storeIds) * Retrieve wishlist store object * * @return \Magento\Store\Model\Store + * @throws NoSuchEntityException */ public function getStore() { @@ -566,7 +615,7 @@ public function getStore() /** * Set wishlist store * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return $this */ public function setStore($store) @@ -600,11 +649,30 @@ public function isSalable() return false; } + /** + * Retrieve if product has stock or config is set for showing out of stock products + * + * @param int $productId + * @return bool + */ + private function isInStock($productId) + { + /** @var StockItemInterface $stockItem */ + $stockItem = $this->stockRegistry->getStockItem($productId); + $showOutOfStock = $this->scopeConfig->isSetFlag( + Configuration::XML_PATH_SHOW_OUT_OF_STOCK, + ScopeInterface::SCOPE_STORE + ); + $isInStock = $stockItem ? $stockItem->getIsInStock() : false; + return !$isInStock && !$showOutOfStock; + } + /** * Check customer is owner this wishlist * * @param int $customerId * @return bool + * @throws LocalizedException */ public function isOwner($customerId) { @@ -626,10 +694,10 @@ public function isOwner($customerId) * For more options see \Magento\Catalog\Helper\Product->addParamsToBuyRequest() * * @param int|Item $itemId - * @param \Magento\Framework\DataObject $buyRequest - * @param null|array|\Magento\Framework\DataObject $params + * @param DataObject $buyRequest + * @param null|array|DataObject $params * @return $this - * @throws \Magento\Framework\Exception\LocalizedException + * @throws LocalizedException * * @see \Magento\Catalog\Helper\Product::addParamsToBuyRequest() * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -645,16 +713,16 @@ public function updateItem($itemId, $buyRequest, $params = null) $item = $this->getItem((int)$itemId); } if (!$item) { - throw new \Magento\Framework\Exception\LocalizedException(__('We can\'t specify a wish list item.')); + throw new LocalizedException(__('We can\'t specify a wish list item.')); } $product = $item->getProduct(); $productId = $product->getId(); if ($productId) { if (!$params) { - $params = new \Magento\Framework\DataObject(); + $params = new DataObject(); } elseif (is_array($params)) { - $params = new \Magento\Framework\DataObject($params); + $params = new DataObject($params); } $params->setCurrentConfig($item->getBuyRequest()); $buyRequest = $this->_catalogProduct->addParamsToBuyRequest($buyRequest, $params); @@ -677,7 +745,7 @@ public function updateItem($itemId, $buyRequest, $params = null) * Error message */ if (is_string($resultItem)) { - throw new \Magento\Framework\Exception\LocalizedException(__($resultItem)); + throw new LocalizedException(__($resultItem)); } if ($resultItem->getId() != $itemId) { @@ -691,7 +759,7 @@ public function updateItem($itemId, $buyRequest, $params = null) $resultItem->setOrigData('qty', 0); } } else { - throw new \Magento\Framework\Exception\LocalizedException(__('The product does not exist.')); + throw new LocalizedException(__('The product does not exist.')); } return $this; } diff --git a/app/code/Magento/Wishlist/Test/Unit/Model/WishlistTest.php b/app/code/Magento/Wishlist/Test/Unit/Model/WishlistTest.php index ff8a3a3b87cec..eb788efc0d622 100644 --- a/app/code/Magento/Wishlist/Test/Unit/Model/WishlistTest.php +++ b/app/code/Magento/Wishlist/Test/Unit/Model/WishlistTest.php @@ -5,76 +5,104 @@ */ namespace Magento\Wishlist\Test\Unit\Model; +use ArrayIterator; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Helper\Product as HelperProduct; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Catalog\Model\ProductFactory; +use Magento\CatalogInventory\Api\StockRegistryInterface; +use Magento\CatalogInventory\Model\Stock\Item as StockItem; +use Magento\CatalogInventory\Model\Stock\StockItemRepository; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\DataObject; +use Magento\Framework\Event\ManagerInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Math\Random; +use Magento\Framework\Model\Context; +use Magento\Framework\Registry; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Stdlib\DateTime; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Wishlist\Helper\Data; +use Magento\Wishlist\Model\Item; +use Magento\Wishlist\Model\ItemFactory; +use Magento\Wishlist\Model\ResourceModel\Item\Collection; +use Magento\Wishlist\Model\ResourceModel\Item\CollectionFactory; +use Magento\Wishlist\Model\ResourceModel\Wishlist as WishlistResource; +use Magento\Wishlist\Model\ResourceModel\Wishlist\Collection as WishlistCollection; use Magento\Wishlist\Model\Wishlist; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) */ -class WishlistTest extends \PHPUnit\Framework\TestCase +class WishlistTest extends TestCase { /** - * @var \Magento\Framework\Registry|\PHPUnit_Framework_MockObject_MockObject + * @var Registry|PHPUnit_Framework_MockObject_MockObject */ protected $registry; /** - * @var \Magento\Catalog\Helper\Product|\PHPUnit_Framework_MockObject_MockObject + * @var HelperProduct|PHPUnit_Framework_MockObject_MockObject */ protected $productHelper; /** - * @var \Magento\Wishlist\Helper\Data|\PHPUnit_Framework_MockObject_MockObject + * @var Data|PHPUnit_Framework_MockObject_MockObject */ protected $helper; /** - * @var \Magento\Wishlist\Model\ResourceModel\Wishlist|\PHPUnit_Framework_MockObject_MockObject + * @var WishlistResource|PHPUnit_Framework_MockObject_MockObject */ protected $resource; /** - * @var \Magento\Wishlist\Model\ResourceModel\Wishlist\Collection|\PHPUnit_Framework_MockObject_MockObject + * @var WishlistCollection|PHPUnit_Framework_MockObject_MockObject */ protected $collection; /** - * @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var StoreManagerInterface|PHPUnit_Framework_MockObject_MockObject */ protected $storeManager; /** - * @var \Magento\Framework\Stdlib\DateTime\DateTime|\PHPUnit_Framework_MockObject_MockObject + * @var DateTime\DateTime|PHPUnit_Framework_MockObject_MockObject */ protected $date; /** - * @var \Magento\Wishlist\Model\ItemFactory|\PHPUnit_Framework_MockObject_MockObject + * @var ItemFactory|PHPUnit_Framework_MockObject_MockObject */ protected $itemFactory; /** - * @var \Magento\Wishlist\Model\ResourceModel\Item\CollectionFactory|\PHPUnit_Framework_MockObject_MockObject + * @var CollectionFactory|PHPUnit_Framework_MockObject_MockObject */ protected $itemsFactory; /** - * @var \Magento\Catalog\Model\ProductFactory|\PHPUnit_Framework_MockObject_MockObject + * @var ProductFactory|PHPUnit_Framework_MockObject_MockObject */ protected $productFactory; /** - * @var \Magento\Framework\Math\Random|\PHPUnit_Framework_MockObject_MockObject + * @var Random|PHPUnit_Framework_MockObject_MockObject */ protected $mathRandom; /** - * @var \Magento\Framework\Stdlib\DateTime|\PHPUnit_Framework_MockObject_MockObject + * @var DateTime|PHPUnit_Framework_MockObject_MockObject */ protected $dateTime; /** - * @var \Magento\Framework\Event\ManagerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ManagerInterface|PHPUnit_Framework_MockObject_MockObject */ protected $eventDispatcher; @@ -84,63 +112,79 @@ class WishlistTest extends \PHPUnit\Framework\TestCase protected $wishlist; /** - * @var \Magento\Catalog\Api\ProductRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ProductRepositoryInterface|PHPUnit_Framework_MockObject_MockObject */ protected $productRepository; /** - * @var \Magento\Framework\Serialize\Serializer\Json|\PHPUnit_Framework_MockObject_MockObject + * @var Json|PHPUnit_Framework_MockObject_MockObject */ protected $serializer; + /** + * @var StockItemRepository|PHPUnit_Framework_MockObject_MockObject + */ + private $scopeConfig; + + /** + * @var StockRegistryInterface|PHPUnit_Framework_MockObject_MockObject + */ + private $stockRegistry; + protected function setUp() { - $context = $this->getMockBuilder(\Magento\Framework\Model\Context::class) + $context = $this->getMockBuilder(Context::class) ->disableOriginalConstructor() ->getMock(); - $this->eventDispatcher = $this->getMockBuilder(\Magento\Framework\Event\ManagerInterface::class) + $this->eventDispatcher = $this->getMockBuilder(ManagerInterface::class) ->getMock(); - $this->registry = $this->getMockBuilder(\Magento\Framework\Registry::class) + $this->registry = $this->getMockBuilder(Registry::class) ->disableOriginalConstructor() ->getMock(); - $this->productHelper = $this->getMockBuilder(\Magento\Catalog\Helper\Product::class) + $this->productHelper = $this->getMockBuilder(HelperProduct::class) ->disableOriginalConstructor() ->getMock(); - $this->helper = $this->getMockBuilder(\Magento\Wishlist\Helper\Data::class) + $this->helper = $this->getMockBuilder(Data::class) ->disableOriginalConstructor() ->getMock(); - $this->resource = $this->getMockBuilder(\Magento\Wishlist\Model\ResourceModel\Wishlist::class) + $this->resource = $this->getMockBuilder(WishlistResource::class) ->disableOriginalConstructor() ->getMock(); - $this->collection = $this->getMockBuilder(\Magento\Wishlist\Model\ResourceModel\Wishlist\Collection::class) + $this->collection = $this->getMockBuilder(WishlistCollection::class) ->disableOriginalConstructor() ->getMock(); - $this->storeManager = $this->getMockBuilder(\Magento\Store\Model\StoreManagerInterface::class) + $this->storeManager = $this->getMockBuilder(StoreManagerInterface::class) ->getMock(); - $this->date = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime\DateTime::class) + $this->date = $this->getMockBuilder(DateTime\DateTime::class) ->disableOriginalConstructor() ->getMock(); - $this->itemFactory = $this->getMockBuilder(\Magento\Wishlist\Model\ItemFactory::class) + $this->itemFactory = $this->getMockBuilder(ItemFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->itemsFactory = $this->getMockBuilder(\Magento\Wishlist\Model\ResourceModel\Item\CollectionFactory::class) + $this->itemsFactory = $this->getMockBuilder(CollectionFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->productFactory = $this->getMockBuilder(\Magento\Catalog\Model\ProductFactory::class) + $this->productFactory = $this->getMockBuilder(ProductFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->mathRandom = $this->getMockBuilder(\Magento\Framework\Math\Random::class) + $this->mathRandom = $this->getMockBuilder(Random::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dateTime = $this->getMockBuilder(DateTime::class) ->disableOriginalConstructor() ->getMock(); - $this->dateTime = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime::class) + $this->productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->stockRegistry = $this->createMock(StockRegistryInterface::class); + $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + + $this->scopeConfig = $this->getMockBuilder(ScopeConfigInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->productRepository = $this->createMock(\Magento\Catalog\Api\ProductRepositoryInterface::class); - $this->serializer = $this->getMockBuilder(\Magento\Framework\Serialize\Serializer\Json::class) + $this->serializer = $this->getMockBuilder(Json::class) ->disableOriginalConstructor() ->getMock(); @@ -165,7 +209,9 @@ protected function setUp() $this->productRepository, false, [], - $this->serializer + $this->serializer, + $this->stockRegistry, + $this->scopeConfig ); } @@ -186,7 +232,7 @@ public function testLoadByCustomerId() ->will($this->returnValue($sharingCode)); $this->assertInstanceOf( - \Magento\Wishlist\Model\Wishlist::class, + Wishlist::class, $this->wishlist->loadByCustomerId($customerId, true) ); $this->assertEquals($customerId, $this->wishlist->getCustomerId()); @@ -194,10 +240,10 @@ public function testLoadByCustomerId() } /** - * @param int|\Magento\Wishlist\Model\Item|\PHPUnit_Framework_MockObject_MockObject $itemId - * @param \Magento\Framework\DataObject $buyRequest - * @param null|array|\Magento\Framework\DataObject $param - * @throws \Magento\Framework\Exception\LocalizedException + * @param int|Item|PHPUnit_Framework_MockObject_MockObject $itemId + * @param DataObject $buyRequest + * @param null|array|DataObject $param + * @throws LocalizedException * * @dataProvider updateItemDataProvider */ @@ -205,9 +251,9 @@ public function testUpdateItem($itemId, $buyRequest, $param) { $storeId = 1; $productId = 1; - $stores = [(new \Magento\Framework\DataObject())->setId($storeId)]; + $stores = [(new DataObject())->setId($storeId)]; - $newItem = $this->getMockBuilder(\Magento\Wishlist\Model\Item::class) + $newItem = $this->getMockBuilder(Item::class) ->setMethods( ['setProductId', 'setWishlistId', 'setStoreId', 'setOptions', 'setProduct', 'setQty', 'getItem', 'save'] ) @@ -228,26 +274,30 @@ public function testUpdateItem($itemId, $buyRequest, $param) $this->storeManager->expects($this->any())->method('getStore')->will($this->returnValue($stores[0])); $product = $this->getMockBuilder( - \Magento\Catalog\Model\Product::class + Product::class )->disableOriginalConstructor()->getMock(); $product->expects($this->any())->method('getId')->will($this->returnValue($productId)); $product->expects($this->any())->method('getStoreId')->will($this->returnValue($storeId)); - $instanceType = $this->getMockBuilder(\Magento\Catalog\Model\Product\Type\AbstractType::class) + $stockItem = $this->getMockBuilder(StockItem::class)->disableOriginalConstructor()->getMock(); + $stockItem->expects($this->any())->method('getIsInStock')->will($this->returnValue(true)); + $this->stockRegistry->expects($this->any()) + ->method('getStockItem') + ->will($this->returnValue($stockItem)); + + $instanceType = $this->getMockBuilder(AbstractType::class) ->disableOriginalConstructor() ->getMock(); $instanceType->expects($this->once()) ->method('processConfiguration') ->will( $this->returnValue( - $this->getMockBuilder( - \Magento\Catalog\Model\Product::class - )->disableOriginalConstructor()->getMock() + $this->getMockBuilder(Product::class)->disableOriginalConstructor()->getMock() ) ); $newProduct = $this->getMockBuilder( - \Magento\Catalog\Model\Product::class + Product::class )->disableOriginalConstructor()->getMock(); $newProduct->expects($this->any()) ->method('setStoreId') @@ -257,12 +307,12 @@ public function testUpdateItem($itemId, $buyRequest, $param) ->method('getTypeInstance') ->will($this->returnValue($instanceType)); - $item = $this->getMockBuilder(\Magento\Wishlist\Model\Item::class)->disableOriginalConstructor()->getMock(); + $item = $this->getMockBuilder(Item::class)->disableOriginalConstructor()->getMock(); $item->expects($this->once()) ->method('getProduct') ->will($this->returnValue($product)); - $items = $this->getMockBuilder(\Magento\Wishlist\Model\ResourceModel\Item\Collection::class) + $items = $this->getMockBuilder(Collection::class) ->disableOriginalConstructor() ->getMock(); @@ -280,7 +330,7 @@ public function testUpdateItem($itemId, $buyRequest, $param) ->will($this->returnValue($item)); $items->expects($this->any()) ->method('getIterator') - ->will($this->returnValue(new \ArrayIterator([$item]))); + ->will($this->returnValue(new ArrayIterator([$item]))); $this->itemsFactory->expects($this->any()) ->method('create') @@ -292,7 +342,7 @@ public function testUpdateItem($itemId, $buyRequest, $param) ->will($this->returnValue($newProduct)); $this->assertInstanceOf( - \Magento\Wishlist\Model\Wishlist::class, + Wishlist::class, $this->wishlist->updateItem($itemId, $buyRequest, $param) ); } @@ -303,7 +353,7 @@ public function testUpdateItem($itemId, $buyRequest, $param) public function updateItemDataProvider() { return [ - '0' => [1, new \Magento\Framework\DataObject(), null] + '0' => [1, new DataObject(), null] ]; } @@ -311,24 +361,26 @@ public function testAddNewItem() { $productId = 1; $storeId = 1; - $buyRequest = json_encode([ - 'number' => 42, - 'string' => 'string_value', - 'boolean' => true, - 'collection' => [1, 2, 3], - 'product' => 1, - 'form_key' => 'abc' - ]); + $buyRequest = json_encode( + [ + 'number' => 42, + 'string' => 'string_value', + 'boolean' => true, + 'collection' => [1, 2, 3], + 'product' => 1, + 'form_key' => 'abc' + ] + ); $result = 'product'; - $instanceType = $this->getMockBuilder(\Magento\Catalog\Model\Product\Type\AbstractType::class) + $instanceType = $this->getMockBuilder(AbstractType::class) ->disableOriginalConstructor() ->getMock(); $instanceType->expects($this->once()) ->method('processConfiguration') ->willReturn('product'); - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) + $productMock = $this->getMockBuilder(Product::class) ->disableOriginalConstructor() ->setMethods(['getId', 'hasWishlistStoreId', 'getStoreId', 'getTypeInstance']) ->getMock(); @@ -358,6 +410,15 @@ function ($value) { } ); + $stockItem = $this->getMockBuilder( + StockItem::class + )->disableOriginalConstructor()->getMock(); + $stockItem->expects($this->any())->method('getIsInStock')->will($this->returnValue(true)); + + $this->stockRegistry->expects($this->any()) + ->method('getStockItem') + ->will($this->returnValue($stockItem)); + $this->assertEquals($result, $this->wishlist->addNewItem($productMock, $buyRequest)); } } diff --git a/app/code/Magento/Wishlist/etc/db_schema.xml b/app/code/Magento/Wishlist/etc/db_schema.xml index 8a02f411ad0db..e3f3024df45fd 100644 --- a/app/code/Magento/Wishlist/etc/db_schema.xml +++ b/app/code/Magento/Wishlist/etc/db_schema.xml @@ -64,11 +64,11 @@ </table> <table name="wishlist_item_option" resource="default" engine="innodb" comment="Wishlist Item Option Table"> <column xsi:type="int" name="option_id" padding="10" unsigned="true" nullable="false" identity="true" - comment="Option Id"/> + comment="Option ID"/> <column xsi:type="int" name="wishlist_item_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Wishlist Item Id"/> + comment="Wishlist Item ID"/> <column xsi:type="int" name="product_id" padding="10" unsigned="true" nullable="false" identity="false" - comment="Product Id"/> + comment="Product ID"/> <column xsi:type="varchar" name="code" nullable="false" length="255" comment="Code"/> <column xsi:type="text" name="value" nullable="true" comment="Value"/> <constraint xsi:type="primary" referenceId="PRIMARY"> diff --git a/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js b/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js index 033e2e43a3c22..aca843872af65 100644 --- a/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js +++ b/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js @@ -79,7 +79,9 @@ define([ $(element).is('textarea') || $('#' + element.id + ' option:selected').length ) { - dataToAdd = $.extend({}, dataToAdd, self._getElementData(element)); + if ($(element).data('selector') || $(element).attr('name')) { + dataToAdd = $.extend({}, dataToAdd, self._getElementData(element)); + } return; } diff --git a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/header/actions-group/_notifications.less b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/header/actions-group/_notifications.less index 40ebb6f3c4569..6b30bf70772a4 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/header/actions-group/_notifications.less +++ b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/header/actions-group/_notifications.less @@ -97,13 +97,14 @@ display: inline-block; font-size: @notifications__font-size; font-weight: @font-weight__bold; - height: 18px; + height: 20px; left: 50%; + line-height: 20px; margin-left: .3em; margin-top: -1.1em; - min-width: 18px; - padding: .3em .5em; + min-width: 20px; position: absolute; + text-align: center; top: 50%; } } diff --git a/app/design/adminhtml/Magento/backend/etc/view.xml b/app/design/adminhtml/Magento/backend/etc/view.xml index 05a5e1978c751..621c18fc97cc8 100644 --- a/app/design/adminhtml/Magento/backend/etc/view.xml +++ b/app/design/adminhtml/Magento/backend/etc/view.xml @@ -24,7 +24,6 @@ </media> <exclude> <item type="file">Lib::mage/captcha.js</item> - <item type="file">Lib::mage/captcha.min.js</item> <item type="file">Lib::mage/common.js</item> <item type="file">Lib::mage/cookies.js</item> <item type="file">Lib::mage/dataPost.js</item> @@ -46,7 +45,6 @@ <item type="file">Lib::mage/translate-inline-vde.js</item> <item type="file">Lib::mage/webapi.js</item> <item type="file">Lib::mage/zoom.js</item> - <item type="file">Lib::mage/validation/dob-rule.js</item> <item type="file">Lib::mage/validation/validation.js</item> <item type="file">Lib::mage/adminhtml/varienLoader.js</item> <item type="file">Lib::mage/adminhtml/tools.js</item> @@ -57,11 +55,9 @@ <item type="file">Lib::jquery/jquery.parsequery.js</item> <item type="file">Lib::jquery/jquery.mobile.custom.js</item> <item type="file">Lib::jquery/jquery-ui.js</item> - <item type="file">Lib::jquery/jquery-ui.min.js</item> <item type="file">Lib::matchMedia.js</item> <item type="file">Lib::requirejs/require.js</item> <item type="file">Lib::requirejs/text.js</item> - <item type="file">Lib::date-format-normalizer.js</item> <item type="file">Lib::varien/js.js</item> <item type="directory">Magento_Tinymce3::tiny_mce</item> <item type="directory">Lib::css</item> @@ -73,10 +69,5 @@ <item type="directory">Lib::fotorama</item> <item type="directory">Lib::magnifier</item> <item type="directory">Lib::tiny_mce</item> - <item type="directory">Lib::tiny_mce/classes</item> - <item type="directory">Lib::tiny_mce/langs</item> - <item type="directory">Lib::tiny_mce/plugins</item> - <item type="directory">Lib::tiny_mce/themes</item> - <item type="directory">Lib::tiny_mce/utils</item> </exclude> </view> diff --git a/app/design/adminhtml/Magento/backend/web/css/styles-old.less b/app/design/adminhtml/Magento/backend/web/css/styles-old.less index 53af8933343f1..44fca79c31be5 100644 --- a/app/design/adminhtml/Magento/backend/web/css/styles-old.less +++ b/app/design/adminhtml/Magento/backend/web/css/styles-old.less @@ -4060,6 +4060,16 @@ } } +.newsletter-template-preview { + height: 100%; + .cms-revision-preview { + height: 100%; + .preview_iframe { + height: calc(~'100% - 50px'); + } + } +} + .admin__scope-old { .buttons-set { margin: 0 0 15px; diff --git a/app/design/frontend/Magento/blank/etc/view.xml b/app/design/frontend/Magento/blank/etc/view.xml index 0081a254eb3b6..5884699af15cd 100644 --- a/app/design/frontend/Magento/blank/etc/view.xml +++ b/app/design/frontend/Magento/blank/etc/view.xml @@ -262,12 +262,10 @@ <item type="file">Lib::jquery/jquery.min.js</item> <item type="file">Lib::jquery/jquery-ui-1.9.2.js</item> <item type="file">Lib::jquery/jquery.details.js</item> - <item type="file">Lib::jquery/jquery.details.min.js</item> <item type="file">Lib::jquery/jquery.hoverIntent.js</item> <item type="file">Lib::jquery/colorpicker/js/colorpicker.js</item> <item type="file">Lib::requirejs/require.js</item> <item type="file">Lib::requirejs/text.js</item> - <item type="file">Lib::date-format-normalizer.js</item> <item type="file">Lib::legacy-build.min.js</item> <item type="file">Lib::mage/captcha.js</item> <item type="file">Lib::mage/dropdown_old.js</item> diff --git a/app/design/frontend/Magento/luma/etc/view.xml b/app/design/frontend/Magento/luma/etc/view.xml index ff1d06204e51e..a2802b7e374f3 100644 --- a/app/design/frontend/Magento/luma/etc/view.xml +++ b/app/design/frontend/Magento/luma/etc/view.xml @@ -273,12 +273,10 @@ <item type="file">Lib::jquery/jquery.min.js</item> <item type="file">Lib::jquery/jquery-ui-1.9.2.js</item> <item type="file">Lib::jquery/jquery.details.js</item> - <item type="file">Lib::jquery/jquery.details.min.js</item> <item type="file">Lib::jquery/jquery.hoverIntent.js</item> <item type="file">Lib::jquery/colorpicker/js/colorpicker.js</item> <item type="file">Lib::requirejs/require.js</item> <item type="file">Lib::requirejs/text.js</item> - <item type="file">Lib::date-format-normalizer.js</item> <item type="file">Lib::legacy-build.min.js</item> <item type="file">Lib::mage/captcha.js</item> <item type="file">Lib::mage/dropdown_old.js</item> diff --git a/app/etc/di.xml b/app/etc/di.xml index 50088d41f1b4b..335743aef8eed 100644 --- a/app/etc/di.xml +++ b/app/etc/di.xml @@ -47,6 +47,7 @@ <preference for="Magento\Framework\App\RequestSafetyInterface" type="Magento\Framework\App\Request\Http" /> <preference for="\Magento\Framework\Setup\SchemaSetupInterface" type="\Magento\Setup\Module\Setup" /> <preference for="\Magento\Framework\Setup\ModuleDataSetupInterface" type="\Magento\Setup\Module\DataSetup" /> + <preference for="Magento\Framework\App\ExceptionHandlerInterface" type="Magento\Framework\App\ExceptionHandler" /> <type name="Magento\Store\Model\Store"> <arguments> <argument name="currencyInstalled" xsi:type="string">system/currency/installed</argument> @@ -1780,4 +1781,5 @@ <type name="Magento\Framework\DB\Adapter\AdapterInterface"> <plugin name="execute_commit_callbacks" type="Magento\Framework\Model\ExecuteCommitCallbacks" /> </type> + <preference for="Magento\Framework\GraphQl\Query\ErrorHandlerInterface" type="Magento\Framework\GraphQl\Query\ErrorHandler"/> </config> diff --git a/app/etc/graphql/di.xml b/app/etc/graphql/di.xml deleted file mode 100644 index aba60d00080ff..0000000000000 --- a/app/etc/graphql/di.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0"?> -<!-- -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ ---> -<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> - <preference for="Magento\Framework\GraphQl\Query\ErrorHandlerInterface" type="Magento\Framework\GraphQl\Query\ErrorHandler"/> -</config> diff --git a/composer.json b/composer.json index 4a179f480c9b0..fdbfb664c9b1b 100644 --- a/composer.json +++ b/composer.json @@ -88,7 +88,7 @@ "friendsofphp/php-cs-fixer": "~2.14.0", "lusitanian/oauth": "~0.8.10", "magento/magento-coding-standard": "~4.0.0", - "magento/magento2-functional-testing-framework": "2.4.3", + "magento/magento2-functional-testing-framework": "2.5.0", "pdepend/pdepend": "2.5.2", "phpmd/phpmd": "@stable", "phpunit/phpunit": "~6.5.0", @@ -123,6 +123,7 @@ "magento/module-captcha": "*", "magento/module-cardinal-commerce": "*", "magento/module-catalog": "*", + "magento/module-catalog-customer-graph-ql": "*", "magento/module-catalog-analytics": "*", "magento/module-catalog-import-export": "*", "magento/module-catalog-inventory": "*", diff --git a/composer.lock b/composer.lock index cb4f029182219..9d6805ac8be48 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fe4a8dce06cfede9180e774e43149550", + "content-hash": "856f519091be654f930aa51de44332a7", "packages": [ { "name": "braintree/braintree_php", @@ -1552,28 +1552,28 @@ "authors": [ { "name": "Jim Wigginton", - "role": "Lead Developer", - "email": "terrafrost@php.net" + "email": "terrafrost@php.net", + "role": "Lead Developer" }, { "name": "Patrick Monnerat", - "role": "Developer", - "email": "pm@datasphere.ch" + "email": "pm@datasphere.ch", + "role": "Developer" }, { "name": "Andreas Fischer", - "role": "Developer", - "email": "bantu@phpbb.com" + "email": "bantu@phpbb.com", + "role": "Developer" }, { "name": "Hans-Jürgen Petrich", - "role": "Developer", - "email": "petrich@tronic-media.com" + "email": "petrich@tronic-media.com", + "role": "Developer" }, { "name": "Graham Campbell", - "role": "Developer", - "email": "graham@alt-three.com" + "email": "graham@alt-three.com", + "role": "Developer" } ], "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", @@ -5097,6 +5097,99 @@ ], "time": "2019-01-16T14:22:17+00:00" }, + { + "name": "cache/cache", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-cache/cache.git", + "reference": "902b2e5b54ea57e3a801437748652228c4c58604" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-cache/cache/zipball/902b2e5b54ea57e3a801437748652228c4c58604", + "reference": "902b2e5b54ea57e3a801437748652228c4c58604", + "shasum": "" + }, + "require": { + "doctrine/cache": "^1.3", + "league/flysystem": "^1.0", + "php": "^5.6 || ^7.0", + "psr/cache": "^1.0", + "psr/log": "^1.0", + "psr/simple-cache": "^1.0" + }, + "conflict": { + "cache/adapter-common": "*", + "cache/apc-adapter": "*", + "cache/apcu-adapter": "*", + "cache/array-adapter": "*", + "cache/chain-adapter": "*", + "cache/doctrine-adapter": "*", + "cache/filesystem-adapter": "*", + "cache/hierarchical-cache": "*", + "cache/illuminate-adapter": "*", + "cache/memcache-adapter": "*", + "cache/memcached-adapter": "*", + "cache/mongodb-adapter": "*", + "cache/predis-adapter": "*", + "cache/psr-6-doctrine-bridge": "*", + "cache/redis-adapter": "*", + "cache/session-handler": "*", + "cache/taggable-cache": "*", + "cache/void-adapter": "*" + }, + "require-dev": { + "cache/integration-tests": "^0.16", + "defuse/php-encryption": "^2.0", + "illuminate/cache": "^5.4", + "mockery/mockery": "^0.9", + "phpunit/phpunit": "^4.0 || ^5.1", + "predis/predis": "^1.0", + "symfony/cache": "dev-master" + }, + "suggest": { + "ext-apc": "APC extension is required to use the APC Adapter", + "ext-apcu": "APCu extension is required to use the APCu Adapter", + "ext-memcache": "Memcache extension is required to use the Memcache Adapter", + "ext-memcached": "Memcached extension is required to use the Memcached Adapter", + "ext-mongodb": "Mongodb extension required to use the Mongodb adapter", + "ext-redis": "Redis extension is required to use the Redis adapter", + "mongodb/mongodb": "Mongodb lib required to use the Mongodb adapter" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cache\\": "src/" + }, + "exclude-from-classmap": [ + "**/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Scherer", + "email": "aequasi@gmail.com", + "homepage": "https://github.com/aequasi" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "Library of all the php-cache adapters", + "homepage": "http://www.php-cache.com/en/latest/", + "keywords": [ + "cache", + "psr6" + ], + "time": "2017-03-28T16:08:48+00:00" + }, { "name": "codeception/codeception", "version": "2.4.5", @@ -5790,6 +5883,92 @@ "description": "Provides a self:update command for Symfony Console applications.", "time": "2018-10-28T01:52:03+00:00" }, + { + "name": "csharpru/vault-php", + "version": "3.5.3", + "source": { + "type": "git", + "url": "https://github.com/CSharpRU/vault-php.git", + "reference": "04be9776310fe7d1afb97795645f95c21e6b4fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CSharpRU/vault-php/zipball/04be9776310fe7d1afb97795645f95c21e6b4fcf", + "reference": "04be9776310fe7d1afb97795645f95c21e6b4fcf", + "shasum": "" + }, + "require": { + "cache/cache": "^0.4.0", + "doctrine/inflector": "~1.1.0", + "guzzlehttp/promises": "^1.3", + "guzzlehttp/psr7": "^1.4", + "psr/cache": "^1.0", + "psr/log": "^1.0", + "weew/helpers-array": "^1.3" + }, + "require-dev": { + "codacy/coverage": "^1.1", + "codeception/codeception": "^2.2", + "csharpru/vault-php-guzzle6-transport": "~2.0", + "php-vcr/php-vcr": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Vault\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Yaroslav Lukyanov", + "email": "c_sharp@mail.ru" + } + ], + "description": "Best Vault client for PHP that you can find", + "time": "2018-04-28T04:52:17+00:00" + }, + { + "name": "csharpru/vault-php-guzzle6-transport", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/CSharpRU/vault-php-guzzle6-transport.git", + "reference": "33c392120ac9f253b62b034e0e8ffbbdb3513bd8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CSharpRU/vault-php-guzzle6-transport/zipball/33c392120ac9f253b62b034e0e8ffbbdb3513bd8", + "reference": "33c392120ac9f253b62b034e0e8ffbbdb3513bd8", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "~6.2", + "guzzlehttp/promises": "^1.3", + "guzzlehttp/psr7": "^1.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "VaultTransports\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Yaroslav Lukyanov", + "email": "c_sharp@mail.ru" + } + ], + "description": "Guzzle6 transport for Vault PHP client", + "time": "2019-03-10T06:17:37+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v1.1.0", @@ -5917,6 +6096,81 @@ ], "time": "2019-03-25T19:12:02+00:00" }, + { + "name": "doctrine/cache", + "version": "v1.8.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/cache.git", + "reference": "d768d58baee9a4862ca783840eca1b9add7a7f57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/cache/zipball/d768d58baee9a4862ca783840eca1b9add7a7f57", + "reference": "d768d58baee9a4862ca783840eca1b9add7a7f57", + "shasum": "" + }, + "require": { + "php": "~7.1" + }, + "conflict": { + "doctrine/common": ">2.2,<2.4" + }, + "require-dev": { + "alcaeus/mongo-php-adapter": "^1.1", + "doctrine/coding-standard": "^4.0", + "mongodb/mongodb": "^1.1", + "phpunit/phpunit": "^7.0", + "predis/predis": "~1.0" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Caching library offering an object-oriented API for many cache backends", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "cache", + "caching" + ], + "time": "2018-08-21T18:01:43+00:00" + }, { "name": "doctrine/collections", "version": "v1.6.2", @@ -5987,6 +6241,73 @@ ], "time": "2019-06-09T13:48:14+00:00" }, + { + "name": "doctrine/inflector", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "90b2128806bfde671b6952ab8bea493942c1fdae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/90b2128806bfde671b6952ab8bea493942c1fdae", + "reference": "90b2128806bfde671b6952ab8bea493942c1fdae", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "4.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-0": { + "Doctrine\\Common\\Inflector\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Common String Manipulations with regard to casing and singular/plural rules.", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "inflection", + "pluralize", + "singularize", + "string" + ], + "time": "2015-11-06T14:35:42+00:00" + }, { "name": "doctrine/instantiator", "version": "1.2.0", @@ -6722,6 +7043,90 @@ ], "time": "2017-05-10T09:20:27+00:00" }, + { + "name": "league/flysystem", + "version": "1.0.55", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "33c91155537c6dc899eacdc54a13ac6303f156e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/33c91155537c6dc899eacdc54a13ac6303f156e6", + "reference": "33c91155537c6dc899eacdc54a13ac6303f156e6", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": ">=5.5.9" + }, + "conflict": { + "league/flysystem-sftp": "<1.0.6" + }, + "require-dev": { + "phpspec/phpspec": "^3.4", + "phpunit/phpunit": "^5.7.10" + }, + "suggest": { + "ext-fileinfo": "Required for MimeType", + "ext-ftp": "Allows you to use FTP server storage", + "ext-openssl": "Allows you to use FTPS server storage", + "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", + "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", + "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", + "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", + "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", + "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", + "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", + "league/flysystem-webdav": "Allows you to use WebDAV storage", + "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", + "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", + "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Filesystem abstraction: Many filesystems, one API.", + "keywords": [ + "Cloud Files", + "WebDAV", + "abstraction", + "aws", + "cloud", + "copy.com", + "dropbox", + "file systems", + "files", + "filesystem", + "filesystems", + "ftp", + "rackspace", + "remote", + "s3", + "sftp", + "storage" + ], + "time": "2019-08-24T11:17:19+00:00" + }, { "name": "lusitanian/oauth", "version": "v0.8.11", @@ -6821,22 +7226,24 @@ }, { "name": "magento/magento2-functional-testing-framework", - "version": "2.4.3", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/magento/magento2-functional-testing-framework.git", - "reference": "9e9a20fd4c77833ef41ac07eb076a7f2434ce61c" + "reference": "5aa379674def88d1efc180d936dae1e4654c238a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/magento/magento2-functional-testing-framework/zipball/9e9a20fd4c77833ef41ac07eb076a7f2434ce61c", - "reference": "9e9a20fd4c77833ef41ac07eb076a7f2434ce61c", + "url": "https://api.github.com/repos/magento/magento2-functional-testing-framework/zipball/5aa379674def88d1efc180d936dae1e4654c238a", + "reference": "5aa379674def88d1efc180d936dae1e4654c238a", "shasum": "" }, "require": { "allure-framework/allure-codeception": "~1.3.0", "codeception/codeception": "~2.3.4 || ~2.4.0 ", "consolidation/robo": "^1.0.0", + "csharpru/vault-php": "~3.5.3", + "csharpru/vault-php-guzzle6-transport": "^2.0", "ext-curl": "*", "flow/jsonpath": ">0.2", "fzaninotto/faker": "^1.6", @@ -6892,7 +7299,7 @@ "magento", "testing" ], - "time": "2019-08-02T14:26:18+00:00" + "time": "2019-09-18T14:52:11+00:00" }, { "name": "mikey179/vfsstream", @@ -8050,6 +8457,100 @@ "abandoned": true, "time": "2018-08-09T05:50:03+00:00" }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "time": "2016-08-06T20:24:11+00:00" + }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "time": "2017-10-23T01:57:42+00:00" + }, { "name": "sebastian/code-unit-reverse-lookup", "version": "1.0.1", @@ -9698,6 +10199,43 @@ "validate" ], "time": "2018-12-25T11:19:39+00:00" + }, + { + "name": "weew/helpers-array", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/weew/helpers-array.git", + "reference": "9bff63111f9765b4277750db8d276d92b3e16ed0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/weew/helpers-array/zipball/9bff63111f9765b4277750db8d276d92b3e16ed0", + "reference": "9bff63111f9765b4277750db8d276d92b3e16ed0", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^4.7", + "satooshi/php-coveralls": "^0.6.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/array.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maxim Kott", + "email": "maximkott@gmail.com" + } + ], + "description": "Useful collection of php array helpers.", + "time": "2016-07-21T11:18:01+00:00" } ], "aliases": [], diff --git a/dev/tests/acceptance/tests/_data/BB-Products.csv b/dev/tests/acceptance/tests/_data/BB-Products.csv new file mode 100644 index 0000000000000..7ab03fd5eaeda --- /dev/null +++ b/dev/tests/acceptance/tests/_data/BB-Products.csv @@ -0,0 +1,118 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,swatch_image,swatch_image_label,created_at,updated_at,new_from_date,new_to_date,display_product_options_in,map_price,msrp_price,map_enabled,gift_message_available,custom_design,custom_design_from,custom_design_to,custom_layout_update,page_layout,product_options_container,msrp_display_actual_price_type,country_of_manufacture,additional_attributes,qty,out_of_stock_qty,use_config_min_qty,is_qty_decimal,allow_backorders,use_config_backorders,min_cart_qty,use_config_min_sale_qty,max_cart_qty,use_config_max_sale_qty,is_in_stock,notify_on_stock_below,use_config_notify_stock_qty,manage_stock,use_config_manage_stock,use_config_qty_increments,qty_increments,use_config_enable_qty_inc,enable_qty_increments,is_decimal_divided,website_id,related_skus,related_position,crosssell_skus,crosssell_position,upsell_skus,upsell_position,additional_images,additional_image_labels,hide_from_product_page,bundle_price_type,bundle_sku_type,bundle_price_view,bundle_weight_type,bundle_values,bundle_shipment_type,configurable_variations,configurable_variation_labels,associated_skus +BB-D2010129,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Sistemi di Climatizzazione,Default Category/Casa Giardino/Sistemi di Climatizzazione/Aria condizionata e ventilatori",base,"Ventilatore Portatile Spray FunFan Nero","<a id=""maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""><p>Se</a> sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il <strong>ventilatore portatile spray FunFan</strong>. Si tratta di una soluzione pratica per mantenersi al fresco in una moltitudine di situazioni, come escursioni, gite in spiaggia, mentre si fa sport, ecc. Inoltre, grazie alle sue dimensioni ridotte (dimensioni: circa 9 x 26 x 6,5 cm) e peso ridotto (circa 130 g), lo puoi portare ovunque!<br /><br /><a href=""http://www.myfunfan.com""><strong>www.myfunfan.com</strong></a><br /><br />Questo <strong>ventilatore portatile</strong> originale ha un pulsante per attivare le eliche in PVC malleabili e una leva che spruzza l'acqua. Cosa c'è di più, puoi aggiungere il ghiaccio per aumentare la sensazione di freddo! Include 1 cacciavite a croce per inserire le batterie. Realizzato in PVC. Funzionamento a batterie (2 x AA, non incluse).</p><p> Dimenzioni per Ventilatore Portatile Spray FunFan : </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 28 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.185 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888101772</p>","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""> Maggiori Informazioni</a>",0.185,1,"Taxable Goods","Catalog, Search",19.9,,,,Ventilatore-Portatile-Spray-FunFan-Nero,"Ventilatore Portatile Spray FunFan Nero","Casa Giardino,Casa,Giardino,Sistemi di Climatizzazione,Sistemi,Climatizzazione,Aria condizionata e ventilatori,Aria,condizionata,ventilatori,Colore Nero,Nero,","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan",http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,,http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,,,,,,"2016-01-12 09:56:53",,,,,,,,,,,,,,,,,"GTIN=4899888101772",41,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78889.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78888.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78886.jpg","GTIN=4899888101772",,,,,,,,,, +BB-D2010130,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Sistemi di Climatizzazione,Default Category/Casa Giardino/Sistemi di Climatizzazione/Aria condizionata e ventilatori",base,"Ventilatore Portatile Spray FunFan Bianco","<a id=""maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""><p>Se</a> sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il <strong>ventilatore portatile spray FunFan</strong>. Si tratta di una soluzione pratica per mantenersi al fresco in una moltitudine di situazioni, come escursioni, gite in spiaggia, mentre si fa sport, ecc. Inoltre, grazie alle sue dimensioni ridotte (dimensioni: circa 9 x 26 x 6,5 cm) e peso ridotto (circa 130 g), lo puoi portare ovunque!<br /><br /><a href=""http://www.myfunfan.com""><strong>www.myfunfan.com</strong></a><br /><br />Questo <strong>ventilatore portatile</strong> originale ha un pulsante per attivare le eliche in PVC malleabili e una leva che spruzza l'acqua. Cosa c'è di più, puoi aggiungere il ghiaccio per aumentare la sensazione di freddo! Include 1 cacciavite a croce per inserire le batterie. Realizzato in PVC. Funzionamento a batterie (2 x AA, non incluse).</p><p> Dimenzioni per Ventilatore Portatile Spray FunFan : </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 28 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.185 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888107965</p>","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""> Maggiori Informazioni</a>",0.185,1,"Taxable Goods","Catalog, Search",19.9,,,,Ventilatore-Portatile-Spray-FunFan-Bianco,"Ventilatore Portatile Spray FunFan Bianco","Casa Giardino,Casa,Giardino,Sistemi di Climatizzazione,Sistemi,Climatizzazione,Aria condizionata e ventilatori,Aria,condizionata,ventilatori,Colore Bianco,Bianco,","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan",http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,,http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,,,,,,"2016-01-12 09:56:53",,,,,,,,,,,,,,,,,"GTIN=4899888107965",741,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78889.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78888.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78886.jpg","GTIN=4899888107965",,,,,,,,,, +BB-D2010131,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Sistemi di Climatizzazione,Default Category/Casa Giardino/Sistemi di Climatizzazione/Aria condizionata e ventilatori",base,"Ventilatore Portatile Spray FunFan Rosso","<a id=""maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""><p>Se</a> sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il <strong>ventilatore portatile spray FunFan</strong>. Si tratta di una soluzione pratica per mantenersi al fresco in una moltitudine di situazioni, come escursioni, gite in spiaggia, mentre si fa sport, ecc. Inoltre, grazie alle sue dimensioni ridotte (dimensioni: circa 9 x 26 x 6,5 cm) e peso ridotto (circa 130 g), lo puoi portare ovunque!<br /><br /><a href=""http://www.myfunfan.com""><strong>www.myfunfan.com</strong></a><br /><br />Questo <strong>ventilatore portatile</strong> originale ha un pulsante per attivare le eliche in PVC malleabili e una leva che spruzza l'acqua. Cosa c'è di più, puoi aggiungere il ghiaccio per aumentare la sensazione di freddo! Include 1 cacciavite a croce per inserire le batterie. Realizzato in PVC. Funzionamento a batterie (2 x AA, non incluse).</p><p> Dimenzioni per Ventilatore Portatile Spray FunFan : </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 28 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.185 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888107972</p>","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""> Maggiori Informazioni</a>",0.185,1,"Taxable Goods","Catalog, Search",19.9,,,,Ventilatore-Portatile-Spray-FunFan-Rosso,"Ventilatore Portatile Spray FunFan Rosso","Casa Giardino,Casa,Giardino,Sistemi di Climatizzazione,Sistemi,Climatizzazione,Aria condizionata e ventilatori,Aria,condizionata,ventilatori,Colore Rosso,Rosso,","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan",http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,,http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,,,,,,"2016-01-12 09:56:53",,,,,,,,,,,,,,,,,"GTIN=4899888107972",570,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78889.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78888.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78886.jpg","GTIN=4899888107972",,,,,,,,,, +BB-H1000163,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Sedia Pieghevole Campart Travel CH0592 Blu Marino","<a id=""maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""><p>Se</a> stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la <strong>sedia pieghevole </strong><strong>Campart Travel</strong>! Questa <strong>sedia da campeggio imbottita</strong> è perfetta per i luoghi di campeggio, cortili, giardini, ecc. Ideale per il riposo e il relax. Può portare fino a 120 kg. Dimensioni: 66 x 70 / 120 x 87 / 115 cm circa. Semplice da trasportare ovunque, grazie al suo design funzionale ed elegante (dimensioni quando piegato: circa 66 x 110 x 10 cm). 7 posizioni regolabili e un poggiatesta incorporato. Struttura in alluminio e stoffa imbottita in poliestere. Altezza sedia: circa 50 cm.</p><p> Dimenzioni per Sedia Pieghevole Campart Travel: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 66 Cm</li><li>Profondita': 110 Cm</li><li>Peso: 5.3 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005922</p>","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc.</br><a href=""#maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""> Maggiori Informazioni</a>",5.3,1,"Taxable Goods","Catalog, Search",129,,,,Sedia-Pieghevole-Campart-Travel-CH0592 Blu Marino,"Sedia Pieghevole Campart Travel CH0592 Blu Marino","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0592 Blu Marino,CH0592 Blu Marino,","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc",http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_00.jpg,,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_00.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005922",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_02.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_04.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_03.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_01.jpg","GTIN=8713016005922",,,,,,,,,, +BB-H1000162,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Sedia Pieghevole Campart Travel CH0596 Grigio","<a id=""maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""><p>Se</a> stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la <strong>sedia pieghevole </strong><strong>Campart Travel</strong>! Questa <strong>sedia da campeggio imbottita</strong> è perfetta per i luoghi di campeggio, cortili, giardini, ecc. Ideale per il riposo e il relax. Può portare fino a 120 kg. Dimensioni: 66 x 70 / 120 x 87 / 115 cm circa. Semplice da trasportare ovunque, grazie al suo design funzionale ed elegante (dimensioni quando piegato: circa 66 x 110 x 10 cm). 7 posizioni regolabili e un poggiatesta incorporato. Struttura in alluminio e stoffa imbottita in poliestere. Altezza sedia: circa 50 cm.</p><p> Dimenzioni per Sedia Pieghevole Campart Travel: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 66 Cm</li><li>Profondita': 110 Cm</li><li>Peso: 5.3 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005960</p>","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc.</br><a href=""#maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""> Maggiori Informazioni</a>",5.3,1,"Taxable Goods","Catalog, Search",129,,,,Sedia-Pieghevole-Campart-Travel-CH0596 Grigio,"Sedia Pieghevole Campart Travel CH0596 Grigio","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0596 Grigio,CH0596 Grigio,","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc",http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_02.jpg,,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_02.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005960",2,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_00.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_04.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_03.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_01.jpg","GTIN=8713016005960",,,,,,,,,, +BB-F1520329,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Poggiapiedi Pieghevole Campart Travel CH0593 Blu Marino","<a id=""maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""><p>Approfitta</a> di un'esperienza rilassante con l'aiuto del <strong>poggiapiedi pieghevole Campart Travel</strong>! Questo<strong> poggiapiedi imbottito</strong> è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc. Possiede 2 ganci di circa 3 cm di diametro che possono essere facilmente attaccate alla barra inferiore delle sedie (utilizzabile solo per sedie con una barra inferiore di circa 2 cm di diametro). Struttura in alluminio. Tessuto: poliestere. Dimensioni: circa 51 x 47 x 96 cm (dimensioni quando ripiegato: circa 51 x 12 x 96 cm). Ideale per le sedie pieghevoli Campart Travel CH0592 e CH0596.</p><p> Dimenzioni per Poggiapiedi Pieghevole Campart Travel: </br><ul><li>Altezza: 13 Cm</li><li>Larghezza: 53 Cm</li><li>Profondita': 97 Cm</li><li>Peso: 1.377 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005939</p>","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc.</br><a href=""#maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""> Maggiori Informazioni</a>",1.377,1,"Taxable Goods","Catalog, Search",46.6,,,,Poggiapiedi-Pieghevole-Campart-Travel-CH0593 Blu Marino,"Poggiapiedi Pieghevole Campart Travel CH0593 Blu Marino","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0593 Blu Marino,CH0593 Blu Marino,","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc",http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_00.jpg,,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_00.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005939",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_01.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_02.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_08.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_07.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_06.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_05.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_04.jpg","GTIN=8713016005939",,,,,,,,,, +BB-F1520328,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Poggiapiedi Pieghevole Campart Travel CH0597 Grigio","<a id=""maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""><p>Approfitta</a> di un'esperienza rilassante con l'aiuto del <strong>poggiapiedi pieghevole Campart Travel</strong>! Questo<strong> poggiapiedi imbottito</strong> è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc. Possiede 2 ganci di circa 3 cm di diametro che possono essere facilmente attaccate alla barra inferiore delle sedie (utilizzabile solo per sedie con una barra inferiore di circa 2 cm di diametro). Struttura in alluminio. Tessuto: poliestere. Dimensioni: circa 51 x 47 x 96 cm (dimensioni quando ripiegato: circa 51 x 12 x 96 cm). Ideale per le sedie pieghevoli Campart Travel CH0592 e CH0596.</p><p> Dimenzioni per Poggiapiedi Pieghevole Campart Travel: </br><ul><li>Altezza: 13 Cm</li><li>Larghezza: 53 Cm</li><li>Profondita': 97 Cm</li><li>Peso: 1.377 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005977</p>","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc.</br><a href=""#maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""> Maggiori Informazioni</a>",1.377,1,"Taxable Goods","Catalog, Search",46.6,,,,Poggiapiedi-Pieghevole-Campart-Travel-CH0597 Grigio,"Poggiapiedi Pieghevole Campart Travel CH0597 Grigio","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0597 Grigio,CH0597 Grigio,","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc",http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_01.jpg,,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_01.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005977",7,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_00.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_02.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_08.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_07.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_06.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_05.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_04.jpg","GTIN=8713016005977",,,,,,,,,, +BB-H4502058,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Orologi da parete e da tavolo",base,"Orologio da Parete Star Wars","<a id=""maggiorni_informazioni"" title=""Orologio da Parete Star Wars""><p>I</a> fan di Star Wars non potranno fare a meno di appendere l'<strong>orologio da parete Star Wars</strong> in casa loro! Realizzato in plastica. Funziona a batterie (1 x AA, non incluse). Diametro circa: 25,5 cm. Spessore circa: 3,5 cm.</p><p> Dimenzioni per Orologio da Parete Star Wars: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 26 Cm</li><li>Profondita': 3.8 Cm</li><li>Peso: 0.287 Kg</li></ul></p><p>Codice Prodotto (EAN): 6950687214204</p>","I fan di Star Wars non potranno fare a meno di appendere l'orologio da parete Star Wars in casa loro! Realizzato in plastica.</br><a href=""#maggiorni_informazioni"" title=""Orologio da Parete Star Wars""> Maggiori Informazioni</a>",0.287,1,"Taxable Goods","Catalog, Search",22.5,,,,Orologio-da-Parete-Star-Wars,"Orologio da Parete Star Wars","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Orologi da parete e da tavolo,Orologi,parete,tavolo,","I fan di Star Wars non potranno fare a meno di appendere l'orologio da parete Star Wars in casa loro! Realizzato in plastica",http://dropshipping.bigbuy.eu/imgs/H4502058_84712.jpg,,http://dropshipping.bigbuy.eu/imgs/H4502058_84712.jpg,,,,,,"2016-08-08 21:09:24",,,,,,,,,,,,,,,,,"GTIN=6950687214204",130,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/H4502058_84713.jpg,http://dropshipping.bigbuy.eu/imgs/H4502058_84711.jpg,http://dropshipping.bigbuy.eu/imgs/H4502058_84710.jpg","GTIN=6950687214204",,,,,,,,,, +BB-G0500195,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Illuminazione LED",base,"Braccialetto Sportivo a LED MegaLed Rosso","<a id=""maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""><p>Se</a> ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed. Con questo<strong> braccialetto di sicurezza</strong> sarai visibile ai motorini e alle auto nell'oscurità, così da poter stare molto più sicuro e tranquillo. È dotato di 2 luci a LED con 2 possibili soluzioni (luce fissa ed intermittente). La lunghezza massima è di circa 55 cm e quella minima è di circa 42 cm. Molto leggero (circa 50 g). Autonomia circa: 24-40 ore. Funziona a batterie (2 x CR2023, incluse).</p><p> </p><p> Dimenzioni per Braccialetto Sportivo a LED MegaLed: </br><ul><li>Altezza: 3 Cm</li><li>Larghezza: 20 Cm</li><li>Profondita': 4 Cm</li><li>Peso: 0.049 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436545443507</p>","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed.</br><a href=""#maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""> Maggiori Informazioni</a>",0.049,1,"Taxable Goods","Catalog, Search",22,,,,Braccialetto-Sportivo-a-LED-MegaLed-Rosso,"Braccialetto Sportivo a LED MegaLed Rosso","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Illuminazione LED,Illuminazione,LED,Colore Rosso,Rosso,","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed",http://dropshipping.bigbuy.eu/imgs/G0500194_87774.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500194_87774.jpg,,,,,,"2016-01-08 12:34:41",,,,,,,,,,,,,,,,,"GTIN=8436545443507",22,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500194_87775.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87773.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87772.jpg","GTIN=8436545443507",,,,,,,,,, +BB-G0500196,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Illuminazione LED",base,"Braccialetto Sportivo a LED MegaLed Verde","<a id=""maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""><p>Se</a> ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed. Con questo<strong> braccialetto di sicurezza</strong> sarai visibile ai motorini e alle auto nell'oscurità, così da poter stare molto più sicuro e tranquillo. È dotato di 2 luci a LED con 2 possibili soluzioni (luce fissa ed intermittente). La lunghezza massima è di circa 55 cm e quella minima è di circa 42 cm. Molto leggero (circa 50 g). Autonomia circa: 24-40 ore. Funziona a batterie (2 x CR2023, incluse).</p><p> </p><p> Dimenzioni per Braccialetto Sportivo a LED MegaLed: </br><ul><li>Altezza: 3 Cm</li><li>Larghezza: 20 Cm</li><li>Profondita': 4 Cm</li><li>Peso: 0.049 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436545443507</p>","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed.</br><a href=""#maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""> Maggiori Informazioni</a>",0.049,1,"Taxable Goods","Catalog, Search",22,,,,Braccialetto-Sportivo-a-LED-MegaLed-Verde,"Braccialetto Sportivo a LED MegaLed Verde","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Illuminazione LED,Illuminazione,LED,Colore Verde,Verde,","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed",http://dropshipping.bigbuy.eu/imgs/G0500194_87775.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500194_87775.jpg,,,,,,"2016-01-08 12:34:41",,,,,,,,,,,,,,,,,"GTIN=8436545443507",37,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500194_87774.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87773.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87772.jpg","GTIN=8436545443507",,,,,,,,,, +BB-I2500333,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Orologi da parete e da tavolo",base,"Orologio da Parete Mom's Diner","<a id=""maggiorni_informazioni"" title=""Orologio da Parete Mom's Diner""><p>Decora</a> la tua cucina con l'originale <strong>orologio da parete</strong> <strong>Mom's Diner</strong> in stile vintage! È realizzato in legno. Diametro: 58 cm circa. Spessore: 0,8 cm circa. Funziona a pile (1 x AA, non inclusa).</p><p> Dimenzioni per Orologio da Parete Mom's Diner: </br><ul><li>Altezza: 59 Cm</li><li>Larghezza: 59 Cm</li><li>Profondita': 6 Cm</li><li>Peso: 2.1 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811345052</p>","Decora la tua cucina con l'originale orologio da parete Mom's Diner in stile vintage! È realizzato in legno.</br><a href=""#maggiorni_informazioni"" title=""Orologio da Parete Mom's Diner""> Maggiori Informazioni</a>",2.1,1,"Taxable Goods","Catalog, Search",42.5,,,,Orologio-da-Parete-Mom's-Diner,"Orologio da Parete Mom's Diner","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Orologi da parete e da tavolo,Orologi,parete,tavolo,","Decora la tua cucina con l'originale orologio da parete Mom's Diner in stile vintage! È realizzato in legno",http://dropshipping.bigbuy.eu/imgs/I2500333_88061.jpg,,http://dropshipping.bigbuy.eu/imgs/I2500333_88061.jpg,,,,,,"2016-07-21 13:05:12",,,,,,,,,,,,,,,,,"GTIN=4029811345052",2,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I2500333_88060.jpg,http://dropshipping.bigbuy.eu/imgs/I2500333_88059.jpg,http://dropshipping.bigbuy.eu/imgs/I2500333_88058.jpg","GTIN=4029811345052",,,,,,,,,, +BB-I2500334,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Orologi da parete e da tavolo",base,"Orologio da Parete Coffee Endless Cup","<a id=""maggiorni_informazioni"" title=""Orologio da Parete Coffee Endless Cup""><p>Se</a> sei un appassionato di caffè, non puoi rimanere senza l'<strong>orologio da parete Coffee Endless Cup</strong>! Un orologio vintage in legno con un design in perfetto stile caffettoso! Diametro: 58 cm circa. Spessore: 0,8 cm circa. Funziona a pile (1 x AA, non inclusa).</p><p> Dimenzioni per Orologio da Parete Coffee Endless Cup: </br><ul><li>Altezza: 59 Cm</li><li>Larghezza: 59 Cm</li><li>Profondita': 6 Cm</li><li>Peso: 2.1 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811345069</p>","Se sei un appassionato di caffè, non puoi rimanere senza l'orologio da parete Coffee Endless Cup! Un orologio vintage in legno con un design in perfetto stile caffettoso! Diametro: 58 cm circa.</br><a href=""#maggiorni_informazioni"" title=""Orologio da Parete Coffee Endless Cup""> Maggiori Informazioni</a>",2.1,1,"Taxable Goods","Catalog, Search",42.5,,,,Orologio-da-Parete-Coffee-Endless-Cup,"Orologio da Parete Coffee Endless Cup","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Orologi da parete e da tavolo,Orologi,parete,tavolo,","Se sei un appassionato di caffè, non puoi rimanere senza l'orologio da parete Coffee Endless Cup! Un orologio vintage in legno con un design in perfetto stile caffettoso! Diametro: 58 cm circa",http://dropshipping.bigbuy.eu/imgs/I2500334_88065.jpg,,http://dropshipping.bigbuy.eu/imgs/I2500334_88065.jpg,,,,,,"2016-08-30 13:41:52",,,,,,,,,,,,,,,,,"GTIN=4029811345069",4,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I2500334_88064.jpg,http://dropshipping.bigbuy.eu/imgs/I2500334_88063.jpg,http://dropshipping.bigbuy.eu/imgs/I2500334_88062.jpg","GTIN=4029811345069",,,,,,,,,, +BB-V0000252,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Insegna Dito Vintage Look Stop!","<a id=""maggiorni_informazioni"" title=""Insegna Dito Vintage Look""><p>Se</a> sei alla ricerca di una <strong>decorazione vintage</strong> originale e divertente per la tua casa, l'<strong>insegna dito Vintage Look</strong> ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno. Dimensioni: 69 x 17 x 1 cm circa.</p><p> Dimenzioni per Insegna Dito Vintage Look: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 69 Cm</li><li>Profondita': 1 Cm</li><li>Peso: 0.63 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346196</p>","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno.</br><a href=""#maggiorni_informazioni"" title=""Insegna Dito Vintage Look""> Maggiori Informazioni</a>",0.63,1,"Taxable Goods","Catalog, Search",15.99,,,,Insegna-Dito-Vintage-Look-Stop!,"Insegna Dito Vintage Look Stop!","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Stop!,Stop!,","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno",http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,,,,,,"2016-02-29 09:49:10",,,,,,,,,,,,,,,,,"GTIN=4029811346196",21,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89743.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89742.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89741.jpg","GTIN=4029811346196",,,,,,,,,, +BB-V0000253,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Insegna Dito Vintage Look Adults Only","<a id=""maggiorni_informazioni"" title=""Insegna Dito Vintage Look""><p>Se</a> sei alla ricerca di una <strong>decorazione vintage</strong> originale e divertente per la tua casa, l'<strong>insegna dito Vintage Look</strong> ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno. Dimensioni: 69 x 17 x 1 cm circa.</p><p> Dimenzioni per Insegna Dito Vintage Look: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 69 Cm</li><li>Profondita': 1 Cm</li><li>Peso: 0.63 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346202</p>","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno.</br><a href=""#maggiorni_informazioni"" title=""Insegna Dito Vintage Look""> Maggiori Informazioni</a>",0.63,1,"Taxable Goods","Catalog, Search",15.99,,,,Insegna-Dito-Vintage-Look-Adults Only,"Insegna Dito Vintage Look Adults Only","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Adults Only,Adults Only,","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno",http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,,,,,,"2016-02-29 09:49:10",,,,,,,,,,,,,,,,,"GTIN=4029811346202",18,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89743.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89742.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89741.jpg","GTIN=4029811346202",,,,,,,,,, +BB-V0000254,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Insegna Dito Vintage Look Talk","<a id=""maggiorni_informazioni"" title=""Insegna Dito Vintage Look""><p>Se</a> sei alla ricerca di una <strong>decorazione vintage</strong> originale e divertente per la tua casa, l'<strong>insegna dito Vintage Look</strong> ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno. Dimensioni: 69 x 17 x 1 cm circa.</p><p> Dimenzioni per Insegna Dito Vintage Look: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 69 Cm</li><li>Profondita': 1 Cm</li><li>Peso: 0.63 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346318</p>","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno.</br><a href=""#maggiorni_informazioni"" title=""Insegna Dito Vintage Look""> Maggiori Informazioni</a>",0.63,1,"Taxable Goods","Catalog, Search",15.99,,,,Insegna-Dito-Vintage-Look-Talk,"Insegna Dito Vintage Look Talk","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Talk,Talk,","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno",http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,,,,,,"2016-02-29 09:49:10",,,,,,,,,,,,,,,,,"GTIN=4029811346318",8,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89743.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89742.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89741.jpg","GTIN=4029811346318",,,,,,,,,, +BB-V0000256,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Freccia Decorativa Vintage Look Go Left","<a id=""maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""><p>Stupisci</a> tutti con la divertente ed originale <strong>freccia decorativa Vintage Look</strong>! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno. Misure appross.: 25 x 40 x 1 cm.</p><p> Dimenzioni per Freccia Decorativa Vintage Look: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 0.8 Cm</li><li>Profondita': 40 Cm</li><li>Peso: 0.376 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346325</p>","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno.</br><a href=""#maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""> Maggiori Informazioni</a>",0.376,1,"Taxable Goods","Catalog, Search",13.9,,,,Freccia-Decorativa-Vintage-Look-Go Left,"Freccia Decorativa Vintage Look Go Left","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Go Left,Go Left,","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno",http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,,,,,,"2016-02-29 10:39:59",,,,,,,,,,,,,,,,,"GTIN=4029811346325",4,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89759.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89758.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89757.jpg","GTIN=4029811346325",,,,,,,,,, +BB-V0000257,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Freccia Decorativa Vintage Look Exit","<a id=""maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""><p>Stupisci</a> tutti con la divertente ed originale <strong>freccia decorativa Vintage Look</strong>! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno. Misure appross.: 25 x 40 x 1 cm.</p><p> Dimenzioni per Freccia Decorativa Vintage Look: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 0.8 Cm</li><li>Profondita': 40 Cm</li><li>Peso: 0.376 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346332</p>","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno.</br><a href=""#maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""> Maggiori Informazioni</a>",0.376,1,"Taxable Goods","Catalog, Search",13.9,,,,Freccia-Decorativa-Vintage-Look-Exit,"Freccia Decorativa Vintage Look Exit","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Exit,Exit,","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno",http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,,,,,,"2016-02-29 10:39:59",,,,,,,,,,,,,,,,,"GTIN=4029811346332",19,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89759.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89758.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89757.jpg","GTIN=4029811346332",,,,,,,,,, +BB-V0000258,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Freccia Decorativa Vintage Look Cold beer here","<a id=""maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""><p>Stupisci</a> tutti con la divertente ed originale <strong>freccia decorativa Vintage Look</strong>! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno. Misure appross.: 25 x 40 x 1 cm.</p><p> Dimenzioni per Freccia Decorativa Vintage Look: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 0.8 Cm</li><li>Profondita': 40 Cm</li><li>Peso: 0.376 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346349</p>","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno.</br><a href=""#maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""> Maggiori Informazioni</a>",0.376,1,"Taxable Goods","Catalog, Search",13.9,,,,Freccia-Decorativa-Vintage-Look-Cold beer here,"Freccia Decorativa Vintage Look Cold beer here","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Cold beer here,Cold beer here,","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno",http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,,,,,,"2016-02-29 10:39:59",,,,,,,,,,,,,,,,,"GTIN=4029811346349",20,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89759.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89758.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89757.jpg","GTIN=4029811346349",,,,,,,,,, +BB-V0200190,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Centrotavola e Vasi",base,"Ciotola in Bambù TakeTokio Bianco","<a id=""maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""><p>Arricchisci</a> la selezione dei tuoi <strong>utensili da cucina</strong> con la <strong>ciotola in bambù</strong> <strong>TakeTokio</strong>, una <strong>ciotola da cucina</strong> funzionale e dal design moderno è perfetta come <strong>insalatiera</strong>, portafrutta, ecc. Realizzata in pregiato legno di bambù. Dimensioni (diametro x altezza): 25 x 15 cm circa. Diametro della base: 9 cm circa.</p><p><a href=""http://www.taketokio.com/"" target=""_blank""><strong>www.taketokio.com</strong></a></p><p> Dimenzioni per Ciotola in Bambù TakeTokio: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 15 Cm</li><li>Peso: 0.39 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158904577</p>","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc.</br><a href=""#maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""> Maggiori Informazioni</a>",0.39,1,"Taxable Goods","Catalog, Search",19.8,,,,Ciotola-in-Bambù-TakeTokio-Bianco,"Ciotola in Bambù TakeTokio Bianco","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Centrotavola e Vasi,Centrotavola,Vasi,Colore Bianco,Bianco,","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc",http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8718158904577",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90744.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90743.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90742.jpg","GTIN=8718158904577",,,,,,,,,, +BB-V0200192,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Centrotavola e Vasi",base,"Ciotola in Bambù TakeTokio Grigio","<a id=""maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""><p>Arricchisci</a> la selezione dei tuoi <strong>utensili da cucina</strong> con la <strong>ciotola in bambù</strong> <strong>TakeTokio</strong>, una <strong>ciotola da cucina</strong> funzionale e dal design moderno è perfetta come <strong>insalatiera</strong>, portafrutta, ecc. Realizzata in pregiato legno di bambù. Dimensioni (diametro x altezza): 25 x 15 cm circa. Diametro della base: 9 cm circa.</p><p><a href=""http://www.taketokio.com/"" target=""_blank""><strong>www.taketokio.com</strong></a></p><p> Dimenzioni per Ciotola in Bambù TakeTokio: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 15 Cm</li><li>Peso: 0.39 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158904577</p>","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc.</br><a href=""#maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""> Maggiori Informazioni</a>",0.39,1,"Taxable Goods","Catalog, Search",19.8,,,,Ciotola-in-Bambù-TakeTokio-Grigio,"Ciotola in Bambù TakeTokio Grigio","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Centrotavola e Vasi,Centrotavola,Vasi,Colore Grigio,Grigio,","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc",http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8718158904577",26,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90744.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90743.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90742.jpg","GTIN=8718158904577",,,,,,,,,, +BB-V0200191,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Centrotavola e Vasi",base,"Ciotola in Bambù TakeTokio Nero","<a id=""maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""><p>Arricchisci</a> la selezione dei tuoi <strong>utensili da cucina</strong> con la <strong>ciotola in bambù</strong> <strong>TakeTokio</strong>, una <strong>ciotola da cucina</strong> funzionale e dal design moderno è perfetta come <strong>insalatiera</strong>, portafrutta, ecc. Realizzata in pregiato legno di bambù. Dimensioni (diametro x altezza): 25 x 15 cm circa. Diametro della base: 9 cm circa.</p><p><a href=""http://www.taketokio.com/"" target=""_blank""><strong>www.taketokio.com</strong></a></p><p> Dimenzioni per Ciotola in Bambù TakeTokio: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 15 Cm</li><li>Peso: 0.39 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158904577</p>","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc.</br><a href=""#maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""> Maggiori Informazioni</a>",0.39,1,"Taxable Goods","Catalog, Search",19.8,,,,Ciotola-in-Bambù-TakeTokio-Nero,"Ciotola in Bambù TakeTokio Nero","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Centrotavola e Vasi,Centrotavola,Vasi,Colore Nero,Nero,","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc",http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8718158904577",22,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90744.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90743.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90742.jpg","GTIN=8718158904577",,,,,,,,,, +BB-V0200360,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Scatola porta Tè Flower Vintage Coconut Rosa","<a id=""maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""><p>Gli</a> amanti della moda vintage non potranno resistere di fronte all'adorabile <strong>scatola porta tè Flower Vintage Coconut</strong>! Una <strong>scatola vintage</strong> in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi. Dispone di un coperchio in cristallo e vari scompartimenti. Dimensioni: circa 23 x 7 x 23 cm.</p><p><a href=""http://www.vintagecoconut.com"" target=""_blank""><strong>www.vintagecoconut.com</strong></a></p><p> Dimenzioni per Scatola porta Tè Flower Vintage Coconut: </br><ul><li>Altezza: 23 Cm</li><li>Larghezza: 7.1 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.795 Kg</li></ul></p><p>Codice Prodotto (EAN): 8711295889547</p>","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi.</br><a href=""#maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""> Maggiori Informazioni</a>",0.795,1,"Taxable Goods","Catalog, Search",25.9,,,,Scatola-porta-Tè-Flower-Vintage-Coconut-Rosa,"Scatola porta Tè Flower Vintage Coconut Rosa","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,Colore Rosa,Rosa,","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi",http://dropshipping.bigbuy.eu/imgs/V0200328_92649.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200328_92649.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8711295889547",13,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200328_92648.jpg,http://dropshipping.bigbuy.eu/imgs/V0200328_92647.jpg","GTIN=8711295889547",,,,,,,,,, +BB-V0200361,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Scatola porta Tè Flower Vintage Coconut Azzurro","<a id=""maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""><p>Gli</a> amanti della moda vintage non potranno resistere di fronte all'adorabile <strong>scatola porta tè Flower Vintage Coconut</strong>! Una <strong>scatola vintage</strong> in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi. Dispone di un coperchio in cristallo e vari scompartimenti. Dimensioni: circa 23 x 7 x 23 cm.</p><p><a href=""http://www.vintagecoconut.com"" target=""_blank""><strong>www.vintagecoconut.com</strong></a></p><p> Dimenzioni per Scatola porta Tè Flower Vintage Coconut: </br><ul><li>Altezza: 23 Cm</li><li>Larghezza: 7.1 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.795 Kg</li></ul></p><p>Codice Prodotto (EAN): 8711295889547</p>","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi.</br><a href=""#maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""> Maggiori Informazioni</a>",0.795,1,"Taxable Goods","Catalog, Search",25.9,,,,Scatola-porta-Tè-Flower-Vintage-Coconut-Azzurro,"Scatola porta Tè Flower Vintage Coconut Azzurro","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,Colore Azzurro,Azzurro,","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi",http://dropshipping.bigbuy.eu/imgs/V0200328_92648.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200328_92648.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8711295889547",21,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200328_92649.jpg,http://dropshipping.bigbuy.eu/imgs/V0200328_92647.jpg","GTIN=8711295889547",,,,,,,,,, +BB-V0200353,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Barbecue",base,"Ventilatore a Pistola classico per Barbecue BBQ Classics","<a id=""maggiorni_informazioni"" title=""Ventilatore a Pistola classico per Barbecue BBQ Classics""><p>Utilizza</a> i migliori barbecue alimentando il fuoco con il <strong>ventilatore a pistola classico per babecue BBQ Classics</strong>! Basterà solo fare una leggera pressione sul pulsante per far uscire l'aria.</p><p><a href=""http://www.bbqclassics.com"" target=""_blank""><strong>www.bbqclassics.com</strong></a></p><ul><li>Realizzato in plastica e metallo</li><li>Dimensioni: 25 x 18 x 4 cm circa</li></ul><p> Dimenzioni per Ventilatore a Pistola classico per Barbecue BBQ Classics: </br><ul><li>Altezza: 5.5 Cm</li><li>Larghezza: 11 Cm</li><li>Profondita': 22 Cm</li><li>Peso: 0.167 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158032706</p>","Utilizza i migliori barbecue alimentando il fuoco con il ventilatore a pistola classico per babecue BBQ Classics! Basterà solo fare una leggera pressione sul pulsante per far uscire l'aria.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore a Pistola classico per Barbecue BBQ Classics""> Maggiori Informazioni</a>",0.167,1,"Taxable Goods","Catalog, Search",9.3,,,,Ventilatore-a-Pistola-classico-per-Barbecue-BBQ-Classics,"Ventilatore a Pistola classico per Barbecue BBQ Classics","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Barbecue,","Utilizza i migliori barbecue alimentando il fuoco con il ventilatore a pistola classico per babecue BBQ Classics! Basterà solo fare una leggera pressione sul pulsante per far uscire l'aria",http://dropshipping.bigbuy.eu/imgs/V0200353_92695.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200353_92695.jpg,,,,,,"2016-09-13 09:56:07",,,,,,,,,,,,,,,,,"GTIN=8718158032706",60,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200353_92696.jpg,http://dropshipping.bigbuy.eu/imgs/V0200353_92694.jpg","GTIN=8718158032706",,,,,,,,,, +BB-V1600123,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Frozen (32 x 23 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (32 x 23 cm)""><p>Non</a> c'è nulla di meglio che tenere le camere dei più piccini in ordine in modo originale e divertente. Con il <strong>contenitore portagiochi Frozen (32 x 23 cm)</strong> sarà semplicissimo!</p><ul><li>Realizzato in polipropilene</li><li>Dimensioni aprossimative: 32 x 15 x 23 cm</li><li>Età consigliata: +3 anni</li></ul><p> </p><p> Dimenzioni per Contenitore Portagiochi Frozen (32 x 23 cm): </br><ul><li>Altezza: 15 Cm</li><li>Larghezza: 34 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.331 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766006</p>","Non c'è nulla di meglio che tenere le camere dei più piccini in ordine in modo originale e divertente.</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (32 x 23 cm)""> Maggiori Informazioni</a>",0.331,1,"Taxable Goods","Catalog, Search",21.9,,,,Contenitore-Portagiochi-Frozen-(32-x-23-cm),"Contenitore Portagiochi Frozen (32 x 23 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","Non c'è nulla di meglio che tenere le camere dei più piccini in ordine in modo originale e divertente",http://dropshipping.bigbuy.eu/imgs/V1600123_93002.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600123_93002.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8412842766006",48,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600123_93005.jpg,http://dropshipping.bigbuy.eu/imgs/V1600123_93004.jpg,http://dropshipping.bigbuy.eu/imgs/V1600123_93003.jpg","GTIN=8412842766006",,,,,,,,,, +BB-V1600124,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Spiderman (32 x 23 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (32 x 23 cm)""><p>Desideri</a> sorprendere i più piccini con un regalo molto originale? Il <strong>contenitore portagiochi Spiderman (32 x 23 cm)</strong> decorerà e metterà in ordine le loro camerette.</p><ul><li>Realizzato in polipropilene</li><li>Dimensioni approssimative: 32 x 15 x 23 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Contenitore Portagiochi Spiderman (32 x 23 cm): </br><ul><li>Altezza: 15 Cm</li><li>Larghezza: 34 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.331 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766037</p>","Desideri sorprendere i più piccini con un regalo molto originale? Il contenitore portagiochi Spiderman (32 x 23 cm) decorerà e metterà in ordine le loro camerette.</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (32 x 23 cm)""> Maggiori Informazioni</a>",0.331,1,"Taxable Goods","Catalog, Search",21.9,,,,Contenitore-Portagiochi-Spiderman--(32-x-23-cm),"Contenitore Portagiochi Spiderman (32 x 23 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","Desideri sorprendere i più piccini con un regalo molto originale? Il contenitore portagiochi Spiderman (32 x 23 cm) decorerà e metterà in ordine le loro camerette",http://dropshipping.bigbuy.eu/imgs/V1600124_93006.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600124_93006.jpg,,,,,,"2016-08-08 21:09:24",,,,,,,,,,,,,,,,,"GTIN=8412842766037",52,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600124_93008.jpg,http://dropshipping.bigbuy.eu/imgs/V1600124_93007.jpg","GTIN=8412842766037",,,,,,,,,, +BB-V1600125,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Frozen (45 x 32 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (45 x 32 cm)""><p>Insegna</a> ai tuoi bambini a tenere i giocattoli conservati ben in ordine con l'aiuto del <strong>contenitore portagiochi Frozen (45 x 32 cm)</strong>. Il <strong>portagiocattoli</strong> che tutte le bambine sognano!</p><ul><li>Realizzato in polipropilene</li><li>Dimensioni: circa 45 x 22 x 32 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Contenitore Portagiochi Frozen (45 x 32 cm): </br><ul><li>Altezza: 22 Cm</li><li>Larghezza: 37 Cm</li><li>Profondita': 45 Cm</li><li>Peso: 0.775 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766129</p>","Insegna ai tuoi bambini a tenere i giocattoli conservati ben in ordine con l'aiuto del contenitore portagiochi Frozen (45 x 32 cm).</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (45 x 32 cm)""> Maggiori Informazioni</a>",0.775,1,"Taxable Goods","Catalog, Search",39.6,,,,Contenitore-Portagiochi-Frozen-(45-x-32-cm),"Contenitore Portagiochi Frozen (45 x 32 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","Insegna ai tuoi bambini a tenere i giocattoli conservati ben in ordine con l'aiuto del contenitore portagiochi Frozen (45 x 32 cm)",http://dropshipping.bigbuy.eu/imgs/V1600125_93010.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600125_93010.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8412842766129",17,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600125_93011.jpg,http://dropshipping.bigbuy.eu/imgs/V1600125_93009.jpg","GTIN=8412842766129",,,,,,,,,, +BB-V1600126,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Spiderman (45 x 32 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (45 x 32 cm)""><p>I</a> piccoli di casa ora possono ordinare e riporre i loro giocattoli facilmente grazie al <strong>c<strong>ontenitore <strong>portagiochi</strong></strong> Spiderman</strong><strong> (45 x 32 cm)</strong>. Il <strong>c<strong>ontenitore <strong>portagiochi</strong></strong> </strong>preferito dai bambini!</p><ul><li>Fabbricato in polipropilene</li><li>Dimensioni: circa 45 x 22 x 32 cm</li><li>Età raccomandata: +3 anni</li></ul><p> Dimenzioni per Contenitore Portagiochi Spiderman (45 x 32 cm): </br><ul><li>Altezza: 22 Cm</li><li>Larghezza: 37 Cm</li><li>Profondita': 45 Cm</li><li>Peso: 0.775 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766150</p>","I piccoli di casa ora possono ordinare e riporre i loro giocattoli facilmente grazie al contenitore portagiochi Spiderman (45 x 32 cm).</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (45 x 32 cm)""> Maggiori Informazioni</a>",0.775,1,"Taxable Goods","Catalog, Search",39.6,,,,Contenitore-Portagiochi-Spiderman-(45-x-32-cm),"Contenitore Portagiochi Spiderman (45 x 32 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","I piccoli di casa ora possono ordinare e riporre i loro giocattoli facilmente grazie al contenitore portagiochi Spiderman (45 x 32 cm)",http://dropshipping.bigbuy.eu/imgs/V1600126_93012.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600126_93012.jpg,,,,,,"2016-08-08 21:09:24",,,,,,,,,,,,,,,,,"GTIN=8412842766150",18,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600126_93014.jpg,http://dropshipping.bigbuy.eu/imgs/V1600126_93013.jpg","GTIN=8412842766150",,,,,,,,,, +BB-V1300154,,Default,simple,"Default Category/Relax Tempo Libero,Default Category/Relax Tempo Libero/Mare e Piscina",base,"Zaino per Piscina Spiderman (4 pezzi)","<a id=""maggiorni_informazioni"" title=""Zaino per Piscina Spiderman (4 pezzi)""><p>Vorresti</a> sorprendere i più piccoli della casa con un <strong>regalo originale</strong>? Se adorano il mare o la piscina, lo <strong>zaino per piscina Spiderman (4 pezzi)</strong> li farà impazzire.</p><ul><li>Dispone di una cerniera, una rete posteriore e uno scompartimento per il nome</li><li>Dispone di manico regolabile</li><li>1 asciugamano 42,5 x 90,5 cm (80 % poliestere e 20 % poliammide) dall'asciugatura rapida</li><li>1 cuffia da nuoto taglia unica (85 % poliestere e 15 % elastam)</li><li>1 paio di occhialini da nuoto (norme 89/686/CEE e ISO 12312-1:2013) anti appannamento</li><li>Dimensioni dello zaino: 24 x 31 x 6 cm circa</li></ul><p> Dimenzioni per Zaino per Piscina Spiderman (4 pezzi): </br><ul><li>Altezza: 3 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 30.5 Cm</li><li>Peso: 0.283 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000752232</p>","Vorresti sorprendere i più piccoli della casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Spiderman (4 pezzi) li farà impazzire.</br><a href=""#maggiorni_informazioni"" title=""Zaino per Piscina Spiderman (4 pezzi)""> Maggiori Informazioni</a>",0.283,1,"Taxable Goods","Catalog, Search",37.9,,,,Zaino-per-Piscina-Spiderman-(4-pezzi),"Zaino per Piscina Spiderman (4 pezzi)","Relax Tempo Libero,Relax,Tempo,Libero,Mare e Piscina,Mare,Piscina,","Vorresti sorprendere i più piccoli della casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Spiderman (4 pezzi) li farà impazzire",http://dropshipping.bigbuy.eu/imgs/V1300154_93570.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300154_93570.jpg,,,,,,"2016-08-16 13:42:17",,,,,,,,,,,,,,,,,"GTIN=7569000752232",129,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300154_93576.jpg,http://dropshipping.bigbuy.eu/imgs/V1300154_93575.jpg,http://dropshipping.bigbuy.eu/imgs/V1300154_93574.jpg,http://dropshipping.bigbuy.eu/imgs/V1300154_93573.jpg,http://dropshipping.bigbuy.eu/imgs/V1300154_93572.jpg,http://dropshipping.bigbuy.eu/imgs/V1300154_93571.jpg","GTIN=7569000752232",,,,,,,,,, +BB-V1300156,,Default,simple,"Default Category/Relax Tempo Libero,Default Category/Relax Tempo Libero/Mare e Piscina",base,"Zaino per Piscina Frozen (4 pezzi)","<a id=""maggiorni_informazioni"" title=""Zaino per Piscina Frozen (4 pezzi)""><p>Vuoi</a> fare un <strong>regalo originale</strong> ai piccoli di casa? Se adorano il mare o la piscina, lo <strong>zaino per piscina Frozen (4 pezzi)</strong> li farà impazzire.</p><ul><li>Presenta una cerniera, una rete posteriore e uno scompartimento per il nome</li><li>Dispone di manico regolabile</li><li>1 asciugamano 42,5 x 90,5 cm (80 % poliestere e 20 % poliammide) dall'asciugatura rapida</li><li>1 cuffia da nuoto taglia unica (85 % poliestere e 15 % elastam)</li><li>1 paio di occhialini da nuoto (norme 89/686/CEE e ISO 12312-1:2013) anti appannamento</li><li>Dimensioni dello zaino: circa 24 x 31 x 6 cm</li></ul><p> Dimenzioni per Zaino per Piscina Frozen (4 pezzi): </br><ul><li>Altezza: 2 Cm</li><li>Larghezza: 23 Cm</li><li>Profondita': 30.5 Cm</li><li>Peso: 0.277 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000752225</p>","Vuoi fare un regalo originale ai piccoli di casa? Se adorano il mare o la piscina, lo zaino per piscina Frozen (4 pezzi) li farà impazzire.</br><a href=""#maggiorni_informazioni"" title=""Zaino per Piscina Frozen (4 pezzi)""> Maggiori Informazioni</a>",0.277,1,"Taxable Goods","Catalog, Search",37.9,,,,Zaino-per-Piscina-Frozen-(4-pezzi),"Zaino per Piscina Frozen (4 pezzi)","Relax Tempo Libero,Relax,Tempo,Libero,Mare e Piscina,Mare,Piscina,","Vuoi fare un regalo originale ai piccoli di casa? Se adorano il mare o la piscina, lo zaino per piscina Frozen (4 pezzi) li farà impazzire",http://dropshipping.bigbuy.eu/imgs/V1300156_93580.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300156_93580.jpg,,,,,,"2016-08-26 13:31:11",,,,,,,,,,,,,,,,,"GTIN=7569000752225",104,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300156_93594.jpg,http://dropshipping.bigbuy.eu/imgs/V1300156_93585.jpg,http://dropshipping.bigbuy.eu/imgs/V1300156_93584.jpg,http://dropshipping.bigbuy.eu/imgs/V1300156_93583.jpg,http://dropshipping.bigbuy.eu/imgs/V1300156_93582.jpg,http://dropshipping.bigbuy.eu/imgs/V1300156_93581.jpg","GTIN=7569000752225",,,,,,,,,, +BB-V1300157,,Default,simple,"Default Category/Relax Tempo Libero,Default Category/Relax Tempo Libero/Mare e Piscina",base,"Zaino per Piscina Minnie (4 pezzi)","<a id=""maggiorni_informazioni"" title=""Zaino per Piscina Minnie (4 pezzi)""><p>Ti</a> piacerebbe sorprendere le bimbe della casa con un <strong>regalo originale</strong>? Se adorano il mare o la piscina, lo <strong>zaino per piscina Minnie (4 pezzi)</strong> le farà impazzire.</p><ul><li>Dispone di una cerniera, una rete posteriore e uno scompartimento per il nome</li><li>Dispone di manico regolabile</li><li>1 asciugamano 42,5 x 90,5 cm (80 % poliestere e 20 % poliammide) dall'asciugatura rapida</li><li>1 cuffia da nuoto taglia unica (85 % poliestere e 15 % elastam)</li><li>1 paio di occhialini da nuoto (norme 89/686/CEE e ISO 12312-1:2013) anti appannamento</li><li>Dimensioni dello zaino: 24 x 31 x 6 cm circa</li></ul><p> Dimenzioni per Zaino per Piscina Minnie (4 pezzi): </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 24 Cm</li><li>Profondita': 30 Cm</li><li>Peso: 0.277 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934823796</p>","Ti piacerebbe sorprendere le bimbe della casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Minnie (4 pezzi) le farà impazzire.</br><a href=""#maggiorni_informazioni"" title=""Zaino per Piscina Minnie (4 pezzi)""> Maggiori Informazioni</a>",0.277,1,"Taxable Goods","Catalog, Search",37.9,,,,Zaino-per-Piscina-Minnie-(4-pezzi),"Zaino per Piscina Minnie (4 pezzi)","Relax Tempo Libero,Relax,Tempo,Libero,Mare e Piscina,Mare,Piscina,","Ti piacerebbe sorprendere le bimbe della casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Minnie (4 pezzi) le farà impazzire",http://dropshipping.bigbuy.eu/imgs/V1300157_93587.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300157_93587.jpg,,,,,,"2016-08-26 13:30:29",,,,,,,,,,,,,,,,,"GTIN=8427934823796",137,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300157_93593.jpg,http://dropshipping.bigbuy.eu/imgs/V1300157_93592.jpg,http://dropshipping.bigbuy.eu/imgs/V1300157_93591.jpg,http://dropshipping.bigbuy.eu/imgs/V1300157_93590.jpg,http://dropshipping.bigbuy.eu/imgs/V1300157_93589.jpg,http://dropshipping.bigbuy.eu/imgs/V1300157_93586.jpg","GTIN=8427934823796",,,,,,,,,, +BB-V1300158,,Default,simple,"Default Category/Relax Tempo Libero,Default Category/Relax Tempo Libero/Mare e Piscina",base,"Zaino per Piscina Avengers (4 pezzi)","<a id=""maggiorni_informazioni"" title=""Zaino per Piscina Avengers (4 pezzi)""><p>Ti</a> piacerebbe sorprendere i più piccoli della casa con un <strong>regalo originale</strong>? Se adorano il mare o la piscina, lo <strong>zaino per piscina Avengers (4 pezzi)</strong> li farà impazzire.</p><ul><li>Dispone di una cerniera, una rete posteriore e uno scompartimento per il nome</li><li>Dispone di manico regolabile</li><li>1 asciugamano 42,5 x 90,5 cm (80 % poliestere e 20 % poliammide) dall'asciugatura rapida</li><li>1 cuffia da nuoto taglia unica (85 % poliestere e 15 % elastam)</li><li>1 paio di occhialini da nuoto (norme 89/686/CEE e ISO 12312-1:2013) anti appannamento</li><li>Dimensioni dello zaino: 24 x 31 x 6 cm circa</li></ul><p> Dimenzioni per Zaino per Piscina Avengers (4 pezzi): </br><ul><li>Altezza: 3 Cm</li><li>Larghezza: 23 Cm</li><li>Profondita': 31 Cm</li><li>Peso: 0.279 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000752249</p>","Ti piacerebbe sorprendere i più piccoli della casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Avengers (4 pezzi) li farà impazzire.</br><a href=""#maggiorni_informazioni"" title=""Zaino per Piscina Avengers (4 pezzi)""> Maggiori Informazioni</a>",0.279,1,"Taxable Goods","Catalog, Search",37.9,,,,Zaino-per-Piscina-Avengers-(4-pezzi),"Zaino per Piscina Avengers (4 pezzi)","Relax Tempo Libero,Relax,Tempo,Libero,Mare e Piscina,Mare,Piscina,","Ti piacerebbe sorprendere i più piccoli della casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Avengers (4 pezzi) li farà impazzire",http://dropshipping.bigbuy.eu/imgs/V1300158_93596.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300158_93596.jpg,,,,,,"2016-08-26 11:29:45",,,,,,,,,,,,,,,,,"GTIN=7569000752249",139,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300158_93601.jpg,http://dropshipping.bigbuy.eu/imgs/V1300158_93600.jpg,http://dropshipping.bigbuy.eu/imgs/V1300158_93599.jpg,http://dropshipping.bigbuy.eu/imgs/V1300158_93598.jpg,http://dropshipping.bigbuy.eu/imgs/V1300158_93597.jpg,http://dropshipping.bigbuy.eu/imgs/V1300158_93595.jpg","GTIN=7569000752249",,,,,,,,,, +BB-V1300159,,Default,simple,"Default Category/Relax Tempo Libero,Default Category/Relax Tempo Libero/Mare e Piscina",base,"Zaino per Piscina Minions (4 pezzi)","<a id=""maggiorni_informazioni"" title=""Zaino per Piscina Minions (4 pezzi)""><p>Ti</a> piacerebbe sorprendere i più piccoli di casa con un <strong>regalo originale</strong>? Se adorano il mare o la piscina, lo <strong>zaino per piscina Minions (4 pezzi)</strong> li farà impazzire.</p><ul><li>Dispone di una cerniera, una rete posteriore e uno scompartimento per il nome</li><li>Dispone di manico regolabile</li><li>1 asciugamano 42,5 x 90,5 cm (80 % poliestere e 20 % poliammide) dall'asciugatura rapida</li><li>1 cuffia da nuoto taglia unica (85 % poliestere e 15 % elastam)</li><li>1 paio di occhialini da nuoto (norme 89/686/CEE e ISO 12312-1:2013) anti appannamento</li><li>Dimensioni dello zaino: 24 x 31 x 6 cm circa</li></ul><p> Dimenzioni per Zaino per Piscina Minions (4 pezzi): </br><ul><li>Altezza: 3 Cm</li><li>Larghezza: 23 Cm</li><li>Profondita': 31 Cm</li><li>Peso: 0.279 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934823833</p>","Ti piacerebbe sorprendere i più piccoli di casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Minions (4 pezzi) li farà impazzire.</br><a href=""#maggiorni_informazioni"" title=""Zaino per Piscina Minions (4 pezzi)""> Maggiori Informazioni</a>",0.279,1,"Taxable Goods","Catalog, Search",37.9,,,,Zaino-per-Piscina-Minions-(4-pezzi),"Zaino per Piscina Minions (4 pezzi)","Relax Tempo Libero,Relax,Tempo,Libero,Mare e Piscina,Mare,Piscina,","Ti piacerebbe sorprendere i più piccoli di casa con un regalo originale? Se adorano il mare o la piscina, lo zaino per piscina Minions (4 pezzi) li farà impazzire",http://dropshipping.bigbuy.eu/imgs/V1300159_93602.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300159_93602.jpg,,,,,,"2016-08-26 11:29:10",,,,,,,,,,,,,,,,,"GTIN=8427934823833",132,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300159_93608.jpg,http://dropshipping.bigbuy.eu/imgs/V1300159_93607.jpg,http://dropshipping.bigbuy.eu/imgs/V1300159_93606.jpg,http://dropshipping.bigbuy.eu/imgs/V1300159_93605.jpg,http://dropshipping.bigbuy.eu/imgs/V1300159_93604.jpg,http://dropshipping.bigbuy.eu/imgs/V1300159_93603.jpg","GTIN=8427934823833",,,,,,,,,, +BB-G0500179,,Default,simple,"Default Category/Sport Fitness,Default Category/Sport Fitness/Abbigliamento, Accessori e Dispositivi Indossabili",base,"Clip di Sicurezza a LED per Scarpe da Corsa GoFit","<a id=""maggiorni_informazioni"" title=""Clip di Sicurezza a LED per Scarpe da Corsa GoFit ""><p>Ti</a> piace allenarti la sera all'aria aperta? Allora, non dimenticare di indossare la tua <strong>clip di sicurezza a LED per scarpe da corsa GoFit</strong>! Grazie a questo utile <strong>accessorio da corsa</strong>, sarai visibile al buio mentre corri o ti alleni. Include 2 tipi di luce (fissa o intermittente) e può essere facilmente applicata sulla parte posteriore della scarpa o indossata intorno al polso. Possiede un bottone on/off che ti permette inoltre di cambiare il tipo di luce.  <strong>Progettato in Europa</strong> con materiali di alta qualità. 1 pezzo incluso.</p><p>Caratteristiche: </p><ul><li>LED verde per alta visibilità</li><li>Circa 100 ore di luce intermittente</li><li>Circa 70 ore di luce fissa</li><li>Adattabile a scrarpe da 6 a 8,5 cm di larghezza</li><li>Funziona a batterie (2 x CR2032, incluse)</li></ul><p> Dimenzioni per Clip di Sicurezza a LED per Scarpe da Corsa GoFit : </br><ul><li>Altezza: 18 Cm</li><li>Larghezza: 9 Cm</li><li>Profondita': 3.7 Cm</li><li>Peso: 0.095 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417209116</p>","Ti piace allenarti la sera all'aria aperta? Allora, non dimenticare di indossare la tua clip di sicurezza a LED per scarpe da corsa GoFit! Grazie a questo utile accessorio da corsa, sarai visibile al buio mentre corri o ti alleni.</br><a href=""#maggiorni_informazioni"" title=""Clip di Sicurezza a LED per Scarpe da Corsa GoFit ""> Maggiori Informazioni</a>",0.095,1,"Taxable Goods","Catalog, Search",34.95,,,,Clip-di-Sicurezza-a-LED-per-Scarpe-da-Corsa-GoFit,"Clip di Sicurezza a LED per Scarpe da Corsa GoFit","Sport Fitness,Sport,Fitness,Abbigliamento, Accessori e Dispositivi Indossabili,Abbigliamento,,Accessori,Dispositivi,Indossabili,","Ti piace allenarti la sera all'aria aperta? Allora, non dimenticare di indossare la tua clip di sicurezza a LED per scarpe da corsa GoFit! Grazie a questo utile accessorio da corsa, sarai visibile al buio mentre corri o ti alleni",http://dropshipping.bigbuy.eu/imgs/clip_led_running_go_fit.jpg,,http://dropshipping.bigbuy.eu/imgs/clip_led_running_go_fit.jpg,,,,,,"2016-08-22 08:23:08",,,,,,,,,,,,,,,,,"GTIN=8018417209116",207,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/clip_led_running_go_fit_04.jpg,http://dropshipping.bigbuy.eu/imgs/clip_led_running_go_fit_02.jpg,http://dropshipping.bigbuy.eu/imgs/clip_led_running_go_fit_002.jpg","GTIN=8018417209116",,,,,,,,,, +BB-G0500187,,Default,simple,"Default Category/Sport Fitness,Default Category/Sport Fitness/Abbigliamento, Accessori e Dispositivi Indossabili",base,"Sensore di Velocità Bluetooth GoFit","<a id=""maggiorni_informazioni"" title=""Sensore di Velocità Bluetooth GoFit""><p>Se</a> sei un amante del ciclismo e vuoi tener traccia della velocità, ritmo e distanza mentre pedali? Allora non perdere l'occasione, acquista il <strong>sensore di velocità Bluetooth GoFit</strong>, in modo da essere in grado di monitorare tutti i suoi dati in ogni momento, grazie al suo ingegnoso dispositivo! Devi solo installare il sensore e scaricare l'apposita applicazione sul tuo telefono cellulare.<br /><br />Questo <strong>sensore di velocità e ritmo </strong>è stato progettato in Europa ed è costituito di materiali resistenti all'acqua, in polimeri termoplastici. Molto semplice da installare. Compatibile con iOS (7.0 e successivi) e Android (4.3 e successivi). Funziona a batterie (1 x CR2032, incluse).</p><p>Include:</p><ul><li>1 sensore di velocità e ritmo (dimensioni: circa 8,5 x 7 x 1,5 cm)</li><li>1 magnete per ritmo pedale (dimensioni: circa 1,5 x 3,5 x 2 cm)</li><li>1 magnete per ruote</li><li>1 cacciavite</li><li>2 fascette</li><li>1 banda elastica</li></ul><p> Dimenzioni per Sensore di Velocità Bluetooth GoFit: </br><ul><li>Altezza: 20.3 Cm</li><li>Larghezza: 9 Cm</li><li>Profondita': 3.5 Cm</li><li>Peso: 0.143 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417209109</p>","Se sei un amante del ciclismo e vuoi tener traccia della velocità, ritmo e distanza mentre pedali? Allora non perdere l'occasione, acquista il sensore di velocità Bluetooth GoFit, in modo da essere in grado di monitorare tutti i suoi dati in ogni momento, grazie al suo ingegnoso dispositivo! Devi solo installare il sensore e scaricare l'apposita applicazione sul tuo telefono cellulare.</br><a href=""#maggiorni_informazioni"" title=""Sensore di Velocità Bluetooth GoFit""> Maggiori Informazioni</a>",0.143,1,"Taxable Goods","Catalog, Search",179.9,,,,Sensore-di-Velocità-Bluetooth-GoFit,"Sensore di Velocità Bluetooth GoFit","Sport Fitness,Sport,Fitness,Abbigliamento, Accessori e Dispositivi Indossabili,Abbigliamento,,Accessori,Dispositivi,Indossabili,","Se sei un amante del ciclismo e vuoi tener traccia della velocità, ritmo e distanza mentre pedali? Allora non perdere l'occasione, acquista il sensore di velocità Bluetooth GoFit, in modo da essere in grado di monitorare tutti i suoi dati in ogni mo",http://dropshipping.bigbuy.eu/imgs/G0500187_81130.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500187_81130.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8018417209109",17,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500187_81089.jpg,http://dropshipping.bigbuy.eu/imgs/G0500187_81088.jpg,http://dropshipping.bigbuy.eu/imgs/G0500187_81087.jpg,http://dropshipping.bigbuy.eu/imgs/G0500187_81086.jpg","GTIN=8018417209109",,,,,,,,,, +BB-G0500188,,Default,simple,"Default Category/Sport Fitness,Default Category/Sport Fitness/Abbigliamento, Accessori e Dispositivi Indossabili",base,"Luce a Led di Sicurezza per Lacci GoFit (pacco da 2)","<a id=""maggiorni_informazioni"" title=""Luce a Led di Sicurezza per Lacci GoFit (pacco da 2)""><p>Da</a> ora in poi potrai fare jogging serenamente, sapendo che puoi essere visto dai veicoli intorno a te! <span id=""result_box"" lang=""en""><span class=""hps"">Basta inserire la <strong>luce a led di sicurezza </strong></span><strong>GoFit <span class=""hps atn"">(pacco da </span><span class=""hps"">2)</span> </strong>dentro ogni scarpa da corsa per aumentare la tua visibilità.<span class=""hps""> Le loro 2 potenti luci a LED verdi si attivano ad ogni passo che fai. È davvero semplice!</span><br /><br /><span class=""hps"">Ogni luce a LED funziona a pile</span> <span class=""hps atn"">(</span>2 x <span class=""hps"">CR1220</span>, 6 <span class=""hps"">V</span>, <span class=""hps"">incluse).</span> Durata delle batterie<span class=""hps"">: </span><span class=""hps"">150,000</span> <span class=""hps"">lampeggi di luce.</span> Queste luci a LED sono costituite di materiali di alta qualità e sono adatte all'utilizzo con i lacci con uno spessore massimo di 9 mm<span class=""hps"">.</span> <span class=""hps"">Include 2 unità. Dimensioni: circa</span> <span class=""hps"">4 x</span> <span class=""hps"">1,5</span> <span class=""hps"">x</span> <span class=""hps"">0,8</span> <span class=""hps"">cm.</span><br /></span></p><p> Dimenzioni per Luce a Led di Sicurezza per Lacci GoFit (pacco da 2): </br><ul><li>Altezza: 9 Cm</li><li>Larghezza: 3.7 Cm</li><li>Profondita': 20 Cm</li><li>Peso: 0.061 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417209482</p>","Da ora in poi potrai fare jogging serenamente, sapendo che puoi essere visto dai veicoli intorno a te! Basta inserire la luce a led di sicurezza GoFit (pacco da 2) dentro ogni scarpa da corsa per aumentare la tua visibilità.</br><a href=""#maggiorni_informazioni"" title=""Luce a Led di Sicurezza per Lacci GoFit (pacco da 2)""> Maggiori Informazioni</a>",0.061,1,"Taxable Goods","Catalog, Search",33.95,,,,Luce-a-Led-di-Sicurezza-per-Lacci-GoFit-(pacco-da-2),"Luce a Led di Sicurezza per Lacci GoFit (pacco da 2)","Sport Fitness,Sport,Fitness,Abbigliamento, Accessori e Dispositivi Indossabili,Abbigliamento,,Accessori,Dispositivi,Indossabili,","Da ora in poi potrai fare jogging serenamente, sapendo che puoi essere visto dai veicoli intorno a te! Basta inserire la luce a led di sicurezza GoFit (pacco da 2) dentro ogni scarpa da corsa per aumentare la tua visibilità",http://dropshipping.bigbuy.eu/imgs/G0500188_81129.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500188_81129.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8018417209482",238,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500188_81096.jpg,http://dropshipping.bigbuy.eu/imgs/G0500188_81095.jpg","GTIN=8018417209482",,,,,,,,,, +BB-G0500189,,Default,simple,"Default Category/Sport Fitness,Default Category/Sport Fitness/Abbigliamento, Accessori e Dispositivi Indossabili",base,"Bracciale di Sicurezza LED GoFit","<a id=""maggiorni_informazioni"" title=""Bracciale di Sicurezza LED GoFit""><p>Non</a> possiedi ancora il <strong>bracciale di sicurezza LED</strong> <strong>GoFit</strong>? Non perdertelo e pratica i tuoi sport preferiti con questo leggero e comodo <strong>bracciale </strong>progettato in Europa. Qualunque auto o moto ti vedrà nel buio! Include 2 luci a LED e 2 modalità di illuminazione (fissa e lampeggiante) che puoi scegliere premendo il pulsante del bracciale. La cinghia di velcro lo fissa al braccio ed è flessibile e regolabile. La lunghezza massima è di circa 38,5 cm e la minima è di 31 cm. Funziona a batterie (2 x CR2023, incluse).</p><p> Dimenzioni per Bracciale di Sicurezza LED GoFit: </br><ul><li>Altezza: 9 Cm</li><li>Larghezza: 4 Cm</li><li>Profondita': 20 Cm</li><li>Peso: 0.087 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417209475</p>","Non possiedi ancora il bracciale di sicurezza LED GoFit? Non perdertelo e pratica i tuoi sport preferiti con questo leggero e comodo bracciale progettato in Europa.</br><a href=""#maggiorni_informazioni"" title=""Bracciale di Sicurezza LED GoFit""> Maggiori Informazioni</a>",0.087,1,"Taxable Goods","Catalog, Search",44.95,,,,Bracciale-di-Sicurezza-LED-GoFit,"Bracciale di Sicurezza LED GoFit","Sport Fitness,Sport,Fitness,Abbigliamento, Accessori e Dispositivi Indossabili,Abbigliamento,,Accessori,Dispositivi,Indossabili,","Non possiedi ancora il bracciale di sicurezza LED GoFit? Non perdertelo e pratica i tuoi sport preferiti con questo leggero e comodo bracciale progettato in Europa",http://dropshipping.bigbuy.eu/imgs/G0500189_81128.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500189_81128.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8018417209475",93,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500189_81093.jpg,http://dropshipping.bigbuy.eu/imgs/G0500189_81092.jpg,http://dropshipping.bigbuy.eu/imgs/G0500189_81091.jpg","GTIN=8018417209475",,,,,,,,,, +BB-F1510306,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Abbigliamento e Scarpe,Default Category/Moda Accessori/Abbigliamento e Scarpe/Pigiami e vestaglie",base,"Coperta con Maniche Snug Snug Big Tribu Leopardato","<a id=""maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug Big Tribu""><p>Affronta</a> il freddo invernale con l'originale<strong> coperta con<strong> maniche </strong><strong>Snug Snug <strong>Big</strong> <strong>Tribu</strong></strong>! </strong>Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti. La <strong>coperta con maniche Big</strong> <strong>Tribu</strong> è dotata di tasca centrale per avere tutto a portata di mano, il telecomando della TV, il tuo libro preferito, ecc. Realizzata in morbido pile 100 % poliestere. Misure: 170 x 130 cm circa.<br /><br /><strong><a href=""http://www.snugsnug.com"">www.snugsnug.com</a></strong></p><p> </p><p> </p><p> Dimenzioni per Coperta con Maniche Snug Snug Big Tribu: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 8.5 Cm</li><li>Profondita': 26.5 Cm</li><li>Peso: 0.602 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888103530</p>","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug Big Tribu! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti.</br><a href=""#maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug Big Tribu""> Maggiori Informazioni</a>",0.602,1,"Taxable Goods","Catalog, Search",39.9,,,,Coperta-con-Maniche-Snug-Snug-Big-Tribu-Leopardato,"Coperta con Maniche Snug Snug Big Tribu Leopardato","Moda Accessori,Moda,Accessori,Abbigliamento e Scarpe,Abbigliamento,Scarpe,Pigiami e vestaglie,Pigiami,vestaglie,Colore Leopardato,Leopardato,","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug Big Tribu! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle",http://dropshipping.bigbuy.eu/imgs/F1510300_81361.jpg,,http://dropshipping.bigbuy.eu/imgs/F1510300_81361.jpg,,,,,,"2015-12-30 17:30:06",,,,,,,,,,,,,,,,,"GTIN=4899888103530",46,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/F1510300_81365.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81364.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81363.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81362.jpg","GTIN=4899888103530",,,,,,,,,, +BB-F1510307,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Abbigliamento e Scarpe,Default Category/Moda Accessori/Abbigliamento e Scarpe/Pigiami e vestaglie",base,"Coperta con Maniche Snug Snug Big Tribu Zebra","<a id=""maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug Big Tribu""><p>Affronta</a> il freddo invernale con l'originale<strong> coperta con<strong> maniche </strong><strong>Snug Snug <strong>Big</strong> <strong>Tribu</strong></strong>! </strong>Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti. La <strong>coperta con maniche Big</strong> <strong>Tribu</strong> è dotata di tasca centrale per avere tutto a portata di mano, il telecomando della TV, il tuo libro preferito, ecc. Realizzata in morbido pile 100 % poliestere. Misure: 170 x 130 cm circa.<br /><br /><strong><a href=""http://www.snugsnug.com"">www.snugsnug.com</a></strong></p><p> </p><p> </p><p> Dimenzioni per Coperta con Maniche Snug Snug Big Tribu: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 8.5 Cm</li><li>Profondita': 26.5 Cm</li><li>Peso: 0.602 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888103530</p>","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug Big Tribu! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti.</br><a href=""#maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug Big Tribu""> Maggiori Informazioni</a>",0.602,1,"Taxable Goods","Catalog, Search",39.9,,,,Coperta-con-Maniche-Snug-Snug-Big-Tribu-Zebra,"Coperta con Maniche Snug Snug Big Tribu Zebra","Moda Accessori,Moda,Accessori,Abbigliamento e Scarpe,Abbigliamento,Scarpe,Pigiami e vestaglie,Pigiami,vestaglie,Colore Zebra,Zebra,","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug Big Tribu! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle",http://dropshipping.bigbuy.eu/imgs/F1510300_81365.jpg,,http://dropshipping.bigbuy.eu/imgs/F1510300_81365.jpg,,,,,,"2016-01-21 08:26:10",,,,,,,,,,,,,,,,,"GTIN=4899888103530",486,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/F1510300_81361.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81364.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81363.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81362.jpg","GTIN=4899888103530",,,,,,,,,, +BB-F1510308,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Abbigliamento e Scarpe,Default Category/Moda Accessori/Abbigliamento e Scarpe/Pigiami e vestaglie",base,"Coperta con Maniche Snug Snug Big Tribu Dalmata","<a id=""maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug Big Tribu""><p>Affronta</a> il freddo invernale con l'originale<strong> coperta con<strong> maniche </strong><strong>Snug Snug <strong>Big</strong> <strong>Tribu</strong></strong>! </strong>Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti. La <strong>coperta con maniche Big</strong> <strong>Tribu</strong> è dotata di tasca centrale per avere tutto a portata di mano, il telecomando della TV, il tuo libro preferito, ecc. Realizzata in morbido pile 100 % poliestere. Misure: 170 x 130 cm circa.<br /><br /><strong><a href=""http://www.snugsnug.com"">www.snugsnug.com</a></strong></p><p> </p><p> </p><p> Dimenzioni per Coperta con Maniche Snug Snug Big Tribu: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 8.5 Cm</li><li>Profondita': 26.5 Cm</li><li>Peso: 0.602 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888103530</p>","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug Big Tribu! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti.</br><a href=""#maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug Big Tribu""> Maggiori Informazioni</a>",0.602,1,"Taxable Goods","Catalog, Search",39.9,,,,Coperta-con-Maniche-Snug-Snug-Big-Tribu-Dalmata,"Coperta con Maniche Snug Snug Big Tribu Dalmata","Moda Accessori,Moda,Accessori,Abbigliamento e Scarpe,Abbigliamento,Scarpe,Pigiami e vestaglie,Pigiami,vestaglie,Colore Dalmata,Dalmata,","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug Big Tribu! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle",http://dropshipping.bigbuy.eu/imgs/F1510300_81364.jpg,,http://dropshipping.bigbuy.eu/imgs/F1510300_81364.jpg,,,,,,"2016-02-22 08:16:26",,,,,,,,,,,,,,,,,"GTIN=4899888103530",307,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/F1510300_81361.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81365.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81363.jpg,http://dropshipping.bigbuy.eu/imgs/F1510300_81362.jpg","GTIN=4899888103530",,,,,,,,,, +BB-F1510302,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Abbigliamento e Scarpe,Default Category/Moda Accessori/Abbigliamento e Scarpe/Pigiami e vestaglie",base,"Coperta con Maniche Snug Snug One Big Azzurro","<a id=""maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""><p>Affronta</a> il freddo invernale con l'originale <strong>coperta con maniche </strong><strong>Snug Snug One Big</strong>! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti. La <strong>coperta con maniche </strong><strong>One Big</strong> è dotata di tasca centrale per avere tutto a portata di mano, il telecomando della TV, il tuo libro preferito, ecc. Realizzata in morbido pile 100 % poliestere. Misure: 170 x 130 cm circa.<br /><br /><strong><a href=""http://www.snugsnug.com/"">www.snugsnug.com</a><br /></strong></p><p> Dimenzioni per Coperta con Maniche Snug Snug One Big: </br><ul><li>Altezza: 26.5 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 23.7 Cm</li><li>Peso: 0.538 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102977</p>","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti.</br><a href=""#maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""> Maggiori Informazioni</a>",0.538,1,"Taxable Goods","Catalog, Search",29.9,,,,Coperta-con-Maniche-Snug-Snug-One-Big-Azzurro,"Coperta con Maniche Snug Snug One Big Azzurro","Moda Accessori,Moda,Accessori,Abbigliamento e Scarpe,Abbigliamento,Scarpe,Pigiami e vestaglie,Pigiami,vestaglie,Colore Azzurro,Azzurro,","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle m",http://dropshipping.bigbuy.eu/imgs/F1510301_81356.jpg,,http://dropshipping.bigbuy.eu/imgs/F1510301_81356.jpg,,,,,,"2015-12-28 17:26:13",,,,,,,,,,,,,,,,,"GTIN=4899888102977",538,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/F1510301_81360.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81359.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81358.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81357.jpg","GTIN=4899888102977",,,,,,,,,, +BB-F1510303,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Abbigliamento e Scarpe,Default Category/Moda Accessori/Abbigliamento e Scarpe/Pigiami e vestaglie",base,"Coperta con Maniche Snug Snug One Big Rosso","<a id=""maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""><p>Affronta</a> il freddo invernale con l'originale <strong>coperta con maniche </strong><strong>Snug Snug One Big</strong>! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti. La <strong>coperta con maniche </strong><strong>One Big</strong> è dotata di tasca centrale per avere tutto a portata di mano, il telecomando della TV, il tuo libro preferito, ecc. Realizzata in morbido pile 100 % poliestere. Misure: 170 x 130 cm circa.<br /><br /><strong><a href=""http://www.snugsnug.com/"">www.snugsnug.com</a><br /></strong></p><p> Dimenzioni per Coperta con Maniche Snug Snug One Big: </br><ul><li>Altezza: 26.5 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 23.7 Cm</li><li>Peso: 0.538 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102977</p>","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti.</br><a href=""#maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""> Maggiori Informazioni</a>",0.538,1,"Taxable Goods","Catalog, Search",29.9,,,,Coperta-con-Maniche-Snug-Snug-One-Big-Rosso,"Coperta con Maniche Snug Snug One Big Rosso","Moda Accessori,Moda,Accessori,Abbigliamento e Scarpe,Abbigliamento,Scarpe,Pigiami e vestaglie,Pigiami,vestaglie,Colore Rosso,Rosso,","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle m",http://dropshipping.bigbuy.eu/imgs/F1510301_81360.jpg,,http://dropshipping.bigbuy.eu/imgs/F1510301_81360.jpg,,,,,,"2016-01-20 10:35:30",,,,,,,,,,,,,,,,,"GTIN=4899888102977",600,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/F1510301_81356.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81359.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81358.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81357.jpg","GTIN=4899888102977",,,,,,,,,, +BB-F1510304,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Abbigliamento e Scarpe,Default Category/Moda Accessori/Abbigliamento e Scarpe/Pigiami e vestaglie",base,"Coperta con Maniche Snug Snug One Big Verde","<a id=""maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""><p>Affronta</a> il freddo invernale con l'originale <strong>coperta con maniche </strong><strong>Snug Snug One Big</strong>! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti. La <strong>coperta con maniche </strong><strong>One Big</strong> è dotata di tasca centrale per avere tutto a portata di mano, il telecomando della TV, il tuo libro preferito, ecc. Realizzata in morbido pile 100 % poliestere. Misure: 170 x 130 cm circa.<br /><br /><strong><a href=""http://www.snugsnug.com/"">www.snugsnug.com</a><br /></strong></p><p> Dimenzioni per Coperta con Maniche Snug Snug One Big: </br><ul><li>Altezza: 26.5 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 23.7 Cm</li><li>Peso: 0.538 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102977</p>","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti.</br><a href=""#maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""> Maggiori Informazioni</a>",0.538,1,"Taxable Goods","Catalog, Search",29.9,,,,Coperta-con-Maniche-Snug-Snug-One-Big-Verde,"Coperta con Maniche Snug Snug One Big Verde","Moda Accessori,Moda,Accessori,Abbigliamento e Scarpe,Abbigliamento,Scarpe,Pigiami e vestaglie,Pigiami,vestaglie,Colore Verde,Verde,","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle m",http://dropshipping.bigbuy.eu/imgs/F1510301_81359.jpg,,http://dropshipping.bigbuy.eu/imgs/F1510301_81359.jpg,,,,,,"2015-10-06 11:20:02",,,,,,,,,,,,,,,,,"GTIN=4899888102977",764,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/F1510301_81356.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81360.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81358.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81357.jpg","GTIN=4899888102977",,,,,,,,,, +BB-F1510305,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Abbigliamento e Scarpe,Default Category/Moda Accessori/Abbigliamento e Scarpe/Pigiami e vestaglie",base,"Coperta con Maniche Snug Snug One Big Rosa","<a id=""maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""><p>Affronta</a> il freddo invernale con l'originale <strong>coperta con maniche </strong><strong>Snug Snug One Big</strong>! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti. La <strong>coperta con maniche </strong><strong>One Big</strong> è dotata di tasca centrale per avere tutto a portata di mano, il telecomando della TV, il tuo libro preferito, ecc. Realizzata in morbido pile 100 % poliestere. Misure: 170 x 130 cm circa.<br /><br /><strong><a href=""http://www.snugsnug.com/"">www.snugsnug.com</a><br /></strong></p><p> Dimenzioni per Coperta con Maniche Snug Snug One Big: </br><ul><li>Altezza: 26.5 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 23.7 Cm</li><li>Peso: 0.538 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102977</p>","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle maniche che ti permettono una totale libertà di movimenti.</br><a href=""#maggiorni_informazioni"" title=""Coperta con Maniche Snug Snug One Big""> Maggiori Informazioni</a>",0.538,1,"Taxable Goods","Catalog, Search",29.9,,,,Coperta-con-Maniche-Snug-Snug-One-Big-Rosa,"Coperta con Maniche Snug Snug One Big Rosa","Moda Accessori,Moda,Accessori,Abbigliamento e Scarpe,Abbigliamento,Scarpe,Pigiami e vestaglie,Pigiami,vestaglie,Colore Rosa,Rosa,","Affronta il freddo invernale con l'originale coperta con maniche Snug Snug One Big! Un fantastico prodotto firmato Snug Snug che ti farà sentire comodo e al caldo, mentre sei sdraiato sul divano o stai facendo qualsiasi lavoro in casa, grazie alle m",http://dropshipping.bigbuy.eu/imgs/F1510301_81358.jpg,,http://dropshipping.bigbuy.eu/imgs/F1510301_81358.jpg,,,,,,"2015-12-29 06:48:13",,,,,,,,,,,,,,,,,"GTIN=4899888102977",968,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/F1510301_81356.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81360.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81359.jpg,http://dropshipping.bigbuy.eu/imgs/F1510301_81357.jpg","GTIN=4899888102977",,,,,,,,,, +BB-V1300145,,Default,simple,"Default Category/Moda Accessori,Default Category/Moda Accessori/Accessori,Default Category/Relax Tempo Libero/Accessori/Ombrelli",base,"Ombrello Star Wars con LED","<a id=""maggiorni_informazioni"" title=""Ombrello Star Wars con LED""><p>I</a> fan di Guerre Stellari impazziranno con l'<strong>ombrello Star Wars con LED</strong>!</p><ul><li>Interruttore on/off sul manico</li><li>LED di vari colori sul manico centrale</li><li>Funziona a batterie (3 x AA, incluse)</li><li>Struttura: metallo, plastica e fibra di vetro</li><li>Cupola: poliestere (pongee)</li><li>Lunghezza approssimativa: 79,5 cm</li><li>Diametro approssimativo: 95 cm</li></ul><p> Dimenzioni per Ombrello Star Wars con LED: </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 79.5 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.458 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000752317</p>","I fan di Guerre Stellari impazziranno con l'ombrello Star Wars con LED!Interruttore on/off sul manicoLED di vari colori sul manico centraleFunziona a batterie (3 x AA, incluse)Struttura: metallo, plastica e fibra di vetroCupola: poliestere (pongee)Lunghezza approssimativa: 79,5 cmDiametro approssimativo: 95 cm.</br><a href=""#maggiorni_informazioni"" title=""Ombrello Star Wars con LED""> Maggiori Informazioni</a>",0.458,1,"Taxable Goods","Catalog, Search",53.9,,,,Ombrello-Star-Wars-con-LED,"Ombrello Star Wars con LED","Moda Accessori,Moda,Accessori,Accessori,Ombrelli,","I fan di Guerre Stellari impazziranno con l'ombrello Star Wars con LED!Interruttore on/off sul manicoLED di vari colori sul manico centraleFunziona a batterie (3 x AA, incluse)Struttura: metallo, plastica e fibra di vetroCupola: poliestere (pongee)L",http://dropshipping.bigbuy.eu/imgs/V1300145_93662.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300145_93662.jpg,,,,,,"2016-09-12 07:48:53",,,,,,,,,,,,,,,,,"GTIN=7569000752317",23,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300145_93665.jpg,http://dropshipping.bigbuy.eu/imgs/V1300145_93664.jpg,http://dropshipping.bigbuy.eu/imgs/V1300145_93663.jpg,http://dropshipping.bigbuy.eu/imgs/V1300145_93661.jpg","GTIN=7569000752317",,,,,,,,,, +BB-H4530316,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Giocattoli e Giochi,Default Category/Giochi Bambini/Giocattoli e Giochi/Giochi educativi",base,"Elastici per fare bracciali con Perline di Frozen","<a id=""maggiorni_informazioni"" title=""Elastici per fare bracciali con Perline di Frozen""><p>Se</a> cerchi un <strong>gioco che intrattenga </strong>i tuoi figli e che sia l'ideale per essere alla moda, non perdere gli <strong>elastici per fare bracciali con le perline di Frozen</strong>. Contiene 130 elastici di diversi colori, perline di differenti forme con i protagonisti di Frozen, 1 gancino metallico, una chiusura a S, perline di diversi colori, 1 strumento per tenere gli elastici. Età consigliata: +5 anni. </p><p> </p><p> Dimenzioni per Elastici per fare bracciali con Perline di Frozen: </br><ul><li>Altezza: 18 Cm</li><li>Larghezza: 3.5 Cm</li><li>Profondita': 15 Cm</li><li>Peso: 0.102 Kg</li></ul></p><p>Codice Prodotto (EAN): 8714274680036</p>","Se cerchi un gioco che intrattenga i tuoi figli e che sia l'ideale per essere alla moda, non perdere gli elastici per fare bracciali con le perline di Frozen.</br><a href=""#maggiorni_informazioni"" title=""Elastici per fare bracciali con Perline di Frozen""> Maggiori Informazioni</a>",0.102,1,"Taxable Goods","Catalog, Search",35,,,,Elastici-per-fare-bracciali-con-Perline-di-Frozen,"Elastici per fare bracciali con Perline di Frozen","Giochi Bambini,Giochi,Bambini,Giocattoli e Giochi,Giocattoli,Giochi,Giochi educativi,Giochi,educativi,","Se cerchi un gioco che intrattenga i tuoi figli e che sia l'ideale per essere alla moda, non perdere gli elastici per fare bracciali con le perline di Frozen",http://dropshipping.bigbuy.eu/imgs/H4530316_93063.jpg,,http://dropshipping.bigbuy.eu/imgs/H4530316_93063.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8714274680036",78,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/H4530316_93067.jpg,http://dropshipping.bigbuy.eu/imgs/H4530316_93066.jpg,http://dropshipping.bigbuy.eu/imgs/H4530316_93065.jpg,http://dropshipping.bigbuy.eu/imgs/H4530316_93064.jpg","GTIN=8714274680036",,,,,,,,,, +BB-V1300134,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Ombrelli e cappellini per bambini",base,"Berretto per Bambini Cars Rosso","<a id=""maggiorni_informazioni"" title=""Berretto per Bambini Cars""><p>Vuoi</a> sorprendere i piccoli della tua casa? Lightning McQueen proteggerà i più piccoli della casa dal sole di questa estate! Non perderti il <strong>berretto per bambini Cars</strong>. Dimensioni 54-56 cm. Misure della visiera: 14 x 6,5 cm circa. Composizione: 65% cotone e 35% poliestere</p><p> Dimenzioni per Berretto per Bambini Cars: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 17 Cm</li><li>Profondita': 21 Cm</li><li>Peso: 0.048 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934797318</p>","Vuoi sorprendere i piccoli della tua casa? Lightning McQueen proteggerà i più piccoli della casa dal sole di questa estate! Non perderti il berretto per bambini Cars.</br><a href=""#maggiorni_informazioni"" title=""Berretto per Bambini Cars""> Maggiori Informazioni</a>",0.048,1,"Taxable Goods","Catalog, Search",7.9,,,,Berretto-per-Bambini-Cars-Rosso,"Berretto per Bambini Cars Rosso","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Ombrelli e cappellini per bambini,Ombrelli,cappellini,bambini,Colore Rosso,Rosso,","Vuoi sorprendere i piccoli della tua casa? Lightning McQueen proteggerà i più piccoli della casa dal sole di questa estate! Non perderti il berretto per bambini Cars",http://dropshipping.bigbuy.eu/imgs/V1300133_91571.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300133_91571.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934797318",87,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300133_91229.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91197.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91196.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91195.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91194.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91193.jpg","GTIN=8427934797318",,,,,,,,,, +BB-V1300135,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Ombrelli e cappellini per bambini",base,"Berretto per Bambini Cars Nero","<a id=""maggiorni_informazioni"" title=""Berretto per Bambini Cars""><p>Vuoi</a> sorprendere i piccoli della tua casa? Lightning McQueen proteggerà i più piccoli della casa dal sole di questa estate! Non perderti il <strong>berretto per bambini Cars</strong>. Dimensioni 54-56 cm. Misure della visiera: 14 x 6,5 cm circa. Composizione: 65% cotone e 35% poliestere</p><p> Dimenzioni per Berretto per Bambini Cars: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 17 Cm</li><li>Profondita': 21 Cm</li><li>Peso: 0.048 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934797301</p>","Vuoi sorprendere i piccoli della tua casa? Lightning McQueen proteggerà i più piccoli della casa dal sole di questa estate! Non perderti il berretto per bambini Cars.</br><a href=""#maggiorni_informazioni"" title=""Berretto per Bambini Cars""> Maggiori Informazioni</a>",0.048,1,"Taxable Goods","Catalog, Search",7.9,,,,Berretto-per-Bambini-Cars-Nero,"Berretto per Bambini Cars Nero","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Ombrelli e cappellini per bambini,Ombrelli,cappellini,bambini,Colore Nero,Nero,","Vuoi sorprendere i piccoli della tua casa? Lightning McQueen proteggerà i più piccoli della casa dal sole di questa estate! Non perderti il berretto per bambini Cars",http://dropshipping.bigbuy.eu/imgs/V1300133_91229.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300133_91229.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934797301",123,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300133_91571.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91197.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91196.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91195.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91194.jpg,http://dropshipping.bigbuy.eu/imgs/V1300133_91193.jpg","GTIN=8427934797301",,,,,,,,,, +BB-V1300138,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Ombrelli e cappellini per bambini",base,"Cappello per Bambini Batman vs Superman Blu Marino","<a id=""maggiorni_informazioni"" title=""Cappello per Bambini Batman vs Superman""><p>A</a> quale bambino non piace vantarsi dei suoi <strong>accessori moda</strong>? Sorprendi i più piccoli con il <strong>cappello per bambini Batman vs Superman</strong><strong> </strong>e quest'estate fai sì che siano ben protetti dai raggi solari. Taglia 52-54 cm. Dimensioni della visiera: 15 x 6,5 cm circa. Composizione: 65 % cotone e 35 % poliestere.</p><p> Dimenzioni per Cappello per Bambini Batman vs Superman: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 19 Cm</li><li>Profondita': 20 Cm</li><li>Peso: 0.048 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934824359</p>","A quale bambino non piace vantarsi dei suoi accessori moda? Sorprendi i più piccoli con il cappello per bambini Batman vs Superman e quest'estate fai sì che siano ben protetti dai raggi solari.</br><a href=""#maggiorni_informazioni"" title=""Cappello per Bambini Batman vs Superman""> Maggiori Informazioni</a>",0.048,1,"Taxable Goods","Catalog, Search",12.5,,,,Cappello-per-Bambini-Batman-vs-Superman-Blu Marino,"Cappello per Bambini Batman vs Superman Blu Marino","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Ombrelli e cappellini per bambini,Ombrelli,cappellini,bambini,Colore Blu Marino,Blu Marino,","A quale bambino non piace vantarsi dei suoi accessori moda? Sorprendi i più piccoli con il cappello per bambini Batman vs Superman e quest'estate fai sì che siano ben protetti dai raggi solari",http://dropshipping.bigbuy.eu/imgs/V1300136_91230.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300136_91230.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934824359",98,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300136_91231.jpg,http://dropshipping.bigbuy.eu/imgs/V1300136_91201.jpg,http://dropshipping.bigbuy.eu/imgs/V1300136_91200.jpg,http://dropshipping.bigbuy.eu/imgs/V1300136_91199.jpg","GTIN=8427934824359",,,,,,,,,, +BB-V1300137,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Ombrelli e cappellini per bambini",base,"Cappello per Bambini Batman vs Superman Grigio","<a id=""maggiorni_informazioni"" title=""Cappello per Bambini Batman vs Superman""><p>A</a> quale bambino non piace vantarsi dei suoi <strong>accessori moda</strong>? Sorprendi i più piccoli con il <strong>cappello per bambini Batman vs Superman</strong><strong> </strong>e quest'estate fai sì che siano ben protetti dai raggi solari. Taglia 52-54 cm. Dimensioni della visiera: 15 x 6,5 cm circa. Composizione: 65 % cotone e 35 % poliestere.</p><p> Dimenzioni per Cappello per Bambini Batman vs Superman: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 19 Cm</li><li>Profondita': 20 Cm</li><li>Peso: 0.048 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934824335</p>","A quale bambino non piace vantarsi dei suoi accessori moda? Sorprendi i più piccoli con il cappello per bambini Batman vs Superman e quest'estate fai sì che siano ben protetti dai raggi solari.</br><a href=""#maggiorni_informazioni"" title=""Cappello per Bambini Batman vs Superman""> Maggiori Informazioni</a>",0.048,1,"Taxable Goods","Catalog, Search",12.5,,,,Cappello-per-Bambini-Batman-vs-Superman-Grigio,"Cappello per Bambini Batman vs Superman Grigio","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Ombrelli e cappellini per bambini,Ombrelli,cappellini,bambini,Colore Grigio,Grigio,","A quale bambino non piace vantarsi dei suoi accessori moda? Sorprendi i più piccoli con il cappello per bambini Batman vs Superman e quest'estate fai sì che siano ben protetti dai raggi solari",http://dropshipping.bigbuy.eu/imgs/V1300136_91231.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300136_91231.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934824335",102,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300136_91230.jpg,http://dropshipping.bigbuy.eu/imgs/V1300136_91201.jpg,http://dropshipping.bigbuy.eu/imgs/V1300136_91200.jpg,http://dropshipping.bigbuy.eu/imgs/V1300136_91199.jpg","GTIN=8427934824335",,,,,,,,,, +BB-V1300125,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Ombrelli e cappellini per bambini",base,"Berretto per Bambini Avengers Rosso","<a id=""maggiorni_informazioni"" title=""Berretto per Bambini Avengers""><p>A</a> quale bambino non piace mostrare gli <strong>accessori di moda</strong>? Sorprendili con il<strong> </strong><strong>berretto per bambini Avengers</strong> e proteggili dai raggi del sole di questa estate. Taglia 52-54 cm. Dimensioni della visiera: 15 x 6,5 cm circa. Composizione: 65% cotone e 35% poliestere.</p><p> Dimenzioni per Berretto per Bambini Avengers: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 20 Cm</li><li>Profondita': 18 Cm</li><li>Peso: 0.048 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934792252</p>","A quale bambino non piace mostrare gli accessori di moda? Sorprendili con il berretto per bambini Avengers e proteggili dai raggi del sole di questa estate.</br><a href=""#maggiorni_informazioni"" title=""Berretto per Bambini Avengers""> Maggiori Informazioni</a>",0.048,1,"Taxable Goods","Catalog, Search",10.9,,,,Berretto-per-Bambini-Avengers-Rosso,"Berretto per Bambini Avengers Rosso","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Ombrelli e cappellini per bambini,Ombrelli,cappellini,bambini,Colore Rosso,Rosso,","A quale bambino non piace mostrare gli accessori di moda? Sorprendili con il berretto per bambini Avengers e proteggili dai raggi del sole di questa estate",http://dropshipping.bigbuy.eu/imgs/V1300124_91179.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300124_91179.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934792252",101,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300124_91180.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91178.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91177.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91176.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91175.jpg","GTIN=8427934792252",,,,,,,,,, +BB-V1300126,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Ombrelli e cappellini per bambini",base,"Berretto per Bambini Avengers Nero","<a id=""maggiorni_informazioni"" title=""Berretto per Bambini Avengers""><p>A</a> quale bambino non piace mostrare gli <strong>accessori di moda</strong>? Sorprendili con il<strong> </strong><strong>berretto per bambini Avengers</strong> e proteggili dai raggi del sole di questa estate. Taglia 52-54 cm. Dimensioni della visiera: 15 x 6,5 cm circa. Composizione: 65% cotone e 35% poliestere.</p><p> Dimenzioni per Berretto per Bambini Avengers: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 20 Cm</li><li>Profondita': 18 Cm</li><li>Peso: 0.048 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934792269</p>","A quale bambino non piace mostrare gli accessori di moda? Sorprendili con il berretto per bambini Avengers e proteggili dai raggi del sole di questa estate.</br><a href=""#maggiorni_informazioni"" title=""Berretto per Bambini Avengers""> Maggiori Informazioni</a>",0.048,1,"Taxable Goods","Catalog, Search",10.9,,,,Berretto-per-Bambini-Avengers-Nero,"Berretto per Bambini Avengers Nero","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Ombrelli e cappellini per bambini,Ombrelli,cappellini,bambini,Colore Nero,Nero,","A quale bambino non piace mostrare gli accessori di moda? Sorprendili con il berretto per bambini Avengers e proteggili dai raggi del sole di questa estate",http://dropshipping.bigbuy.eu/imgs/V1300124_91180.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300124_91180.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934792269",92,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300124_91179.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91178.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91177.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91176.jpg,http://dropshipping.bigbuy.eu/imgs/V1300124_91175.jpg","GTIN=8427934792269",,,,,,,,,, +BB-V0500179,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Stoviglie per bambini",base,"Posate Bambini Disney (5 pezzi) Minnie","<a id=""maggiorni_informazioni"" title=""Posate Bambini Disney (5 pezzi)""><p>Quando</a> i piccoli di casa hanno già familiarizzato con il fatto di mangiare da soli, bisogna fare il passo successivo e comprare loro il <strong>set per mangiare </strong>da grandi, come le <strong>posate per bambini Disney (5 pezzi). </strong>Include: 1 piatto fondo, 1 piatto piano, 1 cucchiaio, 1 forchetta e 1 tazza.</p><ul><li>Età raccomandata: +12 mesi</li><li>Realizzato in polipropilene (senza BPA)</li><li>Adatto a lavastoviglie e microonde</li><li>Dimensioni del piatto fondo (diametro x altura): 15,5 x 3 cm circa</li><li>Dimensioni del piatto piano (diametro x altura): 22 x 1,5 cm circa</li><li>Lunghezza del cucchiaio: 15,5 cm circa</li><li>Lunghezza della forchetta: 15,5 cm circa</li><li>Dimensioni della tazza: 11 x 8,5 x 8,5 cm circa</li><li>Capacità della tazza: circa 250 ml</li><li>Conforme alla normativa UNE-EN 14372 (requisiti di sicurezza per gli articoli di puericultura per l'alimentazione: servizio di posate e utensili)</li><li>Conforme alla normativa UNE-EN 14350 (requisiti di sicurezza per gli articoli di puericultura per l'alimentazione liquida)</li></ul><p> Dimenzioni per Posate Bambini Disney (5 pezzi): </br><ul><li>Altezza: 27.5 Cm</li><li>Larghezza: 9.7 Cm</li><li>Profondita': 9 Cm</li><li>Peso: 0.487 Kg</li></ul></p><p>Codice Prodotto (EAN): 3662332013348</p>","Quando i piccoli di casa hanno già familiarizzato con il fatto di mangiare da soli, bisogna fare il passo successivo e comprare loro il set per mangiare da grandi, come le posate per bambini Disney (5 pezzi).</br><a href=""#maggiorni_informazioni"" title=""Posate Bambini Disney (5 pezzi)""> Maggiori Informazioni</a>",0.487,1,"Taxable Goods","Catalog, Search",41.5,,,,Posate-Bambini-Disney-(5-pezzi)-Minnie,"Posate Bambini Disney (5 pezzi) Minnie","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Stoviglie per bambini,Stoviglie,bambini,Modello Minnie,Minnie,","Quando i piccoli di casa hanno già familiarizzato con il fatto di mangiare da soli, bisogna fare il passo successivo e comprare loro il set per mangiare da grandi, come le posate per bambini Disney (5 pezzi)",http://dropshipping.bigbuy.eu/imgs/V0500178_92050.jpg,,http://dropshipping.bigbuy.eu/imgs/V0500178_92050.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3662332013348",34,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0500178_92051.jpg,http://dropshipping.bigbuy.eu/imgs/V0500178_92049.jpg","GTIN=3662332013348",,,,,,,,,, +BB-V0500180,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Stoviglie per bambini",base,"Posate Bambini Disney (5 pezzi) Mickey","<a id=""maggiorni_informazioni"" title=""Posate Bambini Disney (5 pezzi)""><p>Quando</a> i piccoli di casa hanno già familiarizzato con il fatto di mangiare da soli, bisogna fare il passo successivo e comprare loro il <strong>set per mangiare </strong>da grandi, come le <strong>posate per bambini Disney (5 pezzi). </strong>Include: 1 piatto fondo, 1 piatto piano, 1 cucchiaio, 1 forchetta e 1 tazza.</p><ul><li>Età raccomandata: +12 mesi</li><li>Realizzato in polipropilene (senza BPA)</li><li>Adatto a lavastoviglie e microonde</li><li>Dimensioni del piatto fondo (diametro x altura): 15,5 x 3 cm circa</li><li>Dimensioni del piatto piano (diametro x altura): 22 x 1,5 cm circa</li><li>Lunghezza del cucchiaio: 15,5 cm circa</li><li>Lunghezza della forchetta: 15,5 cm circa</li><li>Dimensioni della tazza: 11 x 8,5 x 8,5 cm circa</li><li>Capacità della tazza: circa 250 ml</li><li>Conforme alla normativa UNE-EN 14372 (requisiti di sicurezza per gli articoli di puericultura per l'alimentazione: servizio di posate e utensili)</li><li>Conforme alla normativa UNE-EN 14350 (requisiti di sicurezza per gli articoli di puericultura per l'alimentazione liquida)</li></ul><p> Dimenzioni per Posate Bambini Disney (5 pezzi): </br><ul><li>Altezza: 27.5 Cm</li><li>Larghezza: 9.7 Cm</li><li>Profondita': 9 Cm</li><li>Peso: 0.487 Kg</li></ul></p><p>Codice Prodotto (EAN): 3662332013355</p>","Quando i piccoli di casa hanno già familiarizzato con il fatto di mangiare da soli, bisogna fare il passo successivo e comprare loro il set per mangiare da grandi, come le posate per bambini Disney (5 pezzi).</br><a href=""#maggiorni_informazioni"" title=""Posate Bambini Disney (5 pezzi)""> Maggiori Informazioni</a>",0.487,1,"Taxable Goods","Catalog, Search",41.5,,,,Posate-Bambini-Disney-(5-pezzi)-Mickey,"Posate Bambini Disney (5 pezzi) Mickey","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Stoviglie per bambini,Stoviglie,bambini,Modello Mickey,Mickey,","Quando i piccoli di casa hanno già familiarizzato con il fatto di mangiare da soli, bisogna fare il passo successivo e comprare loro il set per mangiare da grandi, come le posate per bambini Disney (5 pezzi)",http://dropshipping.bigbuy.eu/imgs/V0500178_92051.jpg,,http://dropshipping.bigbuy.eu/imgs/V0500178_92051.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3662332013355",37,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0500178_92050.jpg,http://dropshipping.bigbuy.eu/imgs/V0500178_92049.jpg","GTIN=3662332013355",,,,,,,,,, +BB-V1300179,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Ombrelli e cappellini per bambini",base,"Ombrello per Bambini Pieghevole Star Wars","<a id=""maggiorni_informazioni"" title=""Ombrello per Bambini Pieghevole Star Wars""><p>Ti</a> presentiamo l'ombrello più galattico del pianeta, l'<strong><strong>ombrello per bambini pieghevole Star Wars</strong></strong>! Perfetto come <strong>regalo per bambini.</strong></p><ul><li>Struttura: 75 % metallo, 25 % plastica</li><li>Cupola: 100 % poliestere</li><li>Lunghezza: circa 23-52 cm</li><li>Diametro: circa 85 cm</li><li>Custodia inclusa</li></ul><p> </p><p> Dimenzioni per Ombrello per Bambini Pieghevole Star Wars: </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 6.5 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.255 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000732739</p>","Ti presentiamo l'ombrello più galattico del pianeta, l'ombrello per bambini pieghevole Star Wars! Perfetto come regalo per bambini.</br><a href=""#maggiorni_informazioni"" title=""Ombrello per Bambini Pieghevole Star Wars""> Maggiori Informazioni</a>",0.255,1,"Taxable Goods","Catalog, Search",24.5,,,,Ombrello-per-Bambini-Pieghevole-Star-Wars,"Ombrello per Bambini Pieghevole Star Wars","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Ombrelli e cappellini per bambini,Ombrelli,cappellini,bambini,","Ti presentiamo l'ombrello più galattico del pianeta, l'ombrello per bambini pieghevole Star Wars! Perfetto come regalo per bambini",http://dropshipping.bigbuy.eu/imgs/V1300179_101280.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300179_101280.jpg,,,,,,"2016-08-29 12:58:30",,,,,,,,,,,,,,,,,"GTIN=7569000732739",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300179_101281.jpg,http://dropshipping.bigbuy.eu/imgs/V1300179_101279.jpg,http://dropshipping.bigbuy.eu/imgs/V1300179_101278.jpg,http://dropshipping.bigbuy.eu/imgs/V1300179_101277.jpg","GTIN=7569000732739",,,,,,,,,, +BB-V1300195,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Passeggiate e viaggi",base,"Borsa Termica Porta Merenda Rubble (PAW Patrol)","<a id=""maggiorni_informazioni"" title=""Borsa Termica Porta Merenda Rubble (PAW Patrol)""><p>Ti</a> presentiamo la <strong><strong>borsa termica porta merende Rubble (PAW Patrol)</strong></strong>! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla. Ideale per portare con sé il pranzo e la merenda.</p><ul><li>Dimensioni: circa 23 x 19 x 8 cm</li><li>Composizione: poliestere, schiuma di poliuretano e PEVA (polietilene di acetato di vinilo)</li></ul><p> Dimenzioni per Borsa Termica Porta Merenda Rubble (PAW Patrol): </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 22 Cm</li><li>Profondita': 26 Cm</li><li>Peso: 0.196 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000732890</p>","Ti presentiamo la borsa termica porta merende Rubble (PAW Patrol)! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla.</br><a href=""#maggiorni_informazioni"" title=""Borsa Termica Porta Merenda Rubble (PAW Patrol)""> Maggiori Informazioni</a>",0.196,1,"Taxable Goods","Catalog, Search",26.9,,,,Borsa-Termica-Porta-Merenda-Rubble-(PAW-Patrol),"Borsa Termica Porta Merenda Rubble (PAW Patrol)","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Passeggiate e viaggi,Passeggiate,viaggi,","Ti presentiamo la borsa termica porta merende Rubble (PAW Patrol)! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla",http://dropshipping.bigbuy.eu/imgs/V1300195_101259.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300195_101259.jpg,,,,,,"2016-08-26 13:36:55",,,,,,,,,,,,,,,,,"GTIN=7569000732890",59,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300195_101258.jpg,http://dropshipping.bigbuy.eu/imgs/V1300195_101257.jpg,http://dropshipping.bigbuy.eu/imgs/V1300195_101256.jpg","GTIN=7569000732890",,,,,,,,,, +BB-V1300196,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Passeggiate e viaggi",base,"Borsa Termica Porta Merenda Everest (PAW Patrol)","<a id=""maggiorni_informazioni"" title=""Borsa Termica Porta Merenda Everest (PAW Patrol)""><p>Scopri</a> la<strong> <strong>borsa termica porta merenda Everest (PAW Patrol)</strong></strong> che sta facendo furore tra i bambini! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla. Perfetta per portare con sé il pranzo e la merenda.</p><ul><li>Dimensioni: circa 23 x 19 x 8 cm</li><li>Composizione: poliestere, schiuma di poliuretano e PEVA (polietilene di acetato di vinilo)</li></ul><p> Dimenzioni per Borsa Termica Porta Merenda Everest (PAW Patrol): </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 22 Cm</li><li>Profondita': 26 Cm</li><li>Peso: 0.196 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000732906</p>","Scopri la borsa termica porta merenda Everest (PAW Patrol) che sta facendo furore tra i bambini! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla.</br><a href=""#maggiorni_informazioni"" title=""Borsa Termica Porta Merenda Everest (PAW Patrol)""> Maggiori Informazioni</a>",0.196,1,"Taxable Goods","Catalog, Search",26.9,,,,Borsa-Termica-Porta-Merenda-Everest-(PAW-Patrol),"Borsa Termica Porta Merenda Everest (PAW Patrol)","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Passeggiate e viaggi,Passeggiate,viaggi,","Scopri la borsa termica porta merenda Everest (PAW Patrol) che sta facendo furore tra i bambini! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla",http://dropshipping.bigbuy.eu/imgs/V1300196_101260.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300196_101260.jpg,,,,,,"2016-08-26 13:36:36",,,,,,,,,,,,,,,,,"GTIN=7569000732906",51,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300196_101263.jpg,http://dropshipping.bigbuy.eu/imgs/V1300196_101262.jpg,http://dropshipping.bigbuy.eu/imgs/V1300196_101261.jpg","GTIN=7569000732906",,,,,,,,,, +BB-V1300198,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Neonati e Bambini,Default Category/Giochi Bambini/Neonati e Bambini/Passeggiate e viaggi",base,"Borsa Termica Porta Merenda Frozen","<a id=""maggiorni_informazioni"" title=""Borsa Termica Porta Merenda Frozen""><p>Tutte</a> le bambine vogliono subito la <strong><strong><strong>borsa termica porta merenda</strong> Frozen</strong></strong>! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla. Perfetta per portare con sé il pranzo e la merenda.</p><ul><li>Dimensioni: circa 23 x 19 x 8 cm</li><li>Composizione: poliestere, schiuma di poliuretano e PEVA (polietilene di acetato di vinilo)</li></ul><p> Dimenzioni per Borsa Termica Porta Merenda Frozen: </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 22 Cm</li><li>Profondita': 26 Cm</li><li>Peso: 0.196 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000732920</p>","Tutte le bambine vogliono subito la borsa termica porta merenda Frozen! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla.</br><a href=""#maggiorni_informazioni"" title=""Borsa Termica Porta Merenda Frozen""> Maggiori Informazioni</a>",0.196,1,"Taxable Goods","Catalog, Search",26.9,,,,Borsa-Termica-Porta-Merenda-Frozen,"Borsa Termica Porta Merenda Frozen","Giochi Bambini,Giochi,Bambini,Neonati e Bambini,Neonati,Bambini,Passeggiate e viaggi,Passeggiate,viaggi,","Tutte le bambine vogliono subito la borsa termica porta merenda Frozen! Dispone di un disegno in rilievo nella parte frontale (gomma EVA), cerniera, manico e due cinte regolabili per appenderla",http://dropshipping.bigbuy.eu/imgs/V1300198_101268.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300198_101268.jpg,,,,,,"2016-08-30 07:32:17",,,,,,,,,,,,,,,,,"GTIN=7569000732920",52,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300198_101271.jpg,http://dropshipping.bigbuy.eu/imgs/V1300198_101270.jpg,http://dropshipping.bigbuy.eu/imgs/V1300198_101269.jpg","GTIN=7569000732920",,,,,,,,,, +BB-V1300204,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Materiale Scolastico,Default Category/Giochi Bambini/Materiale Scolastico/Astucci e portapenne",base,"Astuccio Scuola 3D Frozen","<a id=""maggiorni_informazioni"" title=""Astuccio Scuola 3D Frozen""><p>Le</a> piccola fan delle principesse Anna ed Elsa non possono tornare a scuola senza l'<strong>astuccio scuola</strong> <strong>3D Frozen</strong>!</p><ul><li>Cinque scompartimenti separati</li><li>Due cerniere</li><li>Dimensioni: 21,5 x 12 x 10 cm circa</li><li>Composizione: poliestere</li></ul><p> Dimenzioni per Astuccio Scuola 3D Frozen: </br><ul><li>Altezza: 2 Cm</li><li>Larghezza: 24 Cm</li><li>Profondita': 14 Cm</li><li>Peso: 0.099 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000733248</p>","Le piccola fan delle principesse Anna ed Elsa non possono tornare a scuola senza l'astuccio scuola 3D Frozen!Cinque scompartimenti separatiDue cerniereDimensioni: 21,5 x 12 x 10 cm circaComposizione: poliestere.</br><a href=""#maggiorni_informazioni"" title=""Astuccio Scuola 3D Frozen""> Maggiori Informazioni</a>",0.099,1,"Taxable Goods","Catalog, Search",19.9,,,,Astuccio-Scuola-3D-Frozen,"Astuccio Scuola 3D Frozen","Giochi Bambini,Giochi,Bambini,Materiale Scolastico,Materiale,Scolastico,Astucci e portapenne,Astucci,portapenne,","Le piccola fan delle principesse Anna ed Elsa non possono tornare a scuola senza l'astuccio scuola 3D Frozen!Cinque scompartimenti separatiDue cerniereDimensioni: 21,5 x 12 x 10 cm circaComposizione: poliestere",http://dropshipping.bigbuy.eu/imgs/V1300204_102661.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300204_102661.jpg,,,,,,"2016-09-14 07:51:11",,,,,,,,,,,,,,,,,"GTIN=7569000733248",138,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300204_102663.jpg,http://dropshipping.bigbuy.eu/imgs/V1300204_102662.jpg","GTIN=7569000733248",,,,,,,,,, +BB-V1300208,,Default,simple,"Default Category/Giochi Bambini,Default Category/Giochi Bambini/Materiale Scolastico,Default Category/Giochi Bambini/Materiale Scolastico/Zaini scuola",base,"Zaino-Sacca Frozen","<a id=""maggiorni_informazioni"" title=""Zaino-Sacca Frozen""><p>Lo</a> <strong>zaino-sacca Frozen </strong>è lo zaino che sta facendo furore tra le bambine!</p><ul><li>Realizzato in poliestere</li><li>Manico superiore e cinghie con velcro</li><li>Tasca frontale con cerniera</li><li>Dimensioni: circa 33 x 44 cm</li></ul><p> </p><p> Dimenzioni per Zaino-Sacca Frozen: </br><ul><li>Altezza: 0.5 Cm</li><li>Larghezza: 37 Cm</li><li>Profondita': 46 Cm</li><li>Peso: 0.138 Kg</li></ul></p><p>Codice Prodotto (EAN): 7569000733286</p>","Lo zaino-sacca Frozen è lo zaino che sta facendo furore tra le bambine!Realizzato in poliestereManico superiore e cinghie con velcroTasca frontale con cernieraDimensioni: circa 33 x 44 cm .</br><a href=""#maggiorni_informazioni"" title=""Zaino-Sacca Frozen""> Maggiori Informazioni</a>",0.138,1,"Taxable Goods","Catalog, Search",24.9,,,,Zaino-Sacca-Frozen,"Zaino-Sacca Frozen","Giochi Bambini,Giochi,Bambini,Materiale Scolastico,Materiale,Scolastico,Zaini scuola,Zaini,scuola,","Lo zaino-sacca Frozen è lo zaino che sta facendo furore tra le bambine!Realizzato in poliestereManico superiore e cinghie con velcroTasca frontale con cernieraDimensioni: circa 33 x 44 cm ",http://dropshipping.bigbuy.eu/imgs/V1300208_102667.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300208_102667.jpg,,,,,,"2016-09-14 07:50:58",,,,,,,,,,,,,,,,,"GTIN=7569000733286",141,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300208_102669.jpg,http://dropshipping.bigbuy.eu/imgs/V1300208_102668.jpg","GTIN=7569000733286",,,,,,,,,, +BB-I4115041,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cover e custodie",base,"Custodia Impermeabile per Cellulare WpShield Azzurro","<a id=""maggiorni_informazioni"" title=""Custodia Impermeabile per Cellulare WpShield ""><p>Sei</a> una di quelle persone che portano il loro smartphone ovunque? Se desideri che il tuo telefono venga protetto dalla sporcizia, sabbia, graffi e anche dall'acqua, non perderti la <strong>custodia impermeabile per cellulare WpShield</strong>. Con questa custodia impermeabile potrai portare il telefono ovunque ti piaccia, anche per un tuffo in piscina o sul mare.<br /><br /><a href=""http://www.waterproofshield.com/""><strong>www.waterproofshield.com</strong></a><br /><br />Questa custodia impermeabile dispone sia di un cinturino con chiusura a velcro (lunghezza massima: circa 37 cm) e un cavo con chiusura di sicurezza da indossare al collo (lunghezza massima: circa 60 cm). Composizione: PVC (Spessore: circa 3 mm). Dimensioni: circa 10,5 x 15,5 cm.</p><p> Dimenzioni per Custodia Impermeabile per Cellulare WpShield : </br><ul><li>Altezza: 20 Cm</li><li>Larghezza: 11.1 Cm</li><li>Profondita': 1.9 Cm</li><li>Peso: 0.06 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888106722</p>","Sei una di quelle persone che portano il loro smartphone ovunque? Se desideri che il tuo telefono venga protetto dalla sporcizia, sabbia, graffi e anche dall'acqua, non perderti la custodia impermeabile per cellulare WpShield.</br><a href=""#maggiorni_informazioni"" title=""Custodia Impermeabile per Cellulare WpShield ""> Maggiori Informazioni</a>",0.06,1,"Taxable Goods","Catalog, Search",14.5,,,,Custodia-Impermeabile-per-Cellulare-WpShield-Azzurro,"Custodia Impermeabile per Cellulare WpShield Azzurro","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cover e custodie,Cover,custodie,Colore Azzurro,Azzurro,","Sei una di quelle persone che portano il loro smartphone ovunque? Se desideri che il tuo telefono venga protetto dalla sporcizia, sabbia, graffi e anche dall'acqua, non perderti la custodia impermeabile per cellulare WpShield",http://dropshipping.bigbuy.eu/imgs/I4115040_78768.jpg,,http://dropshipping.bigbuy.eu/imgs/I4115040_78768.jpg,,,,,,"2015-12-21 12:09:49",,,,,,,,,,,,,,,,,"GTIN=4899888106722",4125,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I4115040_78770.jpg,http://dropshipping.bigbuy.eu/imgs/I4115040_78769.jpg,http://dropshipping.bigbuy.eu/imgs/I4115040_78767.jpg","GTIN=4899888106722",,,,,,,,,, +BB-I4115042,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cover e custodie",base,"Custodia Impermeabile per Cellulare WpShield Bianco","<a id=""maggiorni_informazioni"" title=""Custodia Impermeabile per Cellulare WpShield ""><p>Sei</a> una di quelle persone che portano il loro smartphone ovunque? Se desideri che il tuo telefono venga protetto dalla sporcizia, sabbia, graffi e anche dall'acqua, non perderti la <strong>custodia impermeabile per cellulare WpShield</strong>. Con questa custodia impermeabile potrai portare il telefono ovunque ti piaccia, anche per un tuffo in piscina o sul mare.<br /><br /><a href=""http://www.waterproofshield.com/""><strong>www.waterproofshield.com</strong></a><br /><br />Questa custodia impermeabile dispone sia di un cinturino con chiusura a velcro (lunghezza massima: circa 37 cm) e un cavo con chiusura di sicurezza da indossare al collo (lunghezza massima: circa 60 cm). Composizione: PVC (Spessore: circa 3 mm). Dimensioni: circa 10,5 x 15,5 cm.</p><p> Dimenzioni per Custodia Impermeabile per Cellulare WpShield : </br><ul><li>Altezza: 20 Cm</li><li>Larghezza: 11.1 Cm</li><li>Profondita': 1.9 Cm</li><li>Peso: 0.06 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888106739</p>","Sei una di quelle persone che portano il loro smartphone ovunque? Se desideri che il tuo telefono venga protetto dalla sporcizia, sabbia, graffi e anche dall'acqua, non perderti la custodia impermeabile per cellulare WpShield.</br><a href=""#maggiorni_informazioni"" title=""Custodia Impermeabile per Cellulare WpShield ""> Maggiori Informazioni</a>",0.06,1,"Taxable Goods","Catalog, Search",14.5,,,,Custodia-Impermeabile-per-Cellulare-WpShield-Bianco,"Custodia Impermeabile per Cellulare WpShield Bianco","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cover e custodie,Cover,custodie,Colore Bianco,Bianco,","Sei una di quelle persone che portano il loro smartphone ovunque? Se desideri che il tuo telefono venga protetto dalla sporcizia, sabbia, graffi e anche dall'acqua, non perderti la custodia impermeabile per cellulare WpShield",http://dropshipping.bigbuy.eu/imgs/I4115040_78770.jpg,,http://dropshipping.bigbuy.eu/imgs/I4115040_78770.jpg,,,,,,"2015-12-21 12:10:05",,,,,,,,,,,,,,,,,"GTIN=4899888106739",4321,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I4115040_78768.jpg,http://dropshipping.bigbuy.eu/imgs/I4115040_78769.jpg,http://dropshipping.bigbuy.eu/imgs/I4115040_78767.jpg","GTIN=4899888106739",,,,,,,,,, +BB-I4115044,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Batterie, Caricatori, Adattatori",base,"Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken Bianco","<a id=""maggiorni_informazioni"" title=""Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken ""><p>Non</a> uscire di casa senza la <strong>doppia porta USB con presa elettrica e caricabatteria da auto Pocken</strong> per auto! Puoi connetterla in auto o alla rete elettrica per ricaricare i dispositivi mobili, poichè include sia un adattatore per auto che una spina. Questo caricatore con doppio ingresso USB è molto pratico e semplice da usare. Dimensioni approssimative: 6 x 6 x 3,5 cm.</p><p><strong><a href=""http://www.pockenrg.com"">www.pockenrg.com</a>  </strong></p><div><div>Caratteristiche tecniche:</div><ul><li>Ingresso AC: 100-240 V / 0.15 A / 50-60 Hz</li><li>Ingresso DC: 12-24 V / 0.35 A</li><li>Uscita DC: +5  V / 1 A</li></ul></div><p> Dimenzioni per Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken : </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 6.5 Cm</li><li>Profondita': 6.5 Cm</li><li>Peso: 0.077 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888106944</p>","Non uscire di casa senza la doppia porta USB con presa elettrica e caricabatteria da auto Pocken per auto! Puoi connetterla in auto o alla rete elettrica per ricaricare i dispositivi mobili, poichè include sia un adattatore per auto che una spina.</br><a href=""#maggiorni_informazioni"" title=""Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken ""> Maggiori Informazioni</a>",0.077,1,"Taxable Goods","Catalog, Search",29.99,,,,Doppia-Porta-USB-con-Presa-Elettrica-e-Caricabatteria-da-Auto-Pocken-Bianco,"Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken Bianco","Informatica Elettronica,Informatica,Elettronica,Batterie, Caricatori, Adattatori,Batterie,,Caricatori,,Adattatori,Colore Bianco,Bianco,","Non uscire di casa senza la doppia porta USB con presa elettrica e caricabatteria da auto Pocken per auto! Puoi connetterla in auto o alla rete elettrica per ricaricare i dispositivi mobili, poichè include sia un adattatore per auto che una spina",http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_0002.jpg,,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_0002.jpg,,,,,,"2015-05-14 07:58:09",,,,,,,,,,,,,,,,,"GTIN=4899888106944",161,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_00.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_08.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_04.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_004.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_0004.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_03.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_0003.jpg","GTIN=4899888106944",,,,,,,,,, +BB-I4115045,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Batterie, Caricatori, Adattatori",base,"Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken Nero","<a id=""maggiorni_informazioni"" title=""Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken ""><p>Non</a> uscire di casa senza la <strong>doppia porta USB con presa elettrica e caricabatteria da auto Pocken</strong> per auto! Puoi connetterla in auto o alla rete elettrica per ricaricare i dispositivi mobili, poichè include sia un adattatore per auto che una spina. Questo caricatore con doppio ingresso USB è molto pratico e semplice da usare. Dimensioni approssimative: 6 x 6 x 3,5 cm.</p><p><strong><a href=""http://www.pockenrg.com"">www.pockenrg.com</a>  </strong></p><div><div>Caratteristiche tecniche:</div><ul><li>Ingresso AC: 100-240 V / 0.15 A / 50-60 Hz</li><li>Ingresso DC: 12-24 V / 0.35 A</li><li>Uscita DC: +5  V / 1 A</li></ul></div><p> Dimenzioni per Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken : </br><ul><li>Altezza: 4 Cm</li><li>Larghezza: 6.5 Cm</li><li>Profondita': 6.5 Cm</li><li>Peso: 0.077 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888106678</p>","Non uscire di casa senza la doppia porta USB con presa elettrica e caricabatteria da auto Pocken per auto! Puoi connetterla in auto o alla rete elettrica per ricaricare i dispositivi mobili, poichè include sia un adattatore per auto che una spina.</br><a href=""#maggiorni_informazioni"" title=""Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken ""> Maggiori Informazioni</a>",0.077,1,"Taxable Goods","Catalog, Search",29.99,,,,Doppia-Porta-USB-con-Presa-Elettrica-e-Caricabatteria-da-Auto-Pocken-Nero,"Doppia Porta USB con Presa Elettrica e Caricabatteria da Auto Pocken Nero","Informatica Elettronica,Informatica,Elettronica,Batterie, Caricatori, Adattatori,Batterie,,Caricatori,,Adattatori,Colore Nero,Nero,","Non uscire di casa senza la doppia porta USB con presa elettrica e caricabatteria da auto Pocken per auto! Puoi connetterla in auto o alla rete elettrica per ricaricare i dispositivi mobili, poichè include sia un adattatore per auto che una spina",http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_00.jpg,,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_00.jpg,,,,,,"2015-08-18 12:37:09",,,,,,,,,,,,,,,,,"GTIN=4899888106678",265,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_0002.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_08.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_04.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_004.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_0004.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_03.jpg,http://dropshipping.bigbuy.eu/imgs/cargador_usb_doble_coche_0003.jpg","GTIN=4899888106678",,,,,,,,,, +BB-I3505259,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Cuffie",base,"Auricolari da Corsa GoFit Bianco","<a id=""maggiorni_informazioni"" title=""Auricolari da Corsa GoFit""><p>Fai</a> sport mentre ascolti la musica o parli al telefono indossando questi<strong> auricolari da corsa</strong>! Questi <strong>auricolari sportivi GoFit</strong> sono molto comodi e pratici, si adattano perfettamente al tuo orecchio per una migliore aderenza. Progettate specificamente per gli altleti. Con materiali speciali e ottima qualità sonora. Caratteristiche:</p><ul><li>Suono: stereo</li><li>Connessione audio: cavo con uscita 3,5 mm</li><li>Microfono integrato</li><li>Pulsante di risposta alla chiamata</li><li>Risposta in frequenza: 20-20000 Hz</li><li>Intensità del suono: 93 dB</li><li>Impedenza dello speaker: 32 Ω</li><li>Adatto agli iPhone, smartphone e telefoni cellulari</li></ul><p> Dimenzioni per Auricolari da Corsa GoFit: </br><ul><li>Altezza: 20 Cm</li><li>Larghezza: 9 Cm</li><li>Profondita': 2.7 Cm</li><li>Peso: 0.079 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417204005</p>","Fai sport mentre ascolti la musica o parli al telefono indossando questi auricolari da corsa! Questi auricolari sportivi GoFit sono molto comodi e pratici, si adattano perfettamente al tuo orecchio per una migliore aderenza.</br><a href=""#maggiorni_informazioni"" title=""Auricolari da Corsa GoFit""> Maggiori Informazioni</a>",0.079,1,"Taxable Goods","Catalog, Search",38.9,,,,Auricolari-da-Corsa-GoFit-Bianco,"Auricolari da Corsa GoFit Bianco","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Cuffie,Colore Bianco,Bianco,","Fai sport mentre ascolti la musica o parli al telefono indossando questi auricolari da corsa! Questi auricolari sportivi GoFit sono molto comodi e pratici, si adattano perfettamente al tuo orecchio per una migliore aderenza",http://dropshipping.bigbuy.eu/imgs/I3505223_80641.jpg,,http://dropshipping.bigbuy.eu/imgs/I3505223_80641.jpg,,,,,,"2015-09-23 10:52:34",,,,,,,,,,,,,,,,,"GTIN=8018417204005",21,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I3505223_80644.jpg,http://dropshipping.bigbuy.eu/imgs/I3505223_80643.jpg,http://dropshipping.bigbuy.eu/imgs/I3505223_80642.jpg,http://dropshipping.bigbuy.eu/imgs/I3505223_80640.jpg","GTIN=8018417204005",,,,,,,,,, +BB-I3505260,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Cuffie",base,"Auricolari da Corsa GoFit Arancio","<a id=""maggiorni_informazioni"" title=""Auricolari da Corsa GoFit""><p>Fai</a> sport mentre ascolti la musica o parli al telefono indossando questi<strong> auricolari da corsa</strong>! Questi <strong>auricolari sportivi GoFit</strong> sono molto comodi e pratici, si adattano perfettamente al tuo orecchio per una migliore aderenza. Progettate specificamente per gli altleti. Con materiali speciali e ottima qualità sonora. Caratteristiche:</p><ul><li>Suono: stereo</li><li>Connessione audio: cavo con uscita 3,5 mm</li><li>Microfono integrato</li><li>Pulsante di risposta alla chiamata</li><li>Risposta in frequenza: 20-20000 Hz</li><li>Intensità del suono: 93 dB</li><li>Impedenza dello speaker: 32 Ω</li><li>Adatto agli iPhone, smartphone e telefoni cellulari</li></ul><p> Dimenzioni per Auricolari da Corsa GoFit: </br><ul><li>Altezza: 20 Cm</li><li>Larghezza: 9 Cm</li><li>Profondita': 2.7 Cm</li><li>Peso: 0.079 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417209512</p>","Fai sport mentre ascolti la musica o parli al telefono indossando questi auricolari da corsa! Questi auricolari sportivi GoFit sono molto comodi e pratici, si adattano perfettamente al tuo orecchio per una migliore aderenza.</br><a href=""#maggiorni_informazioni"" title=""Auricolari da Corsa GoFit""> Maggiori Informazioni</a>",0.079,1,"Taxable Goods","Catalog, Search",38.9,,,,Auricolari-da-Corsa-GoFit-Arancio,"Auricolari da Corsa GoFit Arancio","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Cuffie,Colore Arancio,Arancio,","Fai sport mentre ascolti la musica o parli al telefono indossando questi auricolari da corsa! Questi auricolari sportivi GoFit sono molto comodi e pratici, si adattano perfettamente al tuo orecchio per una migliore aderenza",http://dropshipping.bigbuy.eu/imgs/I3505223_80644.jpg,,http://dropshipping.bigbuy.eu/imgs/I3505223_80644.jpg,,,,,,"2015-09-23 10:52:34",,,,,,,,,,,,,,,,,"GTIN=8018417209512",43,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I3505223_80641.jpg,http://dropshipping.bigbuy.eu/imgs/I3505223_80643.jpg,http://dropshipping.bigbuy.eu/imgs/I3505223_80642.jpg,http://dropshipping.bigbuy.eu/imgs/I3505223_80640.jpg","GTIN=8018417209512",,,,,,,,,, +BB-I3505248,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Casse",base,"Altoparlante Bluetooth Portatile AudioSonic SK1511 Azzurro","<a id=""maggiorni_informazioni"" title=""Altoparlante Bluetooth Portatile AudioSonic""><p>Se</a> adori la musica e la tecnologia, l'<strong>altoparlante Bluetooth portatile AudioSonic</strong> è l'ideale per te! Riesci ad immaginare di indossare questo <strong>altoparlante</strong> intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smartphone?<strong> </strong>Con questo prodotto versatile è possibile! Lunghezza della corda in silicone: circa 40 cm. Dimensioni: circa 5,5 x 7 x 1 cm. Raggio del bluetooth: circa 10 m.</p><p>Caratteristiche:</p><ul><li>Batteria ricaricabile Li-ion</li><li>Vita della batteria: 4 ore</li><li>Batteria 300 mAh</li><li>Microfono incorporato</li><li>Controllo a mani libere</li><li>Pulsanti di controllo</li><li>Porta micro USB porta: 5 V</li><li>Porta input Aux</li><li>Potenza: 3 W</li></ul><p>Include:</p><ul><li>Cavo USB + micro USB</li><li>Cavo dual jack da 3,5 mm</li></ul><p> Dimenzioni per Altoparlante Bluetooth Portatile AudioSonic: </br><ul><li>Altezza: 13 Cm</li><li>Larghezza: 8 Cm</li><li>Profondita': 4.6 Cm</li><li>Peso: 0.143 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016015112</p>","Se adori la musica e la tecnologia, l'altoparlante Bluetooth portatile AudioSonic è l'ideale per te! Riesci ad immaginare di indossare questo altoparlante intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smartphone? Con questo prodotto versatile è possibile! Lunghezza della corda in silicone: circa 40 cm.</br><a href=""#maggiorni_informazioni"" title=""Altoparlante Bluetooth Portatile AudioSonic""> Maggiori Informazioni</a>",0.143,1,"Taxable Goods","Catalog, Search",33.35,,,,Altoparlante-Bluetooth-Portatile-AudioSonic-SK1511 Azzurro,"Altoparlante Bluetooth Portatile AudioSonic SK1511 Azzurro","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Casse,Referenza e Colore SK1511 Azzurro,SK1511 Azzurro,","Se adori la musica e la tecnologia, l'altoparlante Bluetooth portatile AudioSonic è l'ideale per te! Riesci ad immaginare di indossare questo altoparlante intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smart",http://dropshipping.bigbuy.eu/imgs/altavoz_audiosonic_SK-1539_00.jpg,,http://dropshipping.bigbuy.eu/imgs/altavoz_audiosonic_SK-1539_00.jpg,,,,,,"2015-12-21 11:15:29",,,,,,,,,,,,,,,,,"GTIN=8713016015112",8,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_01.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz-cordón-SK-1511.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_004.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_02.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_0004.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_00.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_04.jpg","GTIN=8713016015112",,,,,,,,,, +BB-I3505249,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Casse",base,"Altoparlante Bluetooth Portatile AudioSonic SK1513 Rosa","<a id=""maggiorni_informazioni"" title=""Altoparlante Bluetooth Portatile AudioSonic""><p>Se</a> adori la musica e la tecnologia, l'<strong>altoparlante Bluetooth portatile AudioSonic</strong> è l'ideale per te! Riesci ad immaginare di indossare questo <strong>altoparlante</strong> intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smartphone?<strong> </strong>Con questo prodotto versatile è possibile! Lunghezza della corda in silicone: circa 40 cm. Dimensioni: circa 5,5 x 7 x 1 cm. Raggio del bluetooth: circa 10 m.</p><p>Caratteristiche:</p><ul><li>Batteria ricaricabile Li-ion</li><li>Vita della batteria: 4 ore</li><li>Batteria 300 mAh</li><li>Microfono incorporato</li><li>Controllo a mani libere</li><li>Pulsanti di controllo</li><li>Porta micro USB porta: 5 V</li><li>Porta input Aux</li><li>Potenza: 3 W</li></ul><p>Include:</p><ul><li>Cavo USB + micro USB</li><li>Cavo dual jack da 3,5 mm</li></ul><p> Dimenzioni per Altoparlante Bluetooth Portatile AudioSonic: </br><ul><li>Altezza: 13 Cm</li><li>Larghezza: 8 Cm</li><li>Profondita': 4.6 Cm</li><li>Peso: 0.143 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016000996</p>","Se adori la musica e la tecnologia, l'altoparlante Bluetooth portatile AudioSonic è l'ideale per te! Riesci ad immaginare di indossare questo altoparlante intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smartphone? Con questo prodotto versatile è possibile! Lunghezza della corda in silicone: circa 40 cm.</br><a href=""#maggiorni_informazioni"" title=""Altoparlante Bluetooth Portatile AudioSonic""> Maggiori Informazioni</a>",0.143,1,"Taxable Goods","Catalog, Search",33.35,,,,Altoparlante-Bluetooth-Portatile-AudioSonic-SK1513 Rosa,"Altoparlante Bluetooth Portatile AudioSonic SK1513 Rosa","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Casse,Referenza e Colore SK1513 Rosa,SK1513 Rosa,","Se adori la musica e la tecnologia, l'altoparlante Bluetooth portatile AudioSonic è l'ideale per te! Riesci ad immaginare di indossare questo altoparlante intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smart",http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_01.jpg,,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_01.jpg,,,,,,"2015-06-01 09:01:55",,,,,,,,,,,,,,,,,"GTIN=8713016000996",16,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/altavoz_audiosonic_SK-1539_00.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz-cordón-SK-1511.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_004.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_02.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_0004.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_00.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_04.jpg","GTIN=8713016000996",,,,,,,,,, +BB-I3505250,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Casse",base,"Altoparlante Bluetooth Portatile AudioSonic SK1512 Verde","<a id=""maggiorni_informazioni"" title=""Altoparlante Bluetooth Portatile AudioSonic""><p>Se</a> adori la musica e la tecnologia, l'<strong>altoparlante Bluetooth portatile AudioSonic</strong> è l'ideale per te! Riesci ad immaginare di indossare questo <strong>altoparlante</strong> intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smartphone?<strong> </strong>Con questo prodotto versatile è possibile! Lunghezza della corda in silicone: circa 40 cm. Dimensioni: circa 5,5 x 7 x 1 cm. Raggio del bluetooth: circa 10 m.</p><p>Caratteristiche:</p><ul><li>Batteria ricaricabile Li-ion</li><li>Vita della batteria: 4 ore</li><li>Batteria 300 mAh</li><li>Microfono incorporato</li><li>Controllo a mani libere</li><li>Pulsanti di controllo</li><li>Porta micro USB porta: 5 V</li><li>Porta input Aux</li><li>Potenza: 3 W</li></ul><p>Include:</p><ul><li>Cavo USB + micro USB</li><li>Cavo dual jack da 3,5 mm</li></ul><p> Dimenzioni per Altoparlante Bluetooth Portatile AudioSonic: </br><ul><li>Altezza: 13 Cm</li><li>Larghezza: 8 Cm</li><li>Profondita': 4.6 Cm</li><li>Peso: 0.143 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016000972</p>","Se adori la musica e la tecnologia, l'altoparlante Bluetooth portatile AudioSonic è l'ideale per te! Riesci ad immaginare di indossare questo altoparlante intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smartphone? Con questo prodotto versatile è possibile! Lunghezza della corda in silicone: circa 40 cm.</br><a href=""#maggiorni_informazioni"" title=""Altoparlante Bluetooth Portatile AudioSonic""> Maggiori Informazioni</a>",0.143,1,"Taxable Goods","Catalog, Search",33.35,,,,Altoparlante-Bluetooth-Portatile-AudioSonic-SK1512 Verde,"Altoparlante Bluetooth Portatile AudioSonic SK1512 Verde","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Casse,Referenza e Colore SK1512 Verde,SK1512 Verde,","Se adori la musica e la tecnologia, l'altoparlante Bluetooth portatile AudioSonic è l'ideale per te! Riesci ad immaginare di indossare questo altoparlante intorno al collo mentre ascolti la tua musica preferita e rispondi alle chiamate del tuo smart",http://dropshipping.bigbuy.eu/imgs/altavoz-cordón-SK-1511.jpg,,http://dropshipping.bigbuy.eu/imgs/altavoz-cordón-SK-1511.jpg,,,,,,"2016-02-01 10:38:01",,,,,,,,,,,,,,,,,"GTIN=8713016000972",22,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/altavoz_audiosonic_SK-1539_00.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_01.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_004.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_02.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_0004.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_00.jpg,http://dropshipping.bigbuy.eu/imgs/altavoz_SK-1511_04.jpg","GTIN=8713016000972",,,,,,,,,, +BB-I3505262,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Cuffie",base,"Auricolari da Corsa GoFit","<a id=""maggiorni_informazioni"" title=""Auricolari da Corsa GoFit""><p>Ora,</a> ascoltare la musica o fare una chiamata non saranno più scuse valide per non allenarsi, grazie agli <strong>auricolari da corsa GoFit</strong>! Sono davvero comodi e pratici <strong>auricolari </strong>che si adattano completamente all'orecchio, Speciale <strong>design Europeo</strong> per l'utilizzo in allenamento. Materiali e suono di alta qualità. Include una piccola custodia in tessuto per conservare gli auricolari.</p><p>Caratteristiche:</p><ul><li>Flessibili e resistenti all'acqua</li><li>Suono: stereo</li><li>Connessione audio: cavo jack 3,5 mm</li><li>Microfono incorporato</li><li>Pulsante di risposta e termine chiamata</li><li>Risposta di frequenza: 20-20000 Hz</li><li>Livello sonoro: 93 dB</li><li>Impedenza altoparlante: 32 Ω</li><li>Adatti all'uso con iPhone, smartphone e altri cellulari</li></ul><p> Dimenzioni per Auricolari da Corsa GoFit: </br><ul><li>Altezza: 20.5 Cm</li><li>Larghezza: 9 Cm</li><li>Profondita': 4 Cm</li><li>Peso: 0.102 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417208119</p>","Ora, ascoltare la musica o fare una chiamata non saranno più scuse valide per non allenarsi, grazie agli auricolari da corsa GoFit! Sono davvero comodi e pratici auricolari che si adattano completamente all'orecchio, Speciale design Europeo per l'utilizzo in allenamento.</br><a href=""#maggiorni_informazioni"" title=""Auricolari da Corsa GoFit""> Maggiori Informazioni</a>",0.102,1,"Taxable Goods","Catalog, Search",49.9,,,,Auricolari-da-Corsa-GoFit,"Auricolari da Corsa GoFit","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Cuffie,","Ora, ascoltare la musica o fare una chiamata non saranno più scuse valide per non allenarsi, grazie agli auricolari da corsa GoFit! Sono davvero comodi e pratici auricolari che si adattano completamente all'orecchio, Speciale design Europeo per l'ut",http://dropshipping.bigbuy.eu/imgs/I3505262_80647.jpg,,http://dropshipping.bigbuy.eu/imgs/I3505262_80647.jpg,,,,,,"2016-09-07 15:19:36",,,,,,,,,,,,,,,,,"GTIN=8018417208119",46,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I3505262_80646.jpg,http://dropshipping.bigbuy.eu/imgs/I3505262_80645.jpg","GTIN=8018417208119",,,,,,,,,, +BB-G0500185,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Cuffie",base,"Fascia Sportiva con Auricolari GoFit Verde","<a id=""maggiorni_informazioni"" title=""Fascia Sportiva con Auricolari GoFit""><p>Se</a> adori gli sport e ti piace tenerti informato con gli ultimi <strong>accessori sportivi</strong>, non puoi perderti questa ottima <strong>fascia sportiva con auricolari GoFit</strong>. Con questa fascia per la testa, potrai ascoltare i tuoi brani musicali preferiti mentre fai jogging, inoltre potrai rispondere alle chiamate, semplicemente premendo il pulsante di risposta e chiusura chiamate incorporato nella doppia connessione, con cavo audio jack da 3,5 mm (lunghezza: circa 1 m). Altoparlanti rimovibili per permetterti di lavare la fascia. Ampiezza: circa 9,5 cm. Diametro: circa 27 cm. Esterno 100% poliestere. Interno in microfibra polare.<br /><br />Caratteristiche:</p><ul><li>Sensibilità: 5 dB</li><li>Impedenza altoparlante: 32 Ω</li><li>Frequenza: 20 Hz-20 kHz</li><li>Adatto a smartphone e altri telefoni mobili</li></ul><p> Dimenzioni per Fascia Sportiva con Auricolari GoFit: </br><ul><li>Altezza: 20 Cm</li><li>Larghezza: 9 Cm</li><li>Profondita': 4.5 Cm</li><li>Peso: 0.102 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417209635</p>","Se adori gli sport e ti piace tenerti informato con gli ultimi accessori sportivi, non puoi perderti questa ottima fascia sportiva con auricolari GoFit.</br><a href=""#maggiorni_informazioni"" title=""Fascia Sportiva con Auricolari GoFit""> Maggiori Informazioni</a>",0.102,1,"Taxable Goods","Catalog, Search",49.99,,,,Fascia-Sportiva-con-Auricolari-GoFit-Verde,"Fascia Sportiva con Auricolari GoFit Verde","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Cuffie,Colore Verde,Verde,","Se adori gli sport e ti piace tenerti informato con gli ultimi accessori sportivi, non puoi perderti questa ottima fascia sportiva con auricolari GoFit",http://dropshipping.bigbuy.eu/imgs/G0500184_81112.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500184_81112.jpg,,,,,,"2015-09-28 07:07:26",,,,,,,,,,,,,,,,,"GTIN=8018417209635",11,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500184_81118.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81117.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81116.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81115.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81114.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81113.jpg","GTIN=8018417209635",,,,,,,,,, +BB-G0500186,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Cuffie",base,"Fascia Sportiva con Auricolari GoFit Arancio","<a id=""maggiorni_informazioni"" title=""Fascia Sportiva con Auricolari GoFit""><p>Se</a> adori gli sport e ti piace tenerti informato con gli ultimi <strong>accessori sportivi</strong>, non puoi perderti questa ottima <strong>fascia sportiva con auricolari GoFit</strong>. Con questa fascia per la testa, potrai ascoltare i tuoi brani musicali preferiti mentre fai jogging, inoltre potrai rispondere alle chiamate, semplicemente premendo il pulsante di risposta e chiusura chiamate incorporato nella doppia connessione, con cavo audio jack da 3,5 mm (lunghezza: circa 1 m). Altoparlanti rimovibili per permetterti di lavare la fascia. Ampiezza: circa 9,5 cm. Diametro: circa 27 cm. Esterno 100% poliestere. Interno in microfibra polare.<br /><br />Caratteristiche:</p><ul><li>Sensibilità: 5 dB</li><li>Impedenza altoparlante: 32 Ω</li><li>Frequenza: 20 Hz-20 kHz</li><li>Adatto a smartphone e altri telefoni mobili</li></ul><p> Dimenzioni per Fascia Sportiva con Auricolari GoFit: </br><ul><li>Altezza: 20 Cm</li><li>Larghezza: 9 Cm</li><li>Profondita': 4.5 Cm</li><li>Peso: 0.102 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417209505</p>","Se adori gli sport e ti piace tenerti informato con gli ultimi accessori sportivi, non puoi perderti questa ottima fascia sportiva con auricolari GoFit.</br><a href=""#maggiorni_informazioni"" title=""Fascia Sportiva con Auricolari GoFit""> Maggiori Informazioni</a>",0.102,1,"Taxable Goods","Catalog, Search",49.99,,,,Fascia-Sportiva-con-Auricolari-GoFit-Arancio,"Fascia Sportiva con Auricolari GoFit Arancio","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Cuffie,Colore Arancio,Arancio,","Se adori gli sport e ti piace tenerti informato con gli ultimi accessori sportivi, non puoi perderti questa ottima fascia sportiva con auricolari GoFit",http://dropshipping.bigbuy.eu/imgs/G0500184_81118.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500184_81118.jpg,,,,,,"2015-09-28 07:07:51",,,,,,,,,,,,,,,,,"GTIN=8018417209505",32,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500184_81112.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81117.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81116.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81115.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81114.jpg,http://dropshipping.bigbuy.eu/imgs/G0500184_81113.jpg","GTIN=8018417209505",,,,,,,,,, +BB-I3505265,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Casse",base,"Altoparlante Sportivo Bluetooth GoFit","<a id=""maggiorni_informazioni"" title=""Altoparlante Sportivo Bluetooth GoFit""><p>Sei</a> un amante dello sport e adori fare escursioni in montagna? Ti piace la musica? Allora questo prodotto ti starà a pennello! Questo favoloso <strong>altoparlante sportivo Bluetooth GoFit</strong> ti accompagnerà dove vuoi, permettendoti di ascoltare la tua musica preferita durante le tue uscite, così come rispondere alle chiamate attraverso la funzione Bluetooth. Resistente all'acqua. Include un cavo di ricarica USB. L'altoparlante ha una clip per inserirlo su una cintura e una banda elastica per indossarlo al polso, nello zaino, ecc. Misure (diametro x altezza) circa: 8,5 cm x 3 cm. Peso: circa 159 g.</p><p>Caratteristiche:</p><ul><li>Protezione impermeabile: IP4</li><li>Bluetooth 2.0 + EDR: distanza fino a circa 10 m</li><li>Funzione mani libere</li><li>Permette la ricezione di chiamate e terminarle</li><li>Pulsanti di pausa, avanzamento e indietro</li><li>Tempo di riproduzione: circa 2,5 ore</li><li>Frequenza: 90 Hz-20 KHz</li><li>Uscita altoparlante: 5 W</li><li>Connessione per audio jack<em> </em>3,5 mm</li></ul><p> </p><p> Dimenzioni per Altoparlante Sportivo Bluetooth GoFit: </br><ul><li>Altezza: 20 Cm</li><li>Larghezza: 6 Cm</li><li>Profondita': 9.5 Cm</li><li>Peso: 0.278 Kg</li></ul></p><p>Codice Prodotto (EAN): 8018417207747</p>","Sei un amante dello sport e adori fare escursioni in montagna? Ti piace la musica? Allora questo prodotto ti starà a pennello! Questo favoloso altoparlante sportivo Bluetooth GoFit ti accompagnerà dove vuoi, permettendoti di ascoltare la tua musica preferita durante le tue uscite, così come rispondere alle chiamate attraverso la funzione Bluetooth.</br><a href=""#maggiorni_informazioni"" title=""Altoparlante Sportivo Bluetooth GoFit""> Maggiori Informazioni</a>",0.278,1,"Taxable Goods","Catalog, Search",89.9,,,,Altoparlante-Sportivo-Bluetooth-GoFit,"Altoparlante Sportivo Bluetooth GoFit","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Casse,","Sei un amante dello sport e adori fare escursioni in montagna? Ti piace la musica? Allora questo prodotto ti starà a pennello! Questo favoloso altoparlante sportivo Bluetooth GoFit ti accompagnerà dove vuoi, permettendoti di ascoltare la tua musica ",http://dropshipping.bigbuy.eu/imgs/I3505265_81327.jpg,,http://dropshipping.bigbuy.eu/imgs/I3505265_81327.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8018417207747",10,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I3505265_81169.jpg,http://dropshipping.bigbuy.eu/imgs/I3505265_81168.jpg,http://dropshipping.bigbuy.eu/imgs/I3505265_81167.jpg","GTIN=8018417207747",,,,,,,,,, +BB-H1000173,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Scooter Elettrici",base,"Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid Azzurro","<a id=""maggiorni_informazioni"" title=""Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid""><p>Dimentica</a> i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spostarsi che chiunque noterà. Metti alla prova il tuo equilibrio e divertiti!</p><p><br /><strong><a href=""http://www.roverdroid.com/"">www.roverdroid.com</a></strong></p><p>Caratteristiche:</p><ul><li>Batteria a litio: 160 Wh</li><li>Pulsante on/off</li><li>Indicatore luminoso del livello di carica sull'asse centrale</li><li>Illuminazione di segnaletica anteriore LED</li><li>Zona d'appoggio in gomma resistente e antiscivolo</li><li>Velocità massima: 10 km/h circa</li><li>Autonomia con carica completa: da 17 a 20 km circa</li><li>Peso: 10 kg circa</li><li>Tempo di carica: 3 ore circa</li><li>Peso massimo supportato: 100 kg</li><li>Dimensioni: 58 x 18 x 18 cm circa</li><li>Diametro delle ruote: 7""</li><li>Borsa di trasporto e caricabatterie inclusi (CA: 100-240 V, 50-60 Hz, 1,2 A / DC: 36 V, 2 A)</li></ul><p> Dimenzioni per Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid: </br><ul><li>Altezza: 22.5 Cm</li><li>Larghezza: 64 Cm</li><li>Profondita': 24 Cm</li><li>Peso: 11.85 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888109204</p>","Dimentica i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spostarsi che chiunque noterà.</br><a href=""#maggiorni_informazioni"" title=""Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid""> Maggiori Informazioni</a>",11.85,1,"Taxable Goods","Catalog, Search",599,,,,Mini-Scooter-Elettrico-di-Auto-Equilibrio-(2-ruote)-Rover-Droid-Azzurro,"Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid Azzurro","Informatica Elettronica,Informatica,Elettronica,Scooter Elettrici,Scooter,Elettrici,Colore Azzurro,Azzurro,","Dimentica i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spos",http://dropshipping.bigbuy.eu/imgs/H1000172_90268.jpg,,http://dropshipping.bigbuy.eu/imgs/H1000172_90268.jpg,,,,,,"2016-02-25 15:50:21",,,,,,,,,,,,,,,,,"GTIN=4899888109204",60,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/H1000172_90282.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_90270.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87995.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87979.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87978.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87976.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87975.jpg","GTIN=4899888109204",,,,,,,,,, +BB-H1000174,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Scooter Elettrici",base,"Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid Nero","<a id=""maggiorni_informazioni"" title=""Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid""><p>Dimentica</a> i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spostarsi che chiunque noterà. Metti alla prova il tuo equilibrio e divertiti!</p><p><br /><strong><a href=""http://www.roverdroid.com/"">www.roverdroid.com</a></strong></p><p>Caratteristiche:</p><ul><li>Batteria a litio: 160 Wh</li><li>Pulsante on/off</li><li>Indicatore luminoso del livello di carica sull'asse centrale</li><li>Illuminazione di segnaletica anteriore LED</li><li>Zona d'appoggio in gomma resistente e antiscivolo</li><li>Velocità massima: 10 km/h circa</li><li>Autonomia con carica completa: da 17 a 20 km circa</li><li>Peso: 10 kg circa</li><li>Tempo di carica: 3 ore circa</li><li>Peso massimo supportato: 100 kg</li><li>Dimensioni: 58 x 18 x 18 cm circa</li><li>Diametro delle ruote: 7""</li><li>Borsa di trasporto e caricabatterie inclusi (CA: 100-240 V, 50-60 Hz, 1,2 A / DC: 36 V, 2 A)</li></ul><p> Dimenzioni per Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid: </br><ul><li>Altezza: 22.5 Cm</li><li>Larghezza: 64 Cm</li><li>Profondita': 24 Cm</li><li>Peso: 11.85 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888109273</p>","Dimentica i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spostarsi che chiunque noterà.</br><a href=""#maggiorni_informazioni"" title=""Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid""> Maggiori Informazioni</a>",11.85,1,"Taxable Goods","Catalog, Search",599,,,,Mini-Scooter-Elettrico-di-Auto-Equilibrio-(2-ruote)-Rover-Droid-Nero,"Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid Nero","Informatica Elettronica,Informatica,Elettronica,Scooter Elettrici,Scooter,Elettrici,Colore Nero,Nero,","Dimentica i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spos",http://dropshipping.bigbuy.eu/imgs/H1000172_90282.jpg,,http://dropshipping.bigbuy.eu/imgs/H1000172_90282.jpg,,,,,,"2016-02-25 15:50:21",,,,,,,,,,,,,,,,,"GTIN=4899888109273",131,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/H1000172_90268.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_90270.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87995.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87979.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87978.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87976.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87975.jpg","GTIN=4899888109273",,,,,,,,,, +BB-H1000182,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Scooter Elettrici",base,"Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid Grafitti","<a id=""maggiorni_informazioni"" title=""Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid""><p>Dimentica</a> i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spostarsi che chiunque noterà. Metti alla prova il tuo equilibrio e divertiti!</p><p><br /><strong><a href=""http://www.roverdroid.com/"">www.roverdroid.com</a></strong></p><p>Caratteristiche:</p><ul><li>Batteria a litio: 160 Wh</li><li>Pulsante on/off</li><li>Indicatore luminoso del livello di carica sull'asse centrale</li><li>Illuminazione di segnaletica anteriore LED</li><li>Zona d'appoggio in gomma resistente e antiscivolo</li><li>Velocità massima: 10 km/h circa</li><li>Autonomia con carica completa: da 17 a 20 km circa</li><li>Peso: 10 kg circa</li><li>Tempo di carica: 3 ore circa</li><li>Peso massimo supportato: 100 kg</li><li>Dimensioni: 58 x 18 x 18 cm circa</li><li>Diametro delle ruote: 7""</li><li>Borsa di trasporto e caricabatterie inclusi (CA: 100-240 V, 50-60 Hz, 1,2 A / DC: 36 V, 2 A)</li></ul><p> Dimenzioni per Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid: </br><ul><li>Altezza: 22.5 Cm</li><li>Larghezza: 64 Cm</li><li>Profondita': 24 Cm</li><li>Peso: 11.85 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888109747</p>","Dimentica i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spostarsi che chiunque noterà.</br><a href=""#maggiorni_informazioni"" title=""Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid""> Maggiori Informazioni</a>",11.85,1,"Taxable Goods","Catalog, Search",599,,,,Mini-Scooter-Elettrico-di-Auto-Equilibrio-(2-ruote)-Rover-Droid-Grafitti,"Mini Scooter Elettrico di Auto Equilibrio (2 ruote) Rover Droid Grafitti","Informatica Elettronica,Informatica,Elettronica,Scooter Elettrici,Scooter,Elettrici,Colore Grafitti,Grafitti,","Dimentica i noiosi trasporti convenzionali e diventa il re delle strade con il mini scooter elettrico di auto equilibrio (2 ruote) Rover Droid! Questo pratico, intelligente ed originale veicolo motorizzato costituisce un modo rapido e comodo di spos",http://dropshipping.bigbuy.eu/imgs/H1000172_90270.jpg,,http://dropshipping.bigbuy.eu/imgs/H1000172_90270.jpg,,,,,,"2016-02-25 16:33:57",,,,,,,,,,,,,,,,,"GTIN=4899888109747",92,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/H1000172_90268.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_90282.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87995.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87979.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87978.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87976.jpg,http://dropshipping.bigbuy.eu/imgs/H1000172_87975.jpg","GTIN=4899888109747",,,,,,,,,, +BB-I2500322,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Orologi e Sveglie,Default Category/Informatica Elettronica/Orologi e Sveglie/Smartwatch",base,"Orologio Intelligente Smartwatch BT110 con Audio Nero","<a id=""maggiorni_informazioni"" title=""Orologio Intelligente Smartwatch BT110 con Audio""><p>Sfoggia</a> il tuo <strong>orologio intelligente Smartwatch BT110 con audio</strong>! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usarlo come cronometro, barometro, altimetro e podometro. Inoltre, questo orologio dispone di varie funzioni autonome: allarme, calendario, calcolatrice, controllo del sonno...</p><p><a href=""http://www.bitblin.com/"" target=""_blank""><strong>www.bitblin.com</strong></a><br /> <br />Caratteristiche:</p><ul><li>Schermo touch</li><li>Risoluzione: 128 x 128 pixel</li><li>Batteria a litio: 3,7 V / 230 mA</li><li>Durata appross. in attesa: 150 h</li><li>Durata appross. in conversazione: 3 h</li><li>Connessione micro USB</li><li>Bluetooth 3.0</li><li>Vibrazione</li><li>Dimensioni appross. della sfera: 4 x 4,5 x 1 cm</li><li>Cavo USB da micro USB incluso</li><li>Il menu è disponibile in spagnolo, inglese, francese, danese, polacco, portoghese, italiano, tedesco, turco, russo e svedese.</li><li>Compatibile con smartphones Android</li></ul><p> </p><p>Con Smartphone Android da 4.0 a 5.0 è possibile ricevere notifiche di SMS, e-mails, WhatsApp e social network (scaricando l'applicazione indicata nella pagina web del prodotto).</p><p> Dimenzioni per Orologio Intelligente Smartwatch BT110 con Audio: </br><ul><li>Altezza: 11 Cm</li><li>Larghezza: 8 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.145 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888109242</p>","Sfoggia il tuo orologio intelligente Smartwatch BT110 con audio! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usarlo come cronometro, barometro, altimetro e podometro.</br><a href=""#maggiorni_informazioni"" title=""Orologio Intelligente Smartwatch BT110 con Audio""> Maggiori Informazioni</a>",0.145,1,"Taxable Goods","Catalog, Search",94.9,,,,Orologio-Intelligente-Smartwatch-BT110-con-Audio-Nero,"Orologio Intelligente Smartwatch BT110 con Audio Nero","Informatica Elettronica,Informatica,Elettronica,Orologi e Sveglie,Orologi,Sveglie,Smartwatch,Colore Nero,Nero,","Sfoggia il tuo orologio intelligente Smartwatch BT110 con audio! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usa",http://dropshipping.bigbuy.eu/imgs/I2500321_84125.jpg,,http://dropshipping.bigbuy.eu/imgs/I2500321_84125.jpg,,,,,,"2016-02-11 08:36:39",,,,,,,,,,,,,,,,,"GTIN=4899888109242",2,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I2500321_101196.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101195.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101194.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101193.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84129.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84128.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84127.jpg","GTIN=4899888109242",,,,,,,,,, +BB-I2500323,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Orologi e Sveglie,Default Category/Informatica Elettronica/Orologi e Sveglie/Smartwatch",base,"Orologio Intelligente Smartwatch BT110 con Audio Bianco","<a id=""maggiorni_informazioni"" title=""Orologio Intelligente Smartwatch BT110 con Audio""><p>Sfoggia</a> il tuo <strong>orologio intelligente Smartwatch BT110 con audio</strong>! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usarlo come cronometro, barometro, altimetro e podometro. Inoltre, questo orologio dispone di varie funzioni autonome: allarme, calendario, calcolatrice, controllo del sonno...</p><p><a href=""http://www.bitblin.com/"" target=""_blank""><strong>www.bitblin.com</strong></a><br /> <br />Caratteristiche:</p><ul><li>Schermo touch</li><li>Risoluzione: 128 x 128 pixel</li><li>Batteria a litio: 3,7 V / 230 mA</li><li>Durata appross. in attesa: 150 h</li><li>Durata appross. in conversazione: 3 h</li><li>Connessione micro USB</li><li>Bluetooth 3.0</li><li>Vibrazione</li><li>Dimensioni appross. della sfera: 4 x 4,5 x 1 cm</li><li>Cavo USB da micro USB incluso</li><li>Il menu è disponibile in spagnolo, inglese, francese, danese, polacco, portoghese, italiano, tedesco, turco, russo e svedese.</li><li>Compatibile con smartphones Android</li></ul><p> </p><p>Con Smartphone Android da 4.0 a 5.0 è possibile ricevere notifiche di SMS, e-mails, WhatsApp e social network (scaricando l'applicazione indicata nella pagina web del prodotto).</p><p> Dimenzioni per Orologio Intelligente Smartwatch BT110 con Audio: </br><ul><li>Altezza: 11 Cm</li><li>Larghezza: 8 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.145 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888109259</p>","Sfoggia il tuo orologio intelligente Smartwatch BT110 con audio! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usarlo come cronometro, barometro, altimetro e podometro.</br><a href=""#maggiorni_informazioni"" title=""Orologio Intelligente Smartwatch BT110 con Audio""> Maggiori Informazioni</a>",0.145,1,"Taxable Goods","Catalog, Search",94.9,,,,Orologio-Intelligente-Smartwatch-BT110-con-Audio-Bianco,"Orologio Intelligente Smartwatch BT110 con Audio Bianco","Informatica Elettronica,Informatica,Elettronica,Orologi e Sveglie,Orologi,Sveglie,Smartwatch,Colore Bianco,Bianco,","Sfoggia il tuo orologio intelligente Smartwatch BT110 con audio! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usa",http://dropshipping.bigbuy.eu/imgs/I2500321_101196.jpg,,http://dropshipping.bigbuy.eu/imgs/I2500321_101196.jpg,,,,,,"2016-02-11 08:36:39",,,,,,,,,,,,,,,,,"GTIN=4899888109259",2,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I2500321_84125.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101195.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101194.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101193.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84129.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84128.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84127.jpg","GTIN=4899888109259",,,,,,,,,, +BB-I2500324,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Orologi e Sveglie,Default Category/Informatica Elettronica/Orologi e Sveglie/Smartwatch",base,"Orologio Intelligente Smartwatch BT110 con Audio Rosso","<a id=""maggiorni_informazioni"" title=""Orologio Intelligente Smartwatch BT110 con Audio""><p>Sfoggia</a> il tuo <strong>orologio intelligente Smartwatch BT110 con audio</strong>! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usarlo come cronometro, barometro, altimetro e podometro. Inoltre, questo orologio dispone di varie funzioni autonome: allarme, calendario, calcolatrice, controllo del sonno...</p><p><a href=""http://www.bitblin.com/"" target=""_blank""><strong>www.bitblin.com</strong></a><br /> <br />Caratteristiche:</p><ul><li>Schermo touch</li><li>Risoluzione: 128 x 128 pixel</li><li>Batteria a litio: 3,7 V / 230 mA</li><li>Durata appross. in attesa: 150 h</li><li>Durata appross. in conversazione: 3 h</li><li>Connessione micro USB</li><li>Bluetooth 3.0</li><li>Vibrazione</li><li>Dimensioni appross. della sfera: 4 x 4,5 x 1 cm</li><li>Cavo USB da micro USB incluso</li><li>Il menu è disponibile in spagnolo, inglese, francese, danese, polacco, portoghese, italiano, tedesco, turco, russo e svedese.</li><li>Compatibile con smartphones Android</li></ul><p> </p><p>Con Smartphone Android da 4.0 a 5.0 è possibile ricevere notifiche di SMS, e-mails, WhatsApp e social network (scaricando l'applicazione indicata nella pagina web del prodotto).</p><p> Dimenzioni per Orologio Intelligente Smartwatch BT110 con Audio: </br><ul><li>Altezza: 11 Cm</li><li>Larghezza: 8 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.145 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888109266</p>","Sfoggia il tuo orologio intelligente Smartwatch BT110 con audio! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usarlo come cronometro, barometro, altimetro e podometro.</br><a href=""#maggiorni_informazioni"" title=""Orologio Intelligente Smartwatch BT110 con Audio""> Maggiori Informazioni</a>",0.145,1,"Taxable Goods","Catalog, Search",94.9,,,,Orologio-Intelligente-Smartwatch-BT110-con-Audio-Rosso,"Orologio Intelligente Smartwatch BT110 con Audio Rosso","Informatica Elettronica,Informatica,Elettronica,Orologi e Sveglie,Orologi,Sveglie,Smartwatch,Colore Rosso,Rosso,","Sfoggia il tuo orologio intelligente Smartwatch BT110 con audio! Sincronizzalo tramite Bluetooth al tuo smartphone e ti permette di realizzare e rispondere alle telefonate, accedere all'agenda e alla cronologia delle chiamate, ascoltare musica e usa",http://dropshipping.bigbuy.eu/imgs/I2500321_101195.jpg,,http://dropshipping.bigbuy.eu/imgs/I2500321_101195.jpg,,,,,,"2016-02-11 08:36:39",,,,,,,,,,,,,,,,,"GTIN=4899888109266",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I2500321_84125.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101196.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101194.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_101193.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84129.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84128.jpg,http://dropshipping.bigbuy.eu/imgs/I2500321_84127.jpg","GTIN=4899888109266",,,,,,,,,, +BB-I4110022,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Smartphone MyWigo UNO 5'' Bianco","<a id=""maggiorni_informazioni"" title=""Smartphone MyWigo UNO 5'' ""><p>Acquista</a> uno dei migliori <strong>telefoni cellulari sbloccati</strong> sul mercato in questo momento, lo <strong>smartphone MyWigo UNO</strong> <strong>5''</strong>! Include caricabatterie e cavetto USB al cavetto micro USB.</p><p>Caratteristiche:</p><ul><li>Schermo Vetro Ricurvo 5'' HD IPS 2.5D </li><li>Batteria a polimeri di litio: 2350 mAh</li><li>Fotocamera anteriore: 5 Mpx</li><li>Telecamera posteriore: 13 Mpx Sony, IMX 214 sensore con autofocus e flash LED</li><li>Processore: MTK6753 Octa Core a 1.3 GHz di 64 Bit</li><li>RAM: 2 GB DDR</li><li>Memoria interna: 32 GB (16 GB + 16 GB micro SD)</li><li>Dual SIM</li><li>Sistema operativo: Android Lollipop 5.1</li><li>Wi-Fi</li><li>Bluetooth 4.0 + HS</li><li>GPS</li><li>2G: GSM a 850/900/1800/1900 MHz</li><li>3G: WCDMA 900/2100 MHz</li><li>Tecnologia 4G (LTE) FDD 800/1800/2100/2600 MHz</li><li>Caricabatterie: AC 110-240 V, DC 5 V, 1000 mA</li><li>Dimensioni: 7 x 14 x 0,8 cm circa</li><li>Dimensioni dello schermo: 6 x 11 cm circa</li><li>Peso: 138 gr circa</li></ul><p> Dimenzioni per Smartphone MyWigo UNO 5'' : </br><ul><li>Altezza: 10.5 Cm</li><li>Larghezza: 18 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.347 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436533839565</p>","Acquista uno dei migliori telefoni cellulari sbloccati sul mercato in questo momento, lo smartphone MyWigo UNO 5''! Include caricabatterie e cavetto USB al cavetto micro USB.</br><a href=""#maggiorni_informazioni"" title=""Smartphone MyWigo UNO 5'' ""> Maggiori Informazioni</a>",0.347,1,"Taxable Goods","Catalog, Search",412.5,,,,Smartphone-MyWigo-UNO-5''-Bianco,"Smartphone MyWigo UNO 5'' Bianco","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Bianco,Bianco,","Acquista uno dei migliori telefoni cellulari sbloccati sul mercato in questo momento, lo smartphone MyWigo UNO 5''! Include caricabatterie e cavetto USB al cavetto micro USB",http://dropshipping.bigbuy.eu/imgs/I4110021_87809.jpg,,http://dropshipping.bigbuy.eu/imgs/I4110021_87809.jpg,,,,,,"2015-12-16 14:44:27",,,,,,,,,,,,,,,,,"GTIN=8436533839565",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I4110021_87813.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87812.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87811.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87810.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87808.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87807.jpg","GTIN=8436533839565",,,,,,,,,, +BB-I4110023,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Smartphone MyWigo UNO 5'' Nero","<a id=""maggiorni_informazioni"" title=""Smartphone MyWigo UNO 5'' ""><p>Acquista</a> uno dei migliori <strong>telefoni cellulari sbloccati</strong> sul mercato in questo momento, lo <strong>smartphone MyWigo UNO</strong> <strong>5''</strong>! Include caricabatterie e cavetto USB al cavetto micro USB.</p><p>Caratteristiche:</p><ul><li>Schermo Vetro Ricurvo 5'' HD IPS 2.5D </li><li>Batteria a polimeri di litio: 2350 mAh</li><li>Fotocamera anteriore: 5 Mpx</li><li>Telecamera posteriore: 13 Mpx Sony, IMX 214 sensore con autofocus e flash LED</li><li>Processore: MTK6753 Octa Core a 1.3 GHz di 64 Bit</li><li>RAM: 2 GB DDR</li><li>Memoria interna: 32 GB (16 GB + 16 GB micro SD)</li><li>Dual SIM</li><li>Sistema operativo: Android Lollipop 5.1</li><li>Wi-Fi</li><li>Bluetooth 4.0 + HS</li><li>GPS</li><li>2G: GSM a 850/900/1800/1900 MHz</li><li>3G: WCDMA 900/2100 MHz</li><li>Tecnologia 4G (LTE) FDD 800/1800/2100/2600 MHz</li><li>Caricabatterie: AC 110-240 V, DC 5 V, 1000 mA</li><li>Dimensioni: 7 x 14 x 0,8 cm circa</li><li>Dimensioni dello schermo: 6 x 11 cm circa</li><li>Peso: 138 gr circa</li></ul><p> Dimenzioni per Smartphone MyWigo UNO 5'' : </br><ul><li>Altezza: 10.5 Cm</li><li>Larghezza: 18 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.347 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436533839558</p>","Acquista uno dei migliori telefoni cellulari sbloccati sul mercato in questo momento, lo smartphone MyWigo UNO 5''! Include caricabatterie e cavetto USB al cavetto micro USB.</br><a href=""#maggiorni_informazioni"" title=""Smartphone MyWigo UNO 5'' ""> Maggiori Informazioni</a>",0.347,1,"Taxable Goods","Catalog, Search",412.5,,,,Smartphone-MyWigo-UNO-5''-Nero,"Smartphone MyWigo UNO 5'' Nero","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Nero,Nero,","Acquista uno dei migliori telefoni cellulari sbloccati sul mercato in questo momento, lo smartphone MyWigo UNO 5''! Include caricabatterie e cavetto USB al cavetto micro USB",http://dropshipping.bigbuy.eu/imgs/I4110021_87813.jpg,,http://dropshipping.bigbuy.eu/imgs/I4110021_87813.jpg,,,,,,"2015-12-16 14:44:27",,,,,,,,,,,,,,,,,"GTIN=8436533839558",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I4110021_87809.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87812.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87811.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87810.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87808.jpg,http://dropshipping.bigbuy.eu/imgs/I4110021_87807.jpg","GTIN=8436533839558",,,,,,,,,, +BB-V1400101,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Telefono Cellulare Thomson Tlink11 Bianco","<a id=""maggiorni_informazioni"" title=""Telefono Cellulare Thomson Tlink11""><p>Se</a> sei alla ricerca di un telefono cellulare user-friendly per le persone anziane o per chi è agli inizi nel mondo della telefonia mobile, il <strong>telefono cellulare </strong><strong>Thomson Tlink11 </strong>è ciò che stai cercando!</p><p>Caratteristiche:</p><ul><li>Telefono cellulare sbloccato</li><li>Schermo: 1.77"" 128 x 160, colori 65 K</li><li>Reti: GSM-EDGE 850/900/1800/1900 MHz</li><li>Dual SIM</li><li>Bluetooth</li><li>MP3</li><li>Radio FM</li><li>Micro USB</li><li>Micro SD (scheda non inclusa)</li><li>Connessione per auricolari (non inclusi)</li><li>Lista contatti: 200 nomi</li><li>Funzione SMS</li><li>Mani-libere</li><li>Batteria Li-ion 600 mAh</li><li>Include: batteria e caricabatterie</li><li>Dimensioni: 4.5 x 11 x 1 cm circa </li></ul><p> Dimenzioni per Telefono Cellulare Thomson Tlink11: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 11 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.167 Kg</li></ul></p><p>Codice Prodotto (EAN): 3527570047688</p>","Se sei alla ricerca di un telefono cellulare user-friendly per le persone anziane o per chi è agli inizi nel mondo della telefonia mobile, il telefono cellulare Thomson Tlink11 è ciò che stai cercando!Caratteristiche:Telefono cellulare sbloccatoSchermo: 1.</br><a href=""#maggiorni_informazioni"" title=""Telefono Cellulare Thomson Tlink11""> Maggiori Informazioni</a>",0.167,1,"Taxable Goods","Catalog, Search",43.5,,,,Telefono-Cellulare-Thomson-Tlink11-Bianco,"Telefono Cellulare Thomson Tlink11 Bianco","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Bianco,Bianco,","Se sei alla ricerca di un telefono cellulare user-friendly per le persone anziane o per chi è agli inizi nel mondo della telefonia mobile, il telefono cellulare Thomson Tlink11 è ciò che stai cercando!Caratteristiche:Telefono cellulare sbloccatoSche",http://dropshipping.bigbuy.eu/imgs/V1400100_91205.jpg,,http://dropshipping.bigbuy.eu/imgs/V1400100_91205.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3527570047688",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1400100_91204.jpg,http://dropshipping.bigbuy.eu/imgs/V1400100_91203.jpg","GTIN=3527570047688",,,,,,,,,, +BB-V1400102,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Telefono Cellulare Thomson Tlink11 Nero","<a id=""maggiorni_informazioni"" title=""Telefono Cellulare Thomson Tlink11""><p>Se</a> sei alla ricerca di un telefono cellulare user-friendly per le persone anziane o per chi è agli inizi nel mondo della telefonia mobile, il <strong>telefono cellulare </strong><strong>Thomson Tlink11 </strong>è ciò che stai cercando!</p><p>Caratteristiche:</p><ul><li>Telefono cellulare sbloccato</li><li>Schermo: 1.77"" 128 x 160, colori 65 K</li><li>Reti: GSM-EDGE 850/900/1800/1900 MHz</li><li>Dual SIM</li><li>Bluetooth</li><li>MP3</li><li>Radio FM</li><li>Micro USB</li><li>Micro SD (scheda non inclusa)</li><li>Connessione per auricolari (non inclusi)</li><li>Lista contatti: 200 nomi</li><li>Funzione SMS</li><li>Mani-libere</li><li>Batteria Li-ion 600 mAh</li><li>Include: batteria e caricabatterie</li><li>Dimensioni: 4.5 x 11 x 1 cm circa </li></ul><p> Dimenzioni per Telefono Cellulare Thomson Tlink11: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 11 Cm</li><li>Profondita': 5 Cm</li><li>Peso: 0.167 Kg</li></ul></p><p>Codice Prodotto (EAN): 3527570047596</p>","Se sei alla ricerca di un telefono cellulare user-friendly per le persone anziane o per chi è agli inizi nel mondo della telefonia mobile, il telefono cellulare Thomson Tlink11 è ciò che stai cercando!Caratteristiche:Telefono cellulare sbloccatoSchermo: 1.</br><a href=""#maggiorni_informazioni"" title=""Telefono Cellulare Thomson Tlink11""> Maggiori Informazioni</a>",0.167,1,"Taxable Goods","Catalog, Search",43.5,,,,Telefono-Cellulare-Thomson-Tlink11-Nero,"Telefono Cellulare Thomson Tlink11 Nero","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Nero,Nero,","Se sei alla ricerca di un telefono cellulare user-friendly per le persone anziane o per chi è agli inizi nel mondo della telefonia mobile, il telefono cellulare Thomson Tlink11 è ciò che stai cercando!Caratteristiche:Telefono cellulare sbloccatoSche",http://dropshipping.bigbuy.eu/imgs/V1400100_91204.jpg,,http://dropshipping.bigbuy.eu/imgs/V1400100_91204.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3527570047596",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1400100_91205.jpg,http://dropshipping.bigbuy.eu/imgs/V1400100_91203.jpg","GTIN=3527570047596",,,,,,,,,, +BB-V1400104,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Telefono Cellulare Thomson Serea51 Bianco","<a id=""maggiorni_informazioni"" title=""Telefono Cellulare Thomson Serea51""><p>In</a> arrivo il <strong>telefono cellulare</strong><strong> Thomson Serea51</strong><strong> </strong>per gli amanti dei cellulari semplici e funzionali, per le persone anziane o per coloro che si avviciano per la prima volta al mondo della telefonia mobile!</p><p>Caratteristiche:</p><ul><li>Cellulare sbloccato</li><li>Schermo: 1,77"" 160 x 128, 65 K colori</li><li>Reti: GSM-EDGE 850/900/1800/1900 MHz</li><li>Tasto per le chiamate d'emergenza</li><li>Tasti di grandi dimensioni</li><li>Luce LED</li><li>Fotocamera VGA</li><li>Bluetooth</li><li>MP3</li><li>Radio FM</li><li>Micro USB</li><li>Micro SD (scheda non inclusa)</li><li>Agenda: 250 voci</li><li>Funzione SMS, MMS</li><li>kit auricolare mani libere</li><li>Batteria Li-ion 800 mAh</li><li>Durata della batteria: 220 h in standby / 5,5 h in conversazione</li><li>Include: batteria, base di ricarica, adattatore di CA, cavo dati USB e auricolari</li><li>Dimensioni (senza base): 5 x 11 x 1,4 cm circa</li></ul><p> Dimenzioni per Telefono Cellulare Thomson Serea51: </br><ul><li>Altezza: 18 Cm</li><li>Larghezza: 11.5 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.288 Kg</li></ul></p><p>Codice Prodotto (EAN): 3527570046964</p>","In arrivo il telefono cellulare Thomson Serea51 per gli amanti dei cellulari semplici e funzionali, per le persone anziane o per coloro che si avviciano per la prima volta al mondo della telefonia mobile!Caratteristiche:Cellulare sbloccatoSchermo: 1,77" 160 x 128, 65 K coloriReti: GSM-EDGE 850/900/1800/1900 MHzTasto per le chiamate d'emergenzaTasti di grandi dimensioniLuce LEDFotocamera VGABluetoothMP3Radio FMMicro USBMicro SD (scheda non inclusa)Agenda: 250 vociFunzione SMS, MMSkit auricolare mani libereBatteria Li-ion 800 mAhDurata della batteria: 220 h in standby / 5,5 h in conversazioneInclude: batteria, base di ricarica, adattatore di CA, cavo dati USB e auricolariDimensioni (senza base): 5 x 11 x 1,4 cm circa.</br><a href=""#maggiorni_informazioni"" title=""Telefono Cellulare Thomson Serea51""> Maggiori Informazioni</a>",0.288,1,"Taxable Goods","Catalog, Search",85.9,,,,Telefono-Cellulare-Thomson-Serea51-Bianco,"Telefono Cellulare Thomson Serea51 Bianco","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Bianco,Bianco,","In arrivo il telefono cellulare Thomson Serea51 per gli amanti dei cellulari semplici e funzionali, per le persone anziane o per coloro che si avviciano per la prima volta al mondo della telefonia mobile!Caratteristiche:Cellulare sbloccatoSchermo: 1",http://dropshipping.bigbuy.eu/imgs/V1400103_91207.jpg,,http://dropshipping.bigbuy.eu/imgs/V1400103_91207.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3527570046964",42,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1400103_91208.jpg,http://dropshipping.bigbuy.eu/imgs/V1400103_91206.jpg","GTIN=3527570046964",,,,,,,,,, +BB-V1400105,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Telefono Cellulare Thomson Serea51 Nero","<a id=""maggiorni_informazioni"" title=""Telefono Cellulare Thomson Serea51""><p>In</a> arrivo il <strong>telefono cellulare</strong><strong> Thomson Serea51</strong><strong> </strong>per gli amanti dei cellulari semplici e funzionali, per le persone anziane o per coloro che si avviciano per la prima volta al mondo della telefonia mobile!</p><p>Caratteristiche:</p><ul><li>Cellulare sbloccato</li><li>Schermo: 1,77"" 160 x 128, 65 K colori</li><li>Reti: GSM-EDGE 850/900/1800/1900 MHz</li><li>Tasto per le chiamate d'emergenza</li><li>Tasti di grandi dimensioni</li><li>Luce LED</li><li>Fotocamera VGA</li><li>Bluetooth</li><li>MP3</li><li>Radio FM</li><li>Micro USB</li><li>Micro SD (scheda non inclusa)</li><li>Agenda: 250 voci</li><li>Funzione SMS, MMS</li><li>kit auricolare mani libere</li><li>Batteria Li-ion 800 mAh</li><li>Durata della batteria: 220 h in standby / 5,5 h in conversazione</li><li>Include: batteria, base di ricarica, adattatore di CA, cavo dati USB e auricolari</li><li>Dimensioni (senza base): 5 x 11 x 1,4 cm circa</li></ul><p> Dimenzioni per Telefono Cellulare Thomson Serea51: </br><ul><li>Altezza: 18 Cm</li><li>Larghezza: 11.5 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.288 Kg</li></ul></p><p>Codice Prodotto (EAN): 3527570046186</p>","In arrivo il telefono cellulare Thomson Serea51 per gli amanti dei cellulari semplici e funzionali, per le persone anziane o per coloro che si avviciano per la prima volta al mondo della telefonia mobile!Caratteristiche:Cellulare sbloccatoSchermo: 1,77" 160 x 128, 65 K coloriReti: GSM-EDGE 850/900/1800/1900 MHzTasto per le chiamate d'emergenzaTasti di grandi dimensioniLuce LEDFotocamera VGABluetoothMP3Radio FMMicro USBMicro SD (scheda non inclusa)Agenda: 250 vociFunzione SMS, MMSkit auricolare mani libereBatteria Li-ion 800 mAhDurata della batteria: 220 h in standby / 5,5 h in conversazioneInclude: batteria, base di ricarica, adattatore di CA, cavo dati USB e auricolariDimensioni (senza base): 5 x 11 x 1,4 cm circa.</br><a href=""#maggiorni_informazioni"" title=""Telefono Cellulare Thomson Serea51""> Maggiori Informazioni</a>",0.288,1,"Taxable Goods","Catalog, Search",85.9,,,,Telefono-Cellulare-Thomson-Serea51-Nero,"Telefono Cellulare Thomson Serea51 Nero","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Nero,Nero,","In arrivo il telefono cellulare Thomson Serea51 per gli amanti dei cellulari semplici e funzionali, per le persone anziane o per coloro che si avviciano per la prima volta al mondo della telefonia mobile!Caratteristiche:Cellulare sbloccatoSchermo: 1",http://dropshipping.bigbuy.eu/imgs/V1400103_91208.jpg,,http://dropshipping.bigbuy.eu/imgs/V1400103_91208.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3527570046186",33,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1400103_91207.jpg,http://dropshipping.bigbuy.eu/imgs/V1400103_91206.jpg","GTIN=3527570046186",,,,,,,,,, +BB-V1400107,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Telefono Cellularel Thomson Serea62 Nero","<a id=""maggiorni_informazioni"" title=""Telefono Cellularel Thomson Serea62""><p>Se</a> sei alla ricerca di un <strong>cellulare</strong> semplice ma dal design impeccabile, non puoi lasciarti sfuggire il <strong>telefono cellulare Thomson Serea62</strong>. È studiato anche per le persone anziane che vogliono avvicinarsi al mondo della <strong>telefonia mobile</strong>. </p><p>Caratteristiche:</p><ul><li>Schermo: 2,4"" 240 x 320, 262 K a colori</li><li>Reti: GSM-EDGE 850/900/1800/1900 MHz</li><li>Tasto per le chiamate d'emergenza</li><li>Menù semplificato e facile da usare</li><li>Tasti di grandi dimensioni</li><li>Fotocamera VGA</li><li>Bluetooth</li><li>MP3</li><li>Radio FM</li><li>Luce LED</li><li>Micro USB</li><li>Micro SD (carta non inclusa)</li><li>Agenda: 250 voci</li><li>Funzione SMS, MMS</li><li>kit auricolare mani libere</li><li>Batteria Li-ion 800 mAh</li><li>Durata della batteria: 480 h in standby / 5,5 h in conversazione</li><li>Include: batteria, base di ricarica, adattatore di CA, cavo dati USB e auricolari</li><li>Dimensioni (senza base): 5,5 x 10,5 x 2 cm circa</li></ul><p> Dimenzioni per Telefono Cellularel Thomson Serea62: </br><ul><li>Altezza: 5.2 Cm</li><li>Larghezza: 11.3 Cm</li><li>Profondita': 18.6 Cm</li><li>Peso: 0.284 Kg</li></ul></p><p>Codice Prodotto (EAN): 3527570046094</p>","Se sei alla ricerca di un cellulare semplice ma dal design impeccabile, non puoi lasciarti sfuggire il telefono cellulare Thomson Serea62.</br><a href=""#maggiorni_informazioni"" title=""Telefono Cellularel Thomson Serea62""> Maggiori Informazioni</a>",0.284,1,"Taxable Goods","Catalog, Search",99.5,,,,Telefono-Cellularel-Thomson-Serea62-Nero,"Telefono Cellularel Thomson Serea62 Nero","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Nero,Nero,","Se sei alla ricerca di un cellulare semplice ma dal design impeccabile, non puoi lasciarti sfuggire il telefono cellulare Thomson Serea62",http://dropshipping.bigbuy.eu/imgs/V1400106_91213.jpg,,http://dropshipping.bigbuy.eu/imgs/V1400106_91213.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3527570046094",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1400106_91215.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91214.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91212.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91211.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91210.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91209.jpg","GTIN=3527570046094",,,,,,,,,, +BB-V1400108,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Telefono Cellularel Thomson Serea62 Bianco","<a id=""maggiorni_informazioni"" title=""Telefono Cellularel Thomson Serea62""><p>Se</a> sei alla ricerca di un <strong>cellulare</strong> semplice ma dal design impeccabile, non puoi lasciarti sfuggire il <strong>telefono cellulare Thomson Serea62</strong>. È studiato anche per le persone anziane che vogliono avvicinarsi al mondo della <strong>telefonia mobile</strong>. </p><p>Caratteristiche:</p><ul><li>Schermo: 2,4"" 240 x 320, 262 K a colori</li><li>Reti: GSM-EDGE 850/900/1800/1900 MHz</li><li>Tasto per le chiamate d'emergenza</li><li>Menù semplificato e facile da usare</li><li>Tasti di grandi dimensioni</li><li>Fotocamera VGA</li><li>Bluetooth</li><li>MP3</li><li>Radio FM</li><li>Luce LED</li><li>Micro USB</li><li>Micro SD (carta non inclusa)</li><li>Agenda: 250 voci</li><li>Funzione SMS, MMS</li><li>kit auricolare mani libere</li><li>Batteria Li-ion 800 mAh</li><li>Durata della batteria: 480 h in standby / 5,5 h in conversazione</li><li>Include: batteria, base di ricarica, adattatore di CA, cavo dati USB e auricolari</li><li>Dimensioni (senza base): 5,5 x 10,5 x 2 cm circa</li></ul><p> Dimenzioni per Telefono Cellularel Thomson Serea62: </br><ul><li>Altezza: 5.2 Cm</li><li>Larghezza: 11.3 Cm</li><li>Profondita': 18.6 Cm</li><li>Peso: 0.284 Kg</li></ul></p><p>Codice Prodotto (EAN): 3527570046100</p>","Se sei alla ricerca di un cellulare semplice ma dal design impeccabile, non puoi lasciarti sfuggire il telefono cellulare Thomson Serea62.</br><a href=""#maggiorni_informazioni"" title=""Telefono Cellularel Thomson Serea62""> Maggiori Informazioni</a>",0.284,1,"Taxable Goods","Catalog, Search",99.5,,,,Telefono-Cellularel-Thomson-Serea62-Bianco,"Telefono Cellularel Thomson Serea62 Bianco","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Bianco,Bianco,","Se sei alla ricerca di un cellulare semplice ma dal design impeccabile, non puoi lasciarti sfuggire il telefono cellulare Thomson Serea62",http://dropshipping.bigbuy.eu/imgs/V1400106_91215.jpg,,http://dropshipping.bigbuy.eu/imgs/V1400106_91215.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3527570046100",13,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1400106_91213.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91214.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91212.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91211.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91210.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91209.jpg","GTIN=3527570046100",,,,,,,,,, +BB-V1400109,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Telefonia e Accessori,Default Category/Informatica Elettronica/Telefonia e Accessori/Cellulari",base,"Telefono Cellularel Thomson Serea62 Rosso","<a id=""maggiorni_informazioni"" title=""Telefono Cellularel Thomson Serea62""><p>Se</a> sei alla ricerca di un <strong>cellulare</strong> semplice ma dal design impeccabile, non puoi lasciarti sfuggire il <strong>telefono cellulare Thomson Serea62</strong>. È studiato anche per le persone anziane che vogliono avvicinarsi al mondo della <strong>telefonia mobile</strong>. </p><p>Caratteristiche:</p><ul><li>Schermo: 2,4"" 240 x 320, 262 K a colori</li><li>Reti: GSM-EDGE 850/900/1800/1900 MHz</li><li>Tasto per le chiamate d'emergenza</li><li>Menù semplificato e facile da usare</li><li>Tasti di grandi dimensioni</li><li>Fotocamera VGA</li><li>Bluetooth</li><li>MP3</li><li>Radio FM</li><li>Luce LED</li><li>Micro USB</li><li>Micro SD (carta non inclusa)</li><li>Agenda: 250 voci</li><li>Funzione SMS, MMS</li><li>kit auricolare mani libere</li><li>Batteria Li-ion 800 mAh</li><li>Durata della batteria: 480 h in standby / 5,5 h in conversazione</li><li>Include: batteria, base di ricarica, adattatore di CA, cavo dati USB e auricolari</li><li>Dimensioni (senza base): 5,5 x 10,5 x 2 cm circa</li></ul><p> Dimenzioni per Telefono Cellularel Thomson Serea62: </br><ul><li>Altezza: 5.2 Cm</li><li>Larghezza: 11.3 Cm</li><li>Profondita': 18.6 Cm</li><li>Peso: 0.284 Kg</li></ul></p><p>Codice Prodotto (EAN): 3527570046117</p>","Se sei alla ricerca di un cellulare semplice ma dal design impeccabile, non puoi lasciarti sfuggire il telefono cellulare Thomson Serea62.</br><a href=""#maggiorni_informazioni"" title=""Telefono Cellularel Thomson Serea62""> Maggiori Informazioni</a>",0.284,1,"Taxable Goods","Catalog, Search",99.5,,,,Telefono-Cellularel-Thomson-Serea62-Rosso,"Telefono Cellularel Thomson Serea62 Rosso","Informatica Elettronica,Informatica,Elettronica,Telefonia e Accessori,Telefonia,Accessori,Cellulari,Colore Rosso,Rosso,","Se sei alla ricerca di un cellulare semplice ma dal design impeccabile, non puoi lasciarti sfuggire il telefono cellulare Thomson Serea62",http://dropshipping.bigbuy.eu/imgs/V1400106_91214.jpg,,http://dropshipping.bigbuy.eu/imgs/V1400106_91214.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=3527570046117",17,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1400106_91213.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91215.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91212.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91211.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91210.jpg,http://dropshipping.bigbuy.eu/imgs/V1400106_91209.jpg","GTIN=3527570046117",,,,,,,,,, +BB-V0100186,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Cuffie",base,"Cuffie Fatina maginca Playz Kidz","<a id=""maggiorni_informazioni"" title=""Cuffie Fatina maginca Playz Kidz""><p>Le</a> <strong>cuffie Fatina Magica Playz Kidz </strong>son perfetti per i piccoli di casa! Queste <strong><strong>cuffie</strong> per bambini</strong> sono ideali come regalo per i re della casa!</p><p><a href=""http://www.playzkidz.com"" target=""_blank""><strong>www.playzkidz.com</strong></a></p><ul><li>Auricolari stereo</li><li>Cuffie imbottite</li><li>Compatibili con MP3, MP4, CD, radio e PC</li><li>Età raccomandata: +4 anni</li></ul><p> Dimenzioni per Cuffie Fatina maginca Playz Kidz: </br><ul><li>Altezza: 22.2 Cm</li><li>Larghezza: 9.5 Cm</li><li>Profondita': 26.7 Cm</li><li>Peso: 0.243 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888111122</p>","Le cuffie Fatina Magica Playz Kidz son perfetti per i piccoli di casa! Queste cuffie per bambini sono ideali come regalo per i re della casa!www.</br><a href=""#maggiorni_informazioni"" title=""Cuffie Fatina maginca Playz Kidz""> Maggiori Informazioni</a>",0.243,1,"Taxable Goods","Catalog, Search",18.9,,,,Cuffie-Fatina-maginca-Playz-Kidz,"Cuffie Fatina maginca Playz Kidz","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Cuffie,","Le cuffie Fatina Magica Playz Kidz son perfetti per i piccoli di casa! Queste cuffie per bambini sono ideali come regalo per i re della casa!www",http://dropshipping.bigbuy.eu/imgs/V0100186_93443.jpg,,http://dropshipping.bigbuy.eu/imgs/V0100186_93443.jpg,,,,,,"2016-08-16 05:37:23",,,,,,,,,,,,,,,,,"GTIN=4899888111122",2436,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0100186_93379.jpg,http://dropshipping.bigbuy.eu/imgs/V0100186_93377.jpg,http://dropshipping.bigbuy.eu/imgs/V0100186_93376.jpg,http://dropshipping.bigbuy.eu/imgs/V0100186_93375.jpg","GTIN=4899888111122",,,,,,,,,, +BB-V0100187,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Audio e Hi-Fi,Default Category/Informatica Elettronica/Audio e Hi-Fi/Cuffie",base,"Cuffie Mostriciattoli Playz Kidz","<a id=""maggiorni_informazioni"" title=""Cuffie Mostriciattoli Playz Kidz""><p>I</a> piccoli di casa impazziranno per le <strong><strong>cuffie</strong> Mostriciattoli Playz Kidz</strong>! Grazie al loro design originale e divertente, queste <strong><strong>cuffie</strong> per bambini </strong>sono il regalo perfetto!</p><p><a href=""http://www.playzkidz.com"" target=""_blank""><strong>www.playzkidz.com</strong></a></p><ul><li>Auricolari stereo</li><li>Cuffie imbottite</li><li>Compatibili con MP3, MP4, CD, radio e PC</li><li>Età raccomandata: +4 anni</li></ul><p> Dimenzioni per Cuffie Mostriciattoli Playz Kidz: </br><ul><li>Altezza: 22.2 Cm</li><li>Larghezza: 9.5 Cm</li><li>Profondita': 26.7 Cm</li><li>Peso: 0.245 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888111139</p>","I piccoli di casa impazziranno per le cuffie Mostriciattoli Playz Kidz! Grazie al loro design originale e divertente, queste cuffie per bambini sono il regalo perfetto!www.</br><a href=""#maggiorni_informazioni"" title=""Cuffie Mostriciattoli Playz Kidz""> Maggiori Informazioni</a>",0.245,1,"Taxable Goods","Catalog, Search",18.9,,,,Cuffie-Mostriciattoli-Playz-Kidz,"Cuffie Mostriciattoli Playz Kidz","Informatica Elettronica,Informatica,Elettronica,Audio e Hi-Fi,Audio,Hi-Fi,Cuffie,","I piccoli di casa impazziranno per le cuffie Mostriciattoli Playz Kidz! Grazie al loro design originale e divertente, queste cuffie per bambini sono il regalo perfetto!www",http://dropshipping.bigbuy.eu/imgs/V0100187_93370.jpg,,http://dropshipping.bigbuy.eu/imgs/V0100187_93370.jpg,,,,,,"2016-08-30 08:26:19",,,,,,,,,,,,,,,,,"GTIN=4899888111139",2438,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0100187_93374.jpg,http://dropshipping.bigbuy.eu/imgs/V0100187_93373.jpg,http://dropshipping.bigbuy.eu/imgs/V0100187_93372.jpg,http://dropshipping.bigbuy.eu/imgs/V0100187_93371.jpg","GTIN=4899888111139",,,,,,,,,, +BB-V1300174,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Orologi e Sveglie,Default Category/Informatica Elettronica/Orologi e Sveglie/Sveglie",base,"Orologio Sveglia con Contasecondi Star Wars R2-D2","<a id=""maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""><p>Sorprendi</a> i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'<strong>o</strong><strong>rologio sveglia con contasecondi Star Wars</strong>! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</p><ul><li>Dispone di segnale acustico d'allarme e di pulsante per spegnerlo</li><li>Funziona a batterie (1 x AA, non inclusa)</li><li>Dimensioni: circa 10,5 x 13,5 x 6 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Orologio Sveglia con Contasecondi Star Wars: </br><ul><li>Altezza: 0.13 Cm</li><li>Larghezza: 0.1 Cm</li><li>Profondita': 0.5 Cm</li><li>Peso: 0.15 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934787128</p>","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</br><a href=""#maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""> Maggiori Informazioni</a>",0.15,1,"Taxable Goods","Catalog, Search",15.8,,,,Orologio-Sveglia-con-Contasecondi-Star-Wars-R2-D2,"Orologio Sveglia con Contasecondi Star Wars R2-D2","Informatica Elettronica,Informatica,Elettronica,Orologi e Sveglie,Orologi,Sveglie,Sveglie,Design R2-D2,R2-D2,","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi",http://dropshipping.bigbuy.eu/imgs/V1300173_102640.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300173_102640.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934787128",69,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300173_102644.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102643.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102642.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102641.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102639.jpg","GTIN=8427934787128",,,,,,,,,, +BB-V1300175,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Orologi e Sveglie,Default Category/Informatica Elettronica/Orologi e Sveglie/Sveglie",base,"Orologio Sveglia con Contasecondi Star Wars Stormtrooper","<a id=""maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""><p>Sorprendi</a> i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'<strong>o</strong><strong>rologio sveglia con contasecondi Star Wars</strong>! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</p><ul><li>Dispone di segnale acustico d'allarme e di pulsante per spegnerlo</li><li>Funziona a batterie (1 x AA, non inclusa)</li><li>Dimensioni: circa 10,5 x 13,5 x 6 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Orologio Sveglia con Contasecondi Star Wars: </br><ul><li>Altezza: 0.13 Cm</li><li>Larghezza: 0.1 Cm</li><li>Profondita': 0.5 Cm</li><li>Peso: 0.15 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934787135</p>","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</br><a href=""#maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""> Maggiori Informazioni</a>",0.15,1,"Taxable Goods","Catalog, Search",15.8,,,,Orologio-Sveglia-con-Contasecondi-Star-Wars-Stormtrooper,"Orologio Sveglia con Contasecondi Star Wars Stormtrooper","Informatica Elettronica,Informatica,Elettronica,Orologi e Sveglie,Orologi,Sveglie,Sveglie,Design Stormtrooper,Stormtrooper,","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi",http://dropshipping.bigbuy.eu/imgs/V1300173_102644.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300173_102644.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934787135",69,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300173_102640.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102643.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102642.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102641.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102639.jpg","GTIN=8427934787135",,,,,,,,,, +BB-V1300176,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Orologi e Sveglie,Default Category/Informatica Elettronica/Orologi e Sveglie/Sveglie",base,"Orologio Sveglia con Contasecondi Star Wars Yoda","<a id=""maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""><p>Sorprendi</a> i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'<strong>o</strong><strong>rologio sveglia con contasecondi Star Wars</strong>! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</p><ul><li>Dispone di segnale acustico d'allarme e di pulsante per spegnerlo</li><li>Funziona a batterie (1 x AA, non inclusa)</li><li>Dimensioni: circa 10,5 x 13,5 x 6 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Orologio Sveglia con Contasecondi Star Wars: </br><ul><li>Altezza: 0.13 Cm</li><li>Larghezza: 0.1 Cm</li><li>Profondita': 0.5 Cm</li><li>Peso: 0.15 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934787142</p>","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</br><a href=""#maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""> Maggiori Informazioni</a>",0.15,1,"Taxable Goods","Catalog, Search",15.8,,,,Orologio-Sveglia-con-Contasecondi-Star-Wars-Yoda,"Orologio Sveglia con Contasecondi Star Wars Yoda","Informatica Elettronica,Informatica,Elettronica,Orologi e Sveglie,Orologi,Sveglie,Sveglie,Design Yoda,Yoda,","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi",http://dropshipping.bigbuy.eu/imgs/V1300173_102643.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300173_102643.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934787142",68,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300173_102640.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102644.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102642.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102641.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102639.jpg","GTIN=8427934787142",,,,,,,,,, +BB-V1300177,,Default,simple,"Default Category/Informatica Elettronica,Default Category/Informatica Elettronica/Orologi e Sveglie,Default Category/Informatica Elettronica/Orologi e Sveglie/Sveglie",base,"Orologio Sveglia con Contasecondi Star Wars Chewbacca","<a id=""maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""><p>Sorprendi</a> i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'<strong>o</strong><strong>rologio sveglia con contasecondi Star Wars</strong>! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</p><ul><li>Dispone di segnale acustico d'allarme e di pulsante per spegnerlo</li><li>Funziona a batterie (1 x AA, non inclusa)</li><li>Dimensioni: circa 10,5 x 13,5 x 6 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Orologio Sveglia con Contasecondi Star Wars: </br><ul><li>Altezza: 0.13 Cm</li><li>Larghezza: 0.1 Cm</li><li>Profondita': 0.5 Cm</li><li>Peso: 0.15 Kg</li></ul></p><p>Codice Prodotto (EAN): 8427934787159</p>","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi.</br><a href=""#maggiorni_informazioni"" title=""Orologio Sveglia con Contasecondi Star Wars""> Maggiori Informazioni</a>",0.15,1,"Taxable Goods","Catalog, Search",15.8,,,,Orologio-Sveglia-con-Contasecondi-Star-Wars-Chewbacca,"Orologio Sveglia con Contasecondi Star Wars Chewbacca","Informatica Elettronica,Informatica,Elettronica,Orologi e Sveglie,Orologi,Sveglie,Sveglie,Design Chewbacca,Chewbacca,","Sorprendi i tuoi familiari ed amici con un regalo originale che li lascerà a bocca aperta, l'orologio sveglia con contasecondi Star Wars! Una sveglia ideale per gli appassionati della famosa saga e dei suoi personaggi",http://dropshipping.bigbuy.eu/imgs/V1300173_102642.jpg,,http://dropshipping.bigbuy.eu/imgs/V1300173_102642.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8427934787159",69,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1300173_102640.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102644.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102643.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102641.jpg,http://dropshipping.bigbuy.eu/imgs/V1300173_102639.jpg","GTIN=8427934787159",,,,,,,,,, +BB-G1000110,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Offerte",base,"Attrezzo da Ginnastica Body Rocker","<a id=""maggiorni_informazioni"" title=""Attrezzo da Ginnastica Body Rocker ""><p>Non</a> perdere tempo e denaro andandoin <strong>palestra</strong> e procurarti ora l'<strong>attrezzo da ginnastica Body Rocker! </strong>Una forma facile ed efficace di rimettresi in forma facendo esercizio in casa. Grazie al suo sistema di oscillazione, è perfetto per lavorare e rafforzare, spalle, braccia, schiena, petto, glutei, ecc. Fatto in acciaio con impugnature in gomma. Include manuale d'istruzioni e DVD dimostrativo. (Questo prodotto può può presentare lievi danni che non impediscono il funzionamento del prodotto: impugnature in gomma piuma scollate).</p><p> Dimenzioni per Attrezzo da Ginnastica Body Rocker : </br><ul><li>Altezza: 21 Cm</li><li>Larghezza: 20 Cm</li><li>Profondita': 78 Cm</li><li>Peso: 2.13 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888101314</p>","Non perdere tempo e denaro andandoin palestra e procurarti ora l'attrezzo da ginnastica Body Rocker! Una forma facile ed efficace di rimettresi in forma facendo esercizio in casa.</br><a href=""#maggiorni_informazioni"" title=""Attrezzo da Ginnastica Body Rocker ""> Maggiori Informazioni</a>",2.13,1,"Taxable Goods","Catalog, Search",79,,,,Attrezzo-da-Ginnastica-Body-Rocker,"Attrezzo da Ginnastica Body Rocker","Outlet Offerte,Outlet,Offerte,Offerte,","Non perdere tempo e denaro andandoin palestra e procurarti ora l'attrezzo da ginnastica Body Rocker! Una forma facile ed efficace di rimettresi in forma facendo esercizio in casa",http://dropshipping.bigbuy.eu/imgs/bodirocker-00.jpg,,http://dropshipping.bigbuy.eu/imgs/bodirocker-00.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=4899888101314",121,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/bodirocker-03.jpg,http://dropshipping.bigbuy.eu/imgs/bodirocker-06.jpg,http://dropshipping.bigbuy.eu/imgs/bodirocker-05.jpg,http://dropshipping.bigbuy.eu/imgs/bodirocker-07.jpg,http://dropshipping.bigbuy.eu/imgs/bodirocker-02.jpg,http://dropshipping.bigbuy.eu/imgs/bodirocker-01.jpg,http://dropshipping.bigbuy.eu/imgs/bodirocker-04.jpg","GTIN=4899888101314",,,,,,,,,, +BB-J2000066,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Lovely Arancio","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""><p><strong>Acquista</a> la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali</strong>.<strong> </strong>La <strong>coperta Snug Snug con le maniche</strong> ti riscalderà senza aver bisogno del riscaldamento per tutto il giorno. Risparmierai i soldi e starai comodo grazie alla <strong>coperta extra morbida Kangoo Snug Snug con le maniche</strong>, poiché potrai fare tutto ciò che vuoi mentre sei coperto.</p><p>La coperta Snug Snug con le maniche è il regalo perfetto per qualsiasi occasione. Nessuno si aspetta un regalo così straordinario! Questa coperta con le maniche è l'ideale per quei momenti in cui fa freddo fuori e vuoi solo stare a casa a leggere, usare il computer o semplicemente a rilassarti sul divano. Fino ad ora, non è era facile raggomitolarsi sul divano e voler fare qualcosa, perché dovevi tirare fuori le braccia, ma faceva freddo. Tutto questo è finito grazie alla coperta Kangoo Snug Snug con le maniche!</p><p>Caratteristiche:</p><ul><li>Fatto di pile ultra soffice</li><li>Lavabile in lavatrice</li><li>Dimensioni: lunghezza 185 cm, larghezza 160 cm | Dimensioni manica: 60cm</li></ul><p><a title=""Batamanta SnugSnug"" href=""http://www.snugsnug.com"" target=""_blank"">www.snugsnug.com</a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio): </br><ul><li>Altezza: 28 Cm</li><li>Larghezza: 26 Cm</li><li>Profondita': 12 Cm</li><li>Peso: 0.8 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102731</p>","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""> Maggiori Informazioni</a>",0.8,1,"Taxable Goods","Catalog, Search",22.9,,,,OUTLET-Coperta-super-soffice-Kangoo-Snug-Snug-con-maniche-per-adulti-|-Decorazioni-originali-(Senza-imballaggio)-Lovely Arancio,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Lovely Arancio","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Lovely Arancio,Lovely Arancio,","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali",http://dropshipping.bigbuy.eu/imgs/J2000065_91715.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000065_91715.jpg,,,,,,"2016-02-11 15:49:04",,,,,,,,,,,,,,,,,"GTIN=4899888102731",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000065_91719.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91718.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91717.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91716.jpg","GTIN=4899888102731",,,,,,,,,, +BB-J2000239,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Commando","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""><p><strong>Acquista</a> la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali</strong>.<strong> </strong>La <strong>coperta Snug Snug con le maniche</strong> ti riscalderà senza aver bisogno del riscaldamento per tutto il giorno. Risparmierai i soldi e starai comodo grazie alla <strong>coperta extra morbida Kangoo Snug Snug con le maniche</strong>, poiché potrai fare tutto ciò che vuoi mentre sei coperto.</p><p>La coperta Snug Snug con le maniche è il regalo perfetto per qualsiasi occasione. Nessuno si aspetta un regalo così straordinario! Questa coperta con le maniche è l'ideale per quei momenti in cui fa freddo fuori e vuoi solo stare a casa a leggere, usare il computer o semplicemente a rilassarti sul divano. Fino ad ora, non è era facile raggomitolarsi sul divano e voler fare qualcosa, perché dovevi tirare fuori le braccia, ma faceva freddo. Tutto questo è finito grazie alla coperta Kangoo Snug Snug con le maniche!</p><p>Caratteristiche:</p><ul><li>Fatto di pile ultra soffice</li><li>Lavabile in lavatrice</li><li>Dimensioni: lunghezza 185 cm, larghezza 160 cm | Dimensioni manica: 60cm</li></ul><p><a title=""Batamanta SnugSnug"" href=""http://www.snugsnug.com"" target=""_blank"">www.snugsnug.com</a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio): </br><ul><li>Altezza: 28 Cm</li><li>Larghezza: 26 Cm</li><li>Profondita': 12 Cm</li><li>Peso: 0.8 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102731</p>","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""> Maggiori Informazioni</a>",0.8,1,"Taxable Goods","Catalog, Search",22.9,,,,OUTLET-Coperta-super-soffice-Kangoo-Snug-Snug-con-maniche-per-adulti-|-Decorazioni-originali-(Senza-imballaggio)-Commando,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Commando","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Commando,Commando,","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali",http://dropshipping.bigbuy.eu/imgs/J2000065_91719.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000065_91719.jpg,,,,,,"2016-02-11 15:49:04",,,,,,,,,,,,,,,,,"GTIN=4899888102731",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000065_91715.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91718.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91717.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91716.jpg","GTIN=4899888102731",,,,,,,,,, +BB-J2000240,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Galaktic","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""><p><strong>Acquista</a> la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali</strong>.<strong> </strong>La <strong>coperta Snug Snug con le maniche</strong> ti riscalderà senza aver bisogno del riscaldamento per tutto il giorno. Risparmierai i soldi e starai comodo grazie alla <strong>coperta extra morbida Kangoo Snug Snug con le maniche</strong>, poiché potrai fare tutto ciò che vuoi mentre sei coperto.</p><p>La coperta Snug Snug con le maniche è il regalo perfetto per qualsiasi occasione. Nessuno si aspetta un regalo così straordinario! Questa coperta con le maniche è l'ideale per quei momenti in cui fa freddo fuori e vuoi solo stare a casa a leggere, usare il computer o semplicemente a rilassarti sul divano. Fino ad ora, non è era facile raggomitolarsi sul divano e voler fare qualcosa, perché dovevi tirare fuori le braccia, ma faceva freddo. Tutto questo è finito grazie alla coperta Kangoo Snug Snug con le maniche!</p><p>Caratteristiche:</p><ul><li>Fatto di pile ultra soffice</li><li>Lavabile in lavatrice</li><li>Dimensioni: lunghezza 185 cm, larghezza 160 cm | Dimensioni manica: 60cm</li></ul><p><a title=""Batamanta SnugSnug"" href=""http://www.snugsnug.com"" target=""_blank"">www.snugsnug.com</a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio): </br><ul><li>Altezza: 28 Cm</li><li>Larghezza: 26 Cm</li><li>Profondita': 12 Cm</li><li>Peso: 0.8 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102731</p>","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""> Maggiori Informazioni</a>",0.8,1,"Taxable Goods","Catalog, Search",22.9,,,,OUTLET-Coperta-super-soffice-Kangoo-Snug-Snug-con-maniche-per-adulti-|-Decorazioni-originali-(Senza-imballaggio)-Galaktic,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Galaktic","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Galaktic,Galaktic,","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali",http://dropshipping.bigbuy.eu/imgs/J2000065_91718.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000065_91718.jpg,,,,,,"2016-02-11 15:49:04",,,,,,,,,,,,,,,,,"GTIN=4899888102731",14,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000065_91715.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91719.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91717.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91716.jpg","GTIN=4899888102731",,,,,,,,,, +BB-J2000329,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Lovely Blu","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""><p><strong>Acquista</a> la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali</strong>.<strong> </strong>La <strong>coperta Snug Snug con le maniche</strong> ti riscalderà senza aver bisogno del riscaldamento per tutto il giorno. Risparmierai i soldi e starai comodo grazie alla <strong>coperta extra morbida Kangoo Snug Snug con le maniche</strong>, poiché potrai fare tutto ciò che vuoi mentre sei coperto.</p><p>La coperta Snug Snug con le maniche è il regalo perfetto per qualsiasi occasione. Nessuno si aspetta un regalo così straordinario! Questa coperta con le maniche è l'ideale per quei momenti in cui fa freddo fuori e vuoi solo stare a casa a leggere, usare il computer o semplicemente a rilassarti sul divano. Fino ad ora, non è era facile raggomitolarsi sul divano e voler fare qualcosa, perché dovevi tirare fuori le braccia, ma faceva freddo. Tutto questo è finito grazie alla coperta Kangoo Snug Snug con le maniche!</p><p>Caratteristiche:</p><ul><li>Fatto di pile ultra soffice</li><li>Lavabile in lavatrice</li><li>Dimensioni: lunghezza 185 cm, larghezza 160 cm | Dimensioni manica: 60cm</li></ul><p><a title=""Batamanta SnugSnug"" href=""http://www.snugsnug.com"" target=""_blank"">www.snugsnug.com</a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio): </br><ul><li>Altezza: 28 Cm</li><li>Larghezza: 26 Cm</li><li>Profondita': 12 Cm</li><li>Peso: 0.8 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102731</p>","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio)""> Maggiori Informazioni</a>",0.8,1,"Taxable Goods","Catalog, Search",22.9,,,,OUTLET-Coperta-super-soffice-Kangoo-Snug-Snug-con-maniche-per-adulti-|-Decorazioni-originali-(Senza-imballaggio)-Lovely Blu,"OUTLET Coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali (Senza imballaggio) Lovely Blu","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Lovely Blu,Lovely Blu,","Acquista la coperta super soffice Kangoo Snug Snug con maniche per adulti | Decorazioni originali",http://dropshipping.bigbuy.eu/imgs/J2000065_91717.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000065_91717.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=4899888102731",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000065_91715.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91719.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91718.jpg,http://dropshipping.bigbuy.eu/imgs/J2000065_91716.jpg","GTIN=4899888102731",,,,,,,,,, +BB-J2000085,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio) Rosa S","<a id=""maggiorni_informazioni"" title=""OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio)""><p>Acquista</a> <strong>Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo.</strong> Scopri il modo più facile per mantenerti caldo a casa quando c'è freddo! Devi solo riscaldare questi <strong>stivali rilassanti nel microonde</strong> per godere immediatamente del calore e del rilassamento ai tuoi piedi. Sono fatti di morbido tessuto polare e di un rivestimento in semi di lavanda che emana un <strong>profumo straordinario</strong> quando riscaldato. Goditi semplicemente il profumo rilassante! I Warm Hug Feet Stivali Riscaldabili al Microonde mantengono il calore per lungo tempo grazie ai semi di lavanda contenuti all'interno. Per pulire questi stivali riscaldabili, passaci sopra un panno umido. Non riscaldarmi mai per più di 2 minuti e non usarli se sono troppo caldi. Disponibili in rosa e blu.</p><p>Taglie (equivalenze approssimative)</p><ul><li>S (35 a 38)</li><li>M (38 a 41)</li><li>L (41 a 43)</li></ul><p>Rispettare sempre i limiti nel microonde</p><ul><li>800W</li><li>80ºC</li><li>990 sec</li></ul><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio): </br><ul><li>Altezza: 27 Cm</li><li>Larghezza: 10.5 Cm</li><li>Profondita': 29 Cm</li><li>Peso: 0.67 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899881028786</p>","Acquista Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio)""> Maggiori Informazioni</a>",0.67,1,"Taxable Goods","Catalog, Search",33.5,,,,OUTLET-Warm-Hug-Feet-Stivali-Riscaldabili-al-Microonde-(Senza-imballaggio)-Rosa-S,"OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio) Rosa S","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Rosa,Rosa,Taglia S,S,","Acquista Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-00.jpg,,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-00.jpg,,,,,,"2016-02-02 10:45:18",,,,,,,,,,,,,,,,,"GTIN=4899881028786",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-03.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-01.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-02.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-04.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-05.jpg","GTIN=4899881028786",,,,,,,,,, +BB-J2000086,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio) Viola M","<a id=""maggiorni_informazioni"" title=""OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio)""><p>Acquista</a> <strong>Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo.</strong> Scopri il modo più facile per mantenerti caldo a casa quando c'è freddo! Devi solo riscaldare questi <strong>stivali rilassanti nel microonde</strong> per godere immediatamente del calore e del rilassamento ai tuoi piedi. Sono fatti di morbido tessuto polare e di un rivestimento in semi di lavanda che emana un <strong>profumo straordinario</strong> quando riscaldato. Goditi semplicemente il profumo rilassante! I Warm Hug Feet Stivali Riscaldabili al Microonde mantengono il calore per lungo tempo grazie ai semi di lavanda contenuti all'interno. Per pulire questi stivali riscaldabili, passaci sopra un panno umido. Non riscaldarmi mai per più di 2 minuti e non usarli se sono troppo caldi. Disponibili in rosa e blu.</p><p>Taglie (equivalenze approssimative)</p><ul><li>S (35 a 38)</li><li>M (38 a 41)</li><li>L (41 a 43)</li></ul><p>Rispettare sempre i limiti nel microonde</p><ul><li>800W</li><li>80ºC</li><li>990 sec</li></ul><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio): </br><ul><li>Altezza: 27 Cm</li><li>Larghezza: 10.5 Cm</li><li>Profondita': 29 Cm</li><li>Peso: 0.67 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899881028786</p>","Acquista Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio)""> Maggiori Informazioni</a>",0.67,1,"Taxable Goods","Catalog, Search",33.5,,,,OUTLET-Warm-Hug-Feet-Stivali-Riscaldabili-al-Microonde-(Senza-imballaggio)-Viola-M,"OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio) Viola M","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Viola,Viola,Taglia M,M,","Acquista Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-03.jpg,,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-03.jpg,,,,,,"2016-02-02 10:45:18",,,,,,,,,,,,,,,,,"GTIN=4899881028786",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-00.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-01.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-02.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-04.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-05.jpg","GTIN=4899881028786",,,,,,,,,, +BB-J2000188,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio) Viola L","<a id=""maggiorni_informazioni"" title=""OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio)""><p>Acquista</a> <strong>Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo.</strong> Scopri il modo più facile per mantenerti caldo a casa quando c'è freddo! Devi solo riscaldare questi <strong>stivali rilassanti nel microonde</strong> per godere immediatamente del calore e del rilassamento ai tuoi piedi. Sono fatti di morbido tessuto polare e di un rivestimento in semi di lavanda che emana un <strong>profumo straordinario</strong> quando riscaldato. Goditi semplicemente il profumo rilassante! I Warm Hug Feet Stivali Riscaldabili al Microonde mantengono il calore per lungo tempo grazie ai semi di lavanda contenuti all'interno. Per pulire questi stivali riscaldabili, passaci sopra un panno umido. Non riscaldarmi mai per più di 2 minuti e non usarli se sono troppo caldi. Disponibili in rosa e blu.</p><p>Taglie (equivalenze approssimative)</p><ul><li>S (35 a 38)</li><li>M (38 a 41)</li><li>L (41 a 43)</li></ul><p>Rispettare sempre i limiti nel microonde</p><ul><li>800W</li><li>80ºC</li><li>990 sec</li></ul><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio): </br><ul><li>Altezza: 27 Cm</li><li>Larghezza: 10.5 Cm</li><li>Profondita': 29 Cm</li><li>Peso: 0.67 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899881028786</p>","Acquista Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio)""> Maggiori Informazioni</a>",0.67,1,"Taxable Goods","Catalog, Search",33.5,,,,OUTLET-Warm-Hug-Feet-Stivali-Riscaldabili-al-Microonde-(Senza-imballaggio)-Viola-L,"OUTLET Warm Hug Feet Stivali Riscaldabili al Microonde (Senza imballaggio) Viola L","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Viola,Viola,Taglia L,L,","Acquista Warm Hug Feet Stivali Riscaldabili al Microonde al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-01.jpg,,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-01.jpg,,,,,,"2016-02-02 10:45:18",,,,,,,,,,,,,,,,,"GTIN=4899881028786",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-00.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-03.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-02.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-04.jpg,http://dropshipping.bigbuy.eu/imgs/warm-hugh-feet-boots-05.jpg","GTIN=4899881028786",,,,,,,,,, +BB-J2000123,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Macchina per Gelato Princess 282602 (Senza imballaggio)","<a id=""maggiorni_informazioni"" title=""OUTLET Macchina per Gelato Princess 282602 (Senza imballaggio)""><p>Avviso</a> per gli amanti del gelato: ecco la nuovissima <strong>macchina per gelato</strong> <strong>Princess 282602</strong>. Una <strong>gelatiera</strong> diversa dalle altre, che ti permetterà di preparare in un batter d'occhio gelati dolci o salati per i grandi e i più piccini!<strong> </strong>Al naturale o con salsa di frutta, pepite o qualsiasi altra guarnizione...le tue papille ti ringrazieranno! Indispensabile per un'estate al fresco!</p><p>Basta mettere in freezer, per 12 ore circa, il recipiente rimovibile a forma di secchiello con manico integrato di 17,5 x 14,5 cm (diametro x altezza) ed è fatto! Il gelato 100% naturale è servito!</p><p>Caratteristiche:</p><ul><li>Potenza: 5 W</li><li>Frequenza: 50 Hz</li><li>Tensione: 220-240 V</li><li>Pulsante On/Off </li><li>Gommini antiscivolo</li><li>Scomparto per cavo di alimentazione</li><li>Mescolatore rimovibile</li><li>Facile da pulire</li><li>Apertura di riempimento (dimensioni approssimative: 5 x 5,5 cm)</li><li>Dimensioni approssimative: 22 x 25 x 22 cm</li></ul><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Macchina per Gelato Princess 282602 (Senza imballaggio): </br><ul><li>Altezza: 31 Cm</li><li>Larghezza: 23.4 Cm</li><li>Profondita': 23.5 Cm</li><li>Peso: 3.26 Kg</li></ul></p><p>Codice Prodotto (EAN): 8712836304895</p>","Avviso per gli amanti del gelato: ecco la nuovissima macchina per gelato Princess 282602.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Macchina per Gelato Princess 282602 (Senza imballaggio)""> Maggiori Informazioni</a>",3.26,1,"Taxable Goods","Catalog, Search",110.88,,,,OUTLET-Macchina-per-Gelato-Princess-282602-(Senza-imballaggio),"OUTLET Macchina per Gelato Princess 282602 (Senza imballaggio)","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,","Avviso per gli amanti del gelato: ecco la nuovissima macchina per gelato Princess 282602",http://dropshipping.bigbuy.eu/imgs/J2000123_88610.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000123_88610.jpg,,,,,,"2016-08-09 01:54:36",,,,,,,,,,,,,,,,,"GTIN=8712836304895",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000123_88612.jpg,http://dropshipping.bigbuy.eu/imgs/J2000123_88611.jpg","GTIN=8712836304895",,,,,,,,,, +BB-J2000176,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Celeste","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""><p><strong>Acquistare</a> Coperta Eden Deluxe </strong>160 x 240 al miglior prezzo<strong>. </strong>Questa fantastica <strong>coperta Eden Deluxe </strong>è perfetta per il tuo letto. La <strong>coperta Eden Dexuxe </strong>misura 160 x 240 cm. Questa coperta è veramente soffice e confortevole. La coperta Eden Deluxe è l'ideale per godere di un caldo inverno. 100% poliestere. Con comoda maniglia da trasporto.</p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio): </br><ul><li>Altezza: 43 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 51.5 Cm</li><li>Peso: 2.183 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436045510426</p>","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""> Maggiori Informazioni</a>",2.183,1,"Taxable Goods","Catalog, Search",57.9,,,,OUTLET-Coperta-Eden-Deluxe-160-x-240-(Senza-imballaggio)-Celeste,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Celeste","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Design Celeste,Celeste,","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,,,,,,"2016-02-17 15:45:14",,,,,,,,,,,,,,,,,"GTIN=8436045510426",5,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg","GTIN=8436045510426",,,,,,,,,, +BB-J2000178,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Rosso","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""><p><strong>Acquistare</a> Coperta Eden Deluxe </strong>160 x 240 al miglior prezzo<strong>. </strong>Questa fantastica <strong>coperta Eden Deluxe </strong>è perfetta per il tuo letto. La <strong>coperta Eden Dexuxe </strong>misura 160 x 240 cm. Questa coperta è veramente soffice e confortevole. La coperta Eden Deluxe è l'ideale per godere di un caldo inverno. 100% poliestere. Con comoda maniglia da trasporto.</p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio): </br><ul><li>Altezza: 43 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 51.5 Cm</li><li>Peso: 2.183 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436045510426</p>","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""> Maggiori Informazioni</a>",2.183,1,"Taxable Goods","Catalog, Search",57.9,,,,OUTLET-Coperta-Eden-Deluxe-160-x-240-(Senza-imballaggio)-Rosso,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Rosso","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Design Rosso,Rosso,","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,,,,,,"2016-02-17 15:45:14",,,,,,,,,,,,,,,,,"GTIN=8436045510426",3,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg","GTIN=8436045510426",,,,,,,,,, +BB-J2000179,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Cioccolato","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""><p><strong>Acquistare</a> Coperta Eden Deluxe </strong>160 x 240 al miglior prezzo<strong>. </strong>Questa fantastica <strong>coperta Eden Deluxe </strong>è perfetta per il tuo letto. La <strong>coperta Eden Dexuxe </strong>misura 160 x 240 cm. Questa coperta è veramente soffice e confortevole. La coperta Eden Deluxe è l'ideale per godere di un caldo inverno. 100% poliestere. Con comoda maniglia da trasporto.</p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio): </br><ul><li>Altezza: 43 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 51.5 Cm</li><li>Peso: 2.183 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436045510426</p>","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""> Maggiori Informazioni</a>",2.183,1,"Taxable Goods","Catalog, Search",57.9,,,,OUTLET-Coperta-Eden-Deluxe-160-x-240-(Senza-imballaggio)-Cioccolato,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Cioccolato","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Design Cioccolato,Cioccolato,","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,,,,,,"2016-02-17 15:45:14",,,,,,,,,,,,,,,,,"GTIN=8436045510426",5,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg","GTIN=8436045510426",,,,,,,,,, +BB-J2000180,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Crudo","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""><p><strong>Acquistare</a> Coperta Eden Deluxe </strong>160 x 240 al miglior prezzo<strong>. </strong>Questa fantastica <strong>coperta Eden Deluxe </strong>è perfetta per il tuo letto. La <strong>coperta Eden Dexuxe </strong>misura 160 x 240 cm. Questa coperta è veramente soffice e confortevole. La coperta Eden Deluxe è l'ideale per godere di un caldo inverno. 100% poliestere. Con comoda maniglia da trasporto.</p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio): </br><ul><li>Altezza: 43 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 51.5 Cm</li><li>Peso: 2.183 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436045510426</p>","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""> Maggiori Informazioni</a>",2.183,1,"Taxable Goods","Catalog, Search",57.9,,,,OUTLET-Coperta-Eden-Deluxe-160-x-240-(Senza-imballaggio)-Crudo,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Crudo","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Design Crudo,Crudo,","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,,,,,,"2016-02-17 15:45:14",,,,,,,,,,,,,,,,,"GTIN=8436045510426",9,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg","GTIN=8436045510426",,,,,,,,,, +BB-J2000181,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Fragola","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""><p><strong>Acquistare</a> Coperta Eden Deluxe </strong>160 x 240 al miglior prezzo<strong>. </strong>Questa fantastica <strong>coperta Eden Deluxe </strong>è perfetta per il tuo letto. La <strong>coperta Eden Dexuxe </strong>misura 160 x 240 cm. Questa coperta è veramente soffice e confortevole. La coperta Eden Deluxe è l'ideale per godere di un caldo inverno. 100% poliestere. Con comoda maniglia da trasporto.</p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio): </br><ul><li>Altezza: 43 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 51.5 Cm</li><li>Peso: 2.183 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436045510426</p>","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""> Maggiori Informazioni</a>",2.183,1,"Taxable Goods","Catalog, Search",57.9,,,,OUTLET-Coperta-Eden-Deluxe-160-x-240-(Senza-imballaggio)-Fragola,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Fragola","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Design Fragola,Fragola,","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,,,,,,"2016-02-17 15:45:14",,,,,,,,,,,,,,,,,"GTIN=8436045510426",8,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg","GTIN=8436045510426",,,,,,,,,, +BB-J2000182,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Cardinale","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""><p><strong>Acquistare</a> Coperta Eden Deluxe </strong>160 x 240 al miglior prezzo<strong>. </strong>Questa fantastica <strong>coperta Eden Deluxe </strong>è perfetta per il tuo letto. La <strong>coperta Eden Dexuxe </strong>misura 160 x 240 cm. Questa coperta è veramente soffice e confortevole. La coperta Eden Deluxe è l'ideale per godere di un caldo inverno. 100% poliestere. Con comoda maniglia da trasporto.</p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio): </br><ul><li>Altezza: 43 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 51.5 Cm</li><li>Peso: 2.183 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436045510426</p>","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""> Maggiori Informazioni</a>",2.183,1,"Taxable Goods","Catalog, Search",57.9,,,,OUTLET-Coperta-Eden-Deluxe-160-x-240-(Senza-imballaggio)-Cardinale,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Cardinale","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Design Cardinale,Cardinale,","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg,,,,,,"2016-02-17 15:45:14",,,,,,,,,,,,,,,,,"GTIN=8436045510426",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg","GTIN=8436045510426",,,,,,,,,, +BB-J2000247,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Turchese","<a id=""maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""><p><strong>Acquistare</a> Coperta Eden Deluxe </strong>160 x 240 al miglior prezzo<strong>. </strong>Questa fantastica <strong>coperta Eden Deluxe </strong>è perfetta per il tuo letto. La <strong>coperta Eden Dexuxe </strong>misura 160 x 240 cm. Questa coperta è veramente soffice e confortevole. La coperta Eden Deluxe è l'ideale per godere di un caldo inverno. 100% poliestere. Con comoda maniglia da trasporto.</p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio): </br><ul><li>Altezza: 43 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 51.5 Cm</li><li>Peso: 2.183 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436045510426</p>","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio)""> Maggiori Informazioni</a>",2.183,1,"Taxable Goods","Catalog, Search",57.9,,,,OUTLET-Coperta-Eden-Deluxe-160-x-240-(Senza-imballaggio)-Turchese,"OUTLET Coperta Eden Deluxe 160 x 240 (Senza imballaggio) Turchese","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Design Turchese,Turchese,","Acquistare Coperta Eden Deluxe 160 x 240 al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000175_88982.jpg,,,,,,"2016-02-17 15:45:14",,,,,,,,,,,,,,,,,"GTIN=8436045510426",7,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000175_88981.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88987.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88986.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88985.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88984.jpg,http://dropshipping.bigbuy.eu/imgs/J2000175_88983.jpg","GTIN=8436045510426",,,,,,,,,, +BB-J2000177,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Macchina RC Ferrari 599 GTO (Senza imballaggio)","<a id=""maggiorni_informazioni"" title=""OUTLET Macchina RC Ferrari 599 GTO (Senza imballaggio)""><p>Hai</a> visto la <strong>macchina RC Ferrari 599 GTO</strong>? Puoi facilmente usare questo divertente giocattolo<strong> autorizzato Ferrari</strong> grazie al suo telecomando. La macchina funziona a batterie 5 x AA (non incluse) e il telecomando a batteria 1 x 6F22 9V (non inclusa). Giocattolo adatto per bambini di età superiore ai 6 anni. Funzioni della macchina radiocontrollata Ferrari:</p><ul><li>Avanti e indietro</li><li>Gira a sinistra e a destra</li><li>Fari e Fanali posteriori</li></ul><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Macchina RC Ferrari 599 GTO (Senza imballaggio): </br><ul><li>Altezza: 17.5 Cm</li><li>Larghezza: 43.5 Cm</li><li>Profondita': 22.7 Cm</li><li>Peso: 1.244 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158011299</p>","Hai visto la macchina RC Ferrari 599 GTO? Puoi facilmente usare questo divertente giocattolo autorizzato Ferrari grazie al suo telecomando.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Macchina RC Ferrari 599 GTO (Senza imballaggio)""> Maggiori Informazioni</a>",1.244,1,"Taxable Goods","Catalog, Search",119,,,,OUTLET-Macchina-RC-Ferrari-599-GTO--(Senza-imballaggio),"OUTLET Macchina RC Ferrari 599 GTO (Senza imballaggio)","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,","Hai visto la macchina RC Ferrari 599 GTO? Puoi facilmente usare questo divertente giocattolo autorizzato Ferrari grazie al suo telecomando",http://dropshipping.bigbuy.eu/imgs/J2000177_89020.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000177_89020.jpg,,,,,,"2016-07-22 06:19:54",,,,,,,,,,,,,,,,,"GTIN=8718158011299",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000177_89022.jpg,http://dropshipping.bigbuy.eu/imgs/J2000177_89021.jpg","GTIN=8718158011299",,,,,,,,,, +BB-J2000255,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio) Beige S","<a id=""maggiorni_informazioni"" title=""OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio)""><p>Metti</a> in luce un corpo scultoreo con la <strong>Canottiera Modellante con Reggiseno Booby & Tummy! </strong>Discreta, comod. Sostiene il seno e offre una grande capacità di sostegno. Prodotta il un tessuto delicato, flessibile e traspirante che si adatta perfettamente al tuo corpo e offre una copertura completa (seno, fianchi e glutei).  Equivalenza di taglie: S: 36-38, M: 38-40, L: 40-42.</p><p><a href=""http://www.boobyandtummy.com/"" target=""_blank""><strong>www.boobyandtummy.com</strong></a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio): </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 13.5 Cm</li><li>Profondita': 7 Cm</li><li>Peso: 0.14 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888101352</p>","Metti in luce un corpo scultoreo con la Canottiera Modellante con Reggiseno Booby & Tummy! Discreta, comod.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio)""> Maggiori Informazioni</a>",0.14,1,"Taxable Goods","Catalog, Search",23.1,,,,OUTLET-Canottiera-Modellante-con-Reggiseno-Booby-&-Tummy--(Senza-imballaggio)-Beige-S,"OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio) Beige S","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Beige,Beige,Taglia S,S,","Metti in luce un corpo scultoreo con la Canottiera Modellante con Reggiseno Booby & Tummy! Discreta, comod",http://dropshipping.bigbuy.eu/imgs/J2000254_89903.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000254_89903.jpg,,,,,,"2016-02-12 08:36:52",,,,,,,,,,,,,,,,,"GTIN=4899888101352",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000254_89906.jpg,http://dropshipping.bigbuy.eu/imgs/J2000254_89905.jpg,http://dropshipping.bigbuy.eu/imgs/J2000254_89904.jpg","GTIN=4899888101352",,,,,,,,,, +BB-J2000285,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio) Beige L","<a id=""maggiorni_informazioni"" title=""OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio)""><p>Metti</a> in luce un corpo scultoreo con la <strong>Canottiera Modellante con Reggiseno Booby & Tummy! </strong>Discreta, comod. Sostiene il seno e offre una grande capacità di sostegno. Prodotta il un tessuto delicato, flessibile e traspirante che si adatta perfettamente al tuo corpo e offre una copertura completa (seno, fianchi e glutei).  Equivalenza di taglie: S: 36-38, M: 38-40, L: 40-42.</p><p><a href=""http://www.boobyandtummy.com/"" target=""_blank""><strong>www.boobyandtummy.com</strong></a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio): </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 13.5 Cm</li><li>Profondita': 7 Cm</li><li>Peso: 0.14 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888101352</p>","Metti in luce un corpo scultoreo con la Canottiera Modellante con Reggiseno Booby & Tummy! Discreta, comod.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio)""> Maggiori Informazioni</a>",0.14,1,"Taxable Goods","Catalog, Search",23.1,,,,OUTLET-Canottiera-Modellante-con-Reggiseno-Booby-&-Tummy--(Senza-imballaggio)-Beige-L,"OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio) Beige L","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Beige,Beige,Taglia L,L,","Metti in luce un corpo scultoreo con la Canottiera Modellante con Reggiseno Booby & Tummy! Discreta, comod",http://dropshipping.bigbuy.eu/imgs/J2000254_89906.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000254_89906.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=4899888101352",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000254_89903.jpg,http://dropshipping.bigbuy.eu/imgs/J2000254_89905.jpg,http://dropshipping.bigbuy.eu/imgs/J2000254_89904.jpg","GTIN=4899888101352",,,,,,,,,, +BB-J2000279,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio) Beige M","<a id=""maggiorni_informazioni"" title=""OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio)""><p>Metti</a> in luce un corpo scultoreo con la <strong>Canottiera Modellante con Reggiseno Booby & Tummy! </strong>Discreta, comod. Sostiene il seno e offre una grande capacità di sostegno. Prodotta il un tessuto delicato, flessibile e traspirante che si adatta perfettamente al tuo corpo e offre una copertura completa (seno, fianchi e glutei).  Equivalenza di taglie: S: 36-38, M: 38-40, L: 40-42.</p><p><a href=""http://www.boobyandtummy.com/"" target=""_blank""><strong>www.boobyandtummy.com</strong></a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio): </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 13.5 Cm</li><li>Profondita': 7 Cm</li><li>Peso: 0.14 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888101352</p>","Metti in luce un corpo scultoreo con la Canottiera Modellante con Reggiseno Booby & Tummy! Discreta, comod.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio)""> Maggiori Informazioni</a>",0.14,1,"Taxable Goods","Catalog, Search",23.1,,,,OUTLET-Canottiera-Modellante-con-Reggiseno-Booby-&-Tummy--(Senza-imballaggio)-Beige-M,"OUTLET Canottiera Modellante con Reggiseno Booby & Tummy (Senza imballaggio) Beige M","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Colore Beige,Beige,Taglia M,M,","Metti in luce un corpo scultoreo con la Canottiera Modellante con Reggiseno Booby & Tummy! Discreta, comod",http://dropshipping.bigbuy.eu/imgs/J2000254_89905.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000254_89905.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=4899888101352",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000254_89903.jpg,http://dropshipping.bigbuy.eu/imgs/J2000254_89906.jpg,http://dropshipping.bigbuy.eu/imgs/J2000254_89904.jpg","GTIN=4899888101352",,,,,,,,,, +BB-J2000264,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Scopa elettrica triangolare senza fili 360 Sweep (Senza imballaggio)","<a id=""maggiorni_informazioni"" title=""OUTLET Scopa elettrica triangolare senza fili 360 Sweep (Senza imballaggio)""><p>La <strong>scopa</a> elettrica triangolare 360 Sweep</strong><strong> </strong>è perfetta per pulire in modo facile, rapido ed efficace. Grazie alla sua tecnologia innovativa, le setole ruotano automaticamente per rimuovere a fondo tutto lo sporco. Inoltre, questa <strong>scopa elettrica</strong> è leggera e facile da usare, il che la rende molto comoda e pratica. La scopa elettrica Sweep ruota di 360º e raggiunge facilmente qualsiasi angolo di casa.</p><p>Prova la nuova scopa elettrica triangolare 360 Sweep e scopri un modo migliore per pulire!</p><p>Caratteristiche della Scopa elettrica triangolare 360 Sweep:</p><ul><li>Scopa elettrica senza fili</li><li>Setole rotanti</li><li>Bastone in alluminio removibile (c.ca 115cm)</li><li>Base piatta triangolare (3 lati identici: circa 33cm di lunghezza x 3cm di altezza)</li><li>Batteria ricaricabile  7.2V</li><li>Caricabatteria (230V, 50Hz)</li><li>Durata batteria: Approx. 30 min</li><li>Scopartimento interno raccogli-polvere</li><li>Leggera, facile da usare, svuotare e pulire</li><li>Estremamente silenziosa</li></ul><p> <a title=""Escoba Eléctrica Sweep 360"" href=""http://www.360sweep.com/"" target=""_blank"">www.360sweep.com</a></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Scopa elettrica triangolare senza fili 360 Sweep (Senza imballaggio): </br><ul><li>Altezza: 32 Cm</li><li>Larghezza: 39.5 Cm</li><li>Profondita': 9.5 Cm</li><li>Peso: 1.625 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888102458</p>","La scopa elettrica triangolare 360 Sweep è perfetta per pulire in modo facile, rapido ed efficace.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Scopa elettrica triangolare senza fili 360 Sweep (Senza imballaggio)""> Maggiori Informazioni</a>",1.625,1,"Taxable Goods","Catalog, Search",89.9,,,,OUTLET-Scopa-elettrica-triangolare-senza-fili-360-Sweep-(Senza-imballaggio),"OUTLET Scopa elettrica triangolare senza fili 360 Sweep (Senza imballaggio)","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,","La scopa elettrica triangolare 360 Sweep è perfetta per pulire in modo facile, rapido ed efficace",http://dropshipping.bigbuy.eu/imgs/J2000264_89527.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000264_89527.jpg,,,,,,"2016-09-01 06:15:11",,,,,,,,,,,,,,,,,"GTIN=4899888102458",2,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000264_89533.jpg,http://dropshipping.bigbuy.eu/imgs/J2000264_89532.jpg,http://dropshipping.bigbuy.eu/imgs/J2000264_89531.jpg,http://dropshipping.bigbuy.eu/imgs/J2000264_89530.jpg,http://dropshipping.bigbuy.eu/imgs/J2000264_89529.jpg,http://dropshipping.bigbuy.eu/imgs/J2000264_89528.jpg","GTIN=4899888102458",,,,,,,,,, +BB-J2000291,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Sabbia Kinetic per Bambini Playz Kidz (Senza imballaggio)","<a id=""maggiorni_informazioni"" title=""OUTLET Sabbia Kinetic per Bambini Playz Kidz (Senza imballaggio)""><p>Porta</a> la spiaggia a casa con la <strong>sabbia kinetic per bambini Playz Kidz</strong>! Il regalo perfetto per sorprendere i tuoi piccoli, che si divertiranno a costruire ogni tipo di castello e forma di sabbia. Questo divertente ed originale gioco sviluppa i talenti artistici e la creatività dei bambini. Comprende circa 0,5 kg di sabbia kinetic e 3 accessori in plastica: un rullo, una forcella e una spatola. Non tossico. Non macchia i vestiti o si attacca alle mani. Età consigliata: 3+ anni</p><p><strong><a href=""http://www.playzkidz.com"">www.playzkidz.com</a></strong></p><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Sabbia Kinetic per Bambini Playz Kidz (Senza imballaggio): </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 14 Cm</li><li>Profondita': 14 Cm</li><li>Peso: 0.65 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888108085</p>","Porta la spiaggia a casa con la sabbia kinetic per bambini Playz Kidz! Il regalo perfetto per sorprendere i tuoi piccoli, che si divertiranno a costruire ogni tipo di castello e forma di sabbia.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Sabbia Kinetic per Bambini Playz Kidz (Senza imballaggio)""> Maggiori Informazioni</a>",0.65,1,"Taxable Goods","Catalog, Search",11.9,,,,OUTLET-Sabbia-Kinetic-per-Bambini-Playz-Kidz--(Senza-imballaggio),"OUTLET Sabbia Kinetic per Bambini Playz Kidz (Senza imballaggio)","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,","Porta la spiaggia a casa con la sabbia kinetic per bambini Playz Kidz! Il regalo perfetto per sorprendere i tuoi piccoli, che si divertiranno a costruire ogni tipo di castello e forma di sabbia",http://dropshipping.bigbuy.eu/imgs/J2000291_90163.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000291_90163.jpg,,,,,,"2016-08-09 01:48:11",,,,,,,,,,,,,,,,,"GTIN=4899888108085",778,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000291_90165.jpg,http://dropshipping.bigbuy.eu/imgs/J2000291_90164.jpg","GTIN=4899888108085",,,,,,,,,, +BB-J2000350,,Default,simple,"Default Category/Outlet Offerte,Default Category/Outlet Offerte/Senza imballaggio",base,"OUTLET Reggiseno Crochet Bra (3 Pezzi) (Senza imballaggio) S","<a id=""maggiorni_informazioni"" title=""OUTLET Reggiseno Crochet Bra (3 Pezzi) (Senza imballaggio)""><p><strong>Acquista</a> il Reggiseno Crochet </strong>(3 pezzi)<strong> al miglior prezzo. </strong>Sentiti fantastica e sexy per tutto il giorno! Dimentica i fili, fascette o spalline. Il Crochet Bra utilizza la tecnologia <em>Woven Everlast</em> per un comfort massimo. Questi reggiseni sono stati elaborati senza tutti quegli elementi in modo da essere usato comodamente dimenticando che lo stai indossando. Questo reggiseno si adatta al tuo seno, sollevandolo e sostenendolo. Il Crochet Bra si adatta perfettamente al tuo corpo e forma , senza lasciare segni o pieghe. In più è morbido, flessibile e molto comodo, e si adatta ad ogni coppa, sollevando il tio seno in modo significativo.</p><p><a href=""http://www.crochetbra.com"" target=""_blank"">www.crochetbra.com</a><br /><strong><br />Caratteristiche:</strong></p><ul><li>Lavabile in lavatrice</li><li>Bretelle comode</li><li>Fatto al 96% di nylon e 4% di spandex</li><li>Adattabile alla forma del tuo seno</li><li>Solleva il tuo seno</li><li>La confezione include 3 reggiseni (1 beige, 1 nero and 1 bianco)</li><li>Equivalenza taglie appross.: S: 80/85 - M: 90/95 - L: 100/105</li></ul><p><strong>NOTA: Questo prodotto presenta lievi danni estetici, quali graffi o sfregature, che non influiscono sul suo funzionamento. Spedito in confezione standard.</strong></p><p> Dimenzioni per OUTLET Reggiseno Crochet Bra (3 Pezzi) (Senza imballaggio): </br><ul><li>Altezza: 4.5 Cm</li><li>Larghezza: 15.5 Cm</li><li>Profondita': 27 Cm</li><li>Peso: 0.269 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888101345</p>","Acquista il Reggiseno Crochet (3 pezzi) al miglior prezzo.</br><a href=""#maggiorni_informazioni"" title=""OUTLET Reggiseno Crochet Bra (3 Pezzi) (Senza imballaggio)""> Maggiori Informazioni</a>",0.269,1,"Taxable Goods","Catalog, Search",34.9,,,,OUTLET-Reggiseno-Crochet-Bra-(3-Pezzi)-(Senza-imballaggio)-S,"OUTLET Reggiseno Crochet Bra (3 Pezzi) (Senza imballaggio) S","Outlet Offerte,Outlet,Offerte,Senza imballaggio,Senza,imballaggio,Taglia S,S,","Acquista il Reggiseno Crochet (3 pezzi) al miglior prezzo",http://dropshipping.bigbuy.eu/imgs/J2000349_93435.jpg,,http://dropshipping.bigbuy.eu/imgs/J2000349_93435.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=4899888101345",8,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/J2000349_93438.jpg,http://dropshipping.bigbuy.eu/imgs/J2000349_93437.jpg,http://dropshipping.bigbuy.eu/imgs/J2000349_93436.jpg","GTIN=4899888101345",,,,,,,,,, diff --git a/dev/tests/acceptance/tests/_data/BB-ProductsWorking.csv b/dev/tests/acceptance/tests/_data/BB-ProductsWorking.csv new file mode 100644 index 0000000000000..0ea052a043526 --- /dev/null +++ b/dev/tests/acceptance/tests/_data/BB-ProductsWorking.csv @@ -0,0 +1,29 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,swatch_image,swatch_image_label,created_at,updated_at,new_from_date,new_to_date,display_product_options_in,map_price,msrp_price,map_enabled,gift_message_available,custom_design,custom_design_from,custom_design_to,custom_layout_update,page_layout,product_options_container,msrp_display_actual_price_type,country_of_manufacture,additional_attributes,qty,out_of_stock_qty,use_config_min_qty,is_qty_decimal,allow_backorders,use_config_backorders,min_cart_qty,use_config_min_sale_qty,max_cart_qty,use_config_max_sale_qty,is_in_stock,notify_on_stock_below,use_config_notify_stock_qty,manage_stock,use_config_manage_stock,use_config_qty_increments,qty_increments,use_config_enable_qty_inc,enable_qty_increments,is_decimal_divided,website_id,related_skus,related_position,crosssell_skus,crosssell_position,upsell_skus,upsell_position,additional_images,additional_image_labels,hide_from_product_page,bundle_price_type,bundle_sku_type,bundle_price_view,bundle_weight_type,bundle_values,bundle_shipment_type,configurable_variations,configurable_variation_labels,associated_skus +BB-D2010129,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Sistemi di Climatizzazione,Default Category/Casa Giardino/Sistemi di Climatizzazione/Aria condizionata e ventilatori",base,"Ventilatore Portatile Spray FunFan Nero","<a id=""maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""><p>Se</a> sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il <strong>ventilatore portatile spray FunFan</strong>. Si tratta di una soluzione pratica per mantenersi al fresco in una moltitudine di situazioni, come escursioni, gite in spiaggia, mentre si fa sport, ecc. Inoltre, grazie alle sue dimensioni ridotte (dimensioni: circa 9 x 26 x 6,5 cm) e peso ridotto (circa 130 g), lo puoi portare ovunque!<br /><br /><a href=""http://www.myfunfan.com""><strong>www.myfunfan.com</strong></a><br /><br />Questo <strong>ventilatore portatile</strong> originale ha un pulsante per attivare le eliche in PVC malleabili e una leva che spruzza l'acqua. Cosa c'è di più, puoi aggiungere il ghiaccio per aumentare la sensazione di freddo! Include 1 cacciavite a croce per inserire le batterie. Realizzato in PVC. Funzionamento a batterie (2 x AA, non incluse).</p><p> Dimenzioni per Ventilatore Portatile Spray FunFan : </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 28 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.185 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888101772</p>","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""> Maggiori Informazioni</a>",0.185,1,"Taxable Goods","Catalog, Search",19.9,,,,Ventilatore-Portatile-Spray-FunFan-Nero,"Ventilatore Portatile Spray FunFan Nero","Casa Giardino,Casa,Giardino,Sistemi di Climatizzazione,Sistemi,Climatizzazione,Aria condizionata e ventilatori,Aria,condizionata,ventilatori,Colore Nero,Nero,","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan",http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,,http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,,,,,,"2016-01-12 09:56:53",,,,,,,,,,,,,,,,,"GTIN=4899888101772",41,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78889.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78888.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78886.jpg","GTIN=4899888101772",,,,,,,,,, +BB-D2010130,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Sistemi di Climatizzazione,Default Category/Casa Giardino/Sistemi di Climatizzazione/Aria condizionata e ventilatori",base,"Ventilatore Portatile Spray FunFan Bianco","<a id=""maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""><p>Se</a> sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il <strong>ventilatore portatile spray FunFan</strong>. Si tratta di una soluzione pratica per mantenersi al fresco in una moltitudine di situazioni, come escursioni, gite in spiaggia, mentre si fa sport, ecc. Inoltre, grazie alle sue dimensioni ridotte (dimensioni: circa 9 x 26 x 6,5 cm) e peso ridotto (circa 130 g), lo puoi portare ovunque!<br /><br /><a href=""http://www.myfunfan.com""><strong>www.myfunfan.com</strong></a><br /><br />Questo <strong>ventilatore portatile</strong> originale ha un pulsante per attivare le eliche in PVC malleabili e una leva che spruzza l'acqua. Cosa c'è di più, puoi aggiungere il ghiaccio per aumentare la sensazione di freddo! Include 1 cacciavite a croce per inserire le batterie. Realizzato in PVC. Funzionamento a batterie (2 x AA, non incluse).</p><p> Dimenzioni per Ventilatore Portatile Spray FunFan : </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 28 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.185 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888107965</p>","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""> Maggiori Informazioni</a>",0.185,1,"Taxable Goods","Catalog, Search",19.9,,,,Ventilatore-Portatile-Spray-FunFan-Bianco,"Ventilatore Portatile Spray FunFan Bianco","Casa Giardino,Casa,Giardino,Sistemi di Climatizzazione,Sistemi,Climatizzazione,Aria condizionata e ventilatori,Aria,condizionata,ventilatori,Colore Bianco,Bianco,","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan",http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,,http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,,,,,,"2016-01-12 09:56:53",,,,,,,,,,,,,,,,,"GTIN=4899888107965",741,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78889.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78888.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78886.jpg","GTIN=4899888107965",,,,,,,,,, +BB-D2010131,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Sistemi di Climatizzazione,Default Category/Casa Giardino/Sistemi di Climatizzazione/Aria condizionata e ventilatori",base,"Ventilatore Portatile Spray FunFan Rosso","<a id=""maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""><p>Se</a> sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il <strong>ventilatore portatile spray FunFan</strong>. Si tratta di una soluzione pratica per mantenersi al fresco in una moltitudine di situazioni, come escursioni, gite in spiaggia, mentre si fa sport, ecc. Inoltre, grazie alle sue dimensioni ridotte (dimensioni: circa 9 x 26 x 6,5 cm) e peso ridotto (circa 130 g), lo puoi portare ovunque!<br /><br /><a href=""http://www.myfunfan.com""><strong>www.myfunfan.com</strong></a><br /><br />Questo <strong>ventilatore portatile</strong> originale ha un pulsante per attivare le eliche in PVC malleabili e una leva che spruzza l'acqua. Cosa c'è di più, puoi aggiungere il ghiaccio per aumentare la sensazione di freddo! Include 1 cacciavite a croce per inserire le batterie. Realizzato in PVC. Funzionamento a batterie (2 x AA, non incluse).</p><p> Dimenzioni per Ventilatore Portatile Spray FunFan : </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 28 Cm</li><li>Profondita': 7.5 Cm</li><li>Peso: 0.185 Kg</li></ul></p><p>Codice Prodotto (EAN): 4899888107972</p>","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore Portatile Spray FunFan ""> Maggiori Informazioni</a>",0.185,1,"Taxable Goods","Catalog, Search",19.9,,,,Ventilatore-Portatile-Spray-FunFan-Rosso,"Ventilatore Portatile Spray FunFan Rosso","Casa Giardino,Casa,Giardino,Sistemi di Climatizzazione,Sistemi,Climatizzazione,Aria condizionata e ventilatori,Aria,condizionata,ventilatori,Colore Rosso,Rosso,","Se sei il tipo di persona che è sempre alla ricerca di prodotti innovativi per combattere il caldo estivo, non perderti il ventilatore portatile spray FunFan",http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,,http://dropshipping.bigbuy.eu/imgs/D2010128_78890.jpg,,,,,,"2016-01-12 09:56:53",,,,,,,,,,,,,,,,,"GTIN=4899888107972",570,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/D2010128_78887.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78898.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78889.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78888.jpg,http://dropshipping.bigbuy.eu/imgs/D2010128_78886.jpg","GTIN=4899888107972",,,,,,,,,, +BB-H1000163,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Sedia Pieghevole Campart Travel CH0592 Blu Marino","<a id=""maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""><p>Se</a> stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la <strong>sedia pieghevole </strong><strong>Campart Travel</strong>! Questa <strong>sedia da campeggio imbottita</strong> è perfetta per i luoghi di campeggio, cortili, giardini, ecc. Ideale per il riposo e il relax. Può portare fino a 120 kg. Dimensioni: 66 x 70 / 120 x 87 / 115 cm circa. Semplice da trasportare ovunque, grazie al suo design funzionale ed elegante (dimensioni quando piegato: circa 66 x 110 x 10 cm). 7 posizioni regolabili e un poggiatesta incorporato. Struttura in alluminio e stoffa imbottita in poliestere. Altezza sedia: circa 50 cm.</p><p> Dimenzioni per Sedia Pieghevole Campart Travel: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 66 Cm</li><li>Profondita': 110 Cm</li><li>Peso: 5.3 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005922</p>","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc.</br><a href=""#maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""> Maggiori Informazioni</a>",5.3,1,"Taxable Goods","Catalog, Search",129,,,,Sedia-Pieghevole-Campart-Travel-CH0592 Blu Marino,"Sedia Pieghevole Campart Travel CH0592 Blu Marino","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0592 Blu Marino,CH0592 Blu Marino,","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc",http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_00.jpg,,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_00.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005922",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_02.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_04.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_03.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_01.jpg","GTIN=8713016005922",,,,,,,,,, +BB-H1000162,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Sedia Pieghevole Campart Travel CH0596 Grigio","<a id=""maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""><p>Se</a> stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la <strong>sedia pieghevole </strong><strong>Campart Travel</strong>! Questa <strong>sedia da campeggio imbottita</strong> è perfetta per i luoghi di campeggio, cortili, giardini, ecc. Ideale per il riposo e il relax. Può portare fino a 120 kg. Dimensioni: 66 x 70 / 120 x 87 / 115 cm circa. Semplice da trasportare ovunque, grazie al suo design funzionale ed elegante (dimensioni quando piegato: circa 66 x 110 x 10 cm). 7 posizioni regolabili e un poggiatesta incorporato. Struttura in alluminio e stoffa imbottita in poliestere. Altezza sedia: circa 50 cm.</p><p> Dimenzioni per Sedia Pieghevole Campart Travel: </br><ul><li>Altezza: 10 Cm</li><li>Larghezza: 66 Cm</li><li>Profondita': 110 Cm</li><li>Peso: 5.3 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005960</p>","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc.</br><a href=""#maggiorni_informazioni"" title=""Sedia Pieghevole Campart Travel""> Maggiori Informazioni</a>",5.3,1,"Taxable Goods","Catalog, Search",129,,,,Sedia-Pieghevole-Campart-Travel-CH0596 Grigio,"Sedia Pieghevole Campart Travel CH0596 Grigio","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0596 Grigio,CH0596 Grigio,","Se stai cercando comfort e convenienza allo stesso tempo, probabilmente adorerai la sedia pieghevole Campart Travel! Questa sedia da campeggio imbottita è perfetta per i luoghi di campeggio, cortili, giardini, ecc",http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_02.jpg,,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_02.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005960",2,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_00.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_04.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_03.jpg,http://dropshipping.bigbuy.eu/imgs/silla_plegable_camping_01.jpg","GTIN=8713016005960",,,,,,,,,, +BB-F1520329,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Poggiapiedi Pieghevole Campart Travel CH0593 Blu Marino","<a id=""maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""><p>Approfitta</a> di un'esperienza rilassante con l'aiuto del <strong>poggiapiedi pieghevole Campart Travel</strong>! Questo<strong> poggiapiedi imbottito</strong> è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc. Possiede 2 ganci di circa 3 cm di diametro che possono essere facilmente attaccate alla barra inferiore delle sedie (utilizzabile solo per sedie con una barra inferiore di circa 2 cm di diametro). Struttura in alluminio. Tessuto: poliestere. Dimensioni: circa 51 x 47 x 96 cm (dimensioni quando ripiegato: circa 51 x 12 x 96 cm). Ideale per le sedie pieghevoli Campart Travel CH0592 e CH0596.</p><p> Dimenzioni per Poggiapiedi Pieghevole Campart Travel: </br><ul><li>Altezza: 13 Cm</li><li>Larghezza: 53 Cm</li><li>Profondita': 97 Cm</li><li>Peso: 1.377 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005939</p>","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc.</br><a href=""#maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""> Maggiori Informazioni</a>",1.377,1,"Taxable Goods","Catalog, Search",46.6,,,,Poggiapiedi-Pieghevole-Campart-Travel-CH0593 Blu Marino,"Poggiapiedi Pieghevole Campart Travel CH0593 Blu Marino","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0593 Blu Marino,CH0593 Blu Marino,","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc",http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_00.jpg,,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_00.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005939",1,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_01.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_02.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_08.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_07.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_06.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_05.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_04.jpg","GTIN=8713016005939",,,,,,,,,, +BB-F1520328,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Decorazione, illuminazione e mobili",base,"Poggiapiedi Pieghevole Campart Travel CH0597 Grigio","<a id=""maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""><p>Approfitta</a> di un'esperienza rilassante con l'aiuto del <strong>poggiapiedi pieghevole Campart Travel</strong>! Questo<strong> poggiapiedi imbottito</strong> è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc. Possiede 2 ganci di circa 3 cm di diametro che possono essere facilmente attaccate alla barra inferiore delle sedie (utilizzabile solo per sedie con una barra inferiore di circa 2 cm di diametro). Struttura in alluminio. Tessuto: poliestere. Dimensioni: circa 51 x 47 x 96 cm (dimensioni quando ripiegato: circa 51 x 12 x 96 cm). Ideale per le sedie pieghevoli Campart Travel CH0592 e CH0596.</p><p> Dimenzioni per Poggiapiedi Pieghevole Campart Travel: </br><ul><li>Altezza: 13 Cm</li><li>Larghezza: 53 Cm</li><li>Profondita': 97 Cm</li><li>Peso: 1.377 Kg</li></ul></p><p>Codice Prodotto (EAN): 8713016005977</p>","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc.</br><a href=""#maggiorni_informazioni"" title=""Poggiapiedi Pieghevole Campart Travel""> Maggiori Informazioni</a>",1.377,1,"Taxable Goods","Catalog, Search",46.6,,,,Poggiapiedi-Pieghevole-Campart-Travel-CH0597 Grigio,"Poggiapiedi Pieghevole Campart Travel CH0597 Grigio","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Decorazione, illuminazione e mobili,Decorazione,,illuminazione,mobili,Referenza e Colore CH0597 Grigio,CH0597 Grigio,","Approfitta di un'esperienza rilassante con l'aiuto del poggiapiedi pieghevole Campart Travel! Questo poggiapiedi imbottito è l'accessorio ideale per sedie da cortile, terrazzo, giardini, luoghi da campeggio, ecc",http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_01.jpg,,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_01.jpg,,,,,,"2015-09-21 15:58:54",,,,,,,,,,,,,,,,,"GTIN=8713016005977",7,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_00.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_02.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_08.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_07.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_06.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_05.jpg,http://dropshipping.bigbuy.eu/imgs/reposapies_CH-0609_04.jpg","GTIN=8713016005977",,,,,,,,,, +BB-H4502058,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Orologi da parete e da tavolo",base,"Orologio da Parete Star Wars","<a id=""maggiorni_informazioni"" title=""Orologio da Parete Star Wars""><p>I</a> fan di Star Wars non potranno fare a meno di appendere l'<strong>orologio da parete Star Wars</strong> in casa loro! Realizzato in plastica. Funziona a batterie (1 x AA, non incluse). Diametro circa: 25,5 cm. Spessore circa: 3,5 cm.</p><p> Dimenzioni per Orologio da Parete Star Wars: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 26 Cm</li><li>Profondita': 3.8 Cm</li><li>Peso: 0.287 Kg</li></ul></p><p>Codice Prodotto (EAN): 6950687214204</p>","I fan di Star Wars non potranno fare a meno di appendere l'orologio da parete Star Wars in casa loro! Realizzato in plastica.</br><a href=""#maggiorni_informazioni"" title=""Orologio da Parete Star Wars""> Maggiori Informazioni</a>",0.287,1,"Taxable Goods","Catalog, Search",22.5,,,,Orologio-da-Parete-Star-Wars,"Orologio da Parete Star Wars","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Orologi da parete e da tavolo,Orologi,parete,tavolo,","I fan di Star Wars non potranno fare a meno di appendere l'orologio da parete Star Wars in casa loro! Realizzato in plastica",http://dropshipping.bigbuy.eu/imgs/H4502058_84712.jpg,,http://dropshipping.bigbuy.eu/imgs/H4502058_84712.jpg,,,,,,"2016-08-08 21:09:24",,,,,,,,,,,,,,,,,"GTIN=6950687214204",130,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/H4502058_84713.jpg,http://dropshipping.bigbuy.eu/imgs/H4502058_84711.jpg,http://dropshipping.bigbuy.eu/imgs/H4502058_84710.jpg","GTIN=6950687214204",,,,,,,,,, +BB-G0500195,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Illuminazione LED",base,"Braccialetto Sportivo a LED MegaLed Rosso","<a id=""maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""><p>Se</a> ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed. Con questo<strong> braccialetto di sicurezza</strong> sarai visibile ai motorini e alle auto nell'oscurità, così da poter stare molto più sicuro e tranquillo. È dotato di 2 luci a LED con 2 possibili soluzioni (luce fissa ed intermittente). La lunghezza massima è di circa 55 cm e quella minima è di circa 42 cm. Molto leggero (circa 50 g). Autonomia circa: 24-40 ore. Funziona a batterie (2 x CR2023, incluse).</p><p> </p><p> Dimenzioni per Braccialetto Sportivo a LED MegaLed: </br><ul><li>Altezza: 3 Cm</li><li>Larghezza: 20 Cm</li><li>Profondita': 4 Cm</li><li>Peso: 0.049 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436545443507</p>","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed.</br><a href=""#maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""> Maggiori Informazioni</a>",0.049,1,"Taxable Goods","Catalog, Search",22,,,,Braccialetto-Sportivo-a-LED-MegaLed-Rosso,"Braccialetto Sportivo a LED MegaLed Rosso","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Illuminazione LED,Illuminazione,LED,Colore Rosso,Rosso,","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed",http://dropshipping.bigbuy.eu/imgs/G0500194_87774.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500194_87774.jpg,,,,,,"2016-01-08 12:34:41",,,,,,,,,,,,,,,,,"GTIN=8436545443507",22,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500194_87775.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87773.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87772.jpg","GTIN=8436545443507",,,,,,,,,, +BB-G0500196,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Illuminazione LED",base,"Braccialetto Sportivo a LED MegaLed Verde","<a id=""maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""><p>Se</a> ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed. Con questo<strong> braccialetto di sicurezza</strong> sarai visibile ai motorini e alle auto nell'oscurità, così da poter stare molto più sicuro e tranquillo. È dotato di 2 luci a LED con 2 possibili soluzioni (luce fissa ed intermittente). La lunghezza massima è di circa 55 cm e quella minima è di circa 42 cm. Molto leggero (circa 50 g). Autonomia circa: 24-40 ore. Funziona a batterie (2 x CR2023, incluse).</p><p> </p><p> Dimenzioni per Braccialetto Sportivo a LED MegaLed: </br><ul><li>Altezza: 3 Cm</li><li>Larghezza: 20 Cm</li><li>Profondita': 4 Cm</li><li>Peso: 0.049 Kg</li></ul></p><p>Codice Prodotto (EAN): 8436545443507</p>","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed.</br><a href=""#maggiorni_informazioni"" title=""Braccialetto Sportivo a LED MegaLed""> Maggiori Informazioni</a>",0.049,1,"Taxable Goods","Catalog, Search",22,,,,Braccialetto-Sportivo-a-LED-MegaLed-Verde,"Braccialetto Sportivo a LED MegaLed Verde","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Illuminazione LED,Illuminazione,LED,Colore Verde,Verde,","Se ti piace praticare la corsa, il ciclismo, o qualunque sport all'aria aperta, non può mancare tra i tuoi accessori il braccialetto per lo sport a LED MegaLed",http://dropshipping.bigbuy.eu/imgs/G0500194_87775.jpg,,http://dropshipping.bigbuy.eu/imgs/G0500194_87775.jpg,,,,,,"2016-01-08 12:34:41",,,,,,,,,,,,,,,,,"GTIN=8436545443507",37,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/G0500194_87774.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87773.jpg,http://dropshipping.bigbuy.eu/imgs/G0500194_87772.jpg","GTIN=8436545443507",,,,,,,,,, +BB-I2500333,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Orologi da parete e da tavolo",base,"Orologio da Parete Mom's Diner","<a id=""maggiorni_informazioni"" title=""Orologio da Parete Mom's Diner""><p>Decora</a> la tua cucina con l'originale <strong>orologio da parete</strong> <strong>Mom's Diner</strong> in stile vintage! È realizzato in legno. Diametro: 58 cm circa. Spessore: 0,8 cm circa. Funziona a pile (1 x AA, non inclusa).</p><p> Dimenzioni per Orologio da Parete Mom's Diner: </br><ul><li>Altezza: 59 Cm</li><li>Larghezza: 59 Cm</li><li>Profondita': 6 Cm</li><li>Peso: 2.1 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811345052</p>","Decora la tua cucina con l'originale orologio da parete Mom's Diner in stile vintage! È realizzato in legno.</br><a href=""#maggiorni_informazioni"" title=""Orologio da Parete Mom's Diner""> Maggiori Informazioni</a>",2.1,1,"Taxable Goods","Catalog, Search",42.5,,,,Orologio-da-Parete-Mom's-Diner,"Orologio da Parete Mom's Diner","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Orologi da parete e da tavolo,Orologi,parete,tavolo,","Decora la tua cucina con l'originale orologio da parete Mom's Diner in stile vintage! È realizzato in legno",http://dropshipping.bigbuy.eu/imgs/I2500333_88061.jpg,,http://dropshipping.bigbuy.eu/imgs/I2500333_88061.jpg,,,,,,"2016-07-21 13:05:12",,,,,,,,,,,,,,,,,"GTIN=4029811345052",2,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I2500333_88060.jpg,http://dropshipping.bigbuy.eu/imgs/I2500333_88059.jpg,http://dropshipping.bigbuy.eu/imgs/I2500333_88058.jpg","GTIN=4029811345052",,,,,,,,,, +BB-I2500334,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Orologi da parete e da tavolo",base,"Orologio da Parete Coffee Endless Cup","<a id=""maggiorni_informazioni"" title=""Orologio da Parete Coffee Endless Cup""><p>Se</a> sei un appassionato di caffè, non puoi rimanere senza l'<strong>orologio da parete Coffee Endless Cup</strong>! Un orologio vintage in legno con un design in perfetto stile caffettoso! Diametro: 58 cm circa. Spessore: 0,8 cm circa. Funziona a pile (1 x AA, non inclusa).</p><p> Dimenzioni per Orologio da Parete Coffee Endless Cup: </br><ul><li>Altezza: 59 Cm</li><li>Larghezza: 59 Cm</li><li>Profondita': 6 Cm</li><li>Peso: 2.1 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811345069</p>","Se sei un appassionato di caffè, non puoi rimanere senza l'orologio da parete Coffee Endless Cup! Un orologio vintage in legno con un design in perfetto stile caffettoso! Diametro: 58 cm circa.</br><a href=""#maggiorni_informazioni"" title=""Orologio da Parete Coffee Endless Cup""> Maggiori Informazioni</a>",2.1,1,"Taxable Goods","Catalog, Search",42.5,,,,Orologio-da-Parete-Coffee-Endless-Cup,"Orologio da Parete Coffee Endless Cup","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Orologi da parete e da tavolo,Orologi,parete,tavolo,","Se sei un appassionato di caffè, non puoi rimanere senza l'orologio da parete Coffee Endless Cup! Un orologio vintage in legno con un design in perfetto stile caffettoso! Diametro: 58 cm circa",http://dropshipping.bigbuy.eu/imgs/I2500334_88065.jpg,,http://dropshipping.bigbuy.eu/imgs/I2500334_88065.jpg,,,,,,"2016-08-30 13:41:52",,,,,,,,,,,,,,,,,"GTIN=4029811345069",4,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/I2500334_88064.jpg,http://dropshipping.bigbuy.eu/imgs/I2500334_88063.jpg,http://dropshipping.bigbuy.eu/imgs/I2500334_88062.jpg","GTIN=4029811345069",,,,,,,,,, +BB-V0000252,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Insegna Dito Vintage Look Stop!","<a id=""maggiorni_informazioni"" title=""Insegna Dito Vintage Look""><p>Se</a> sei alla ricerca di una <strong>decorazione vintage</strong> originale e divertente per la tua casa, l'<strong>insegna dito Vintage Look</strong> ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno. Dimensioni: 69 x 17 x 1 cm circa.</p><p> Dimenzioni per Insegna Dito Vintage Look: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 69 Cm</li><li>Profondita': 1 Cm</li><li>Peso: 0.63 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346196</p>","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno.</br><a href=""#maggiorni_informazioni"" title=""Insegna Dito Vintage Look""> Maggiori Informazioni</a>",0.63,1,"Taxable Goods","Catalog, Search",15.99,,,,Insegna-Dito-Vintage-Look-Stop!,"Insegna Dito Vintage Look Stop!","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Stop!,Stop!,","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno",http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,,,,,,"2016-02-29 09:49:10",,,,,,,,,,,,,,,,,"GTIN=4029811346196",21,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89743.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89742.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89741.jpg","GTIN=4029811346196",,,,,,,,,, +BB-V0000253,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Insegna Dito Vintage Look Adults Only","<a id=""maggiorni_informazioni"" title=""Insegna Dito Vintage Look""><p>Se</a> sei alla ricerca di una <strong>decorazione vintage</strong> originale e divertente per la tua casa, l'<strong>insegna dito Vintage Look</strong> ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno. Dimensioni: 69 x 17 x 1 cm circa.</p><p> Dimenzioni per Insegna Dito Vintage Look: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 69 Cm</li><li>Profondita': 1 Cm</li><li>Peso: 0.63 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346202</p>","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno.</br><a href=""#maggiorni_informazioni"" title=""Insegna Dito Vintage Look""> Maggiori Informazioni</a>",0.63,1,"Taxable Goods","Catalog, Search",15.99,,,,Insegna-Dito-Vintage-Look-Adults Only,"Insegna Dito Vintage Look Adults Only","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Adults Only,Adults Only,","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno",http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,,,,,,"2016-02-29 09:49:10",,,,,,,,,,,,,,,,,"GTIN=4029811346202",18,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89743.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89742.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89741.jpg","GTIN=4029811346202",,,,,,,,,, +BB-V0000254,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Insegna Dito Vintage Look Talk","<a id=""maggiorni_informazioni"" title=""Insegna Dito Vintage Look""><p>Se</a> sei alla ricerca di una <strong>decorazione vintage</strong> originale e divertente per la tua casa, l'<strong>insegna dito Vintage Look</strong> ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno. Dimensioni: 69 x 17 x 1 cm circa.</p><p> Dimenzioni per Insegna Dito Vintage Look: </br><ul><li>Altezza: 17 Cm</li><li>Larghezza: 69 Cm</li><li>Profondita': 1 Cm</li><li>Peso: 0.63 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346318</p>","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno.</br><a href=""#maggiorni_informazioni"" title=""Insegna Dito Vintage Look""> Maggiori Informazioni</a>",0.63,1,"Taxable Goods","Catalog, Search",15.99,,,,Insegna-Dito-Vintage-Look-Talk,"Insegna Dito Vintage Look Talk","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Talk,Talk,","Se sei alla ricerca di una decorazione vintage originale e divertente per la tua casa, l'insegna dito Vintage Look ti conquisterà con il suo stile e i suoi simpatici messaggi! Realizzata in legno",http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000251_89744.jpg,,,,,,"2016-02-29 09:49:10",,,,,,,,,,,,,,,,,"GTIN=4029811346318",8,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000251_89745.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89746.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89743.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89742.jpg,http://dropshipping.bigbuy.eu/imgs/V0000251_89741.jpg","GTIN=4029811346318",,,,,,,,,, +BB-V0000256,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Freccia Decorativa Vintage Look Go Left","<a id=""maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""><p>Stupisci</a> tutti con la divertente ed originale <strong>freccia decorativa Vintage Look</strong>! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno. Misure appross.: 25 x 40 x 1 cm.</p><p> Dimenzioni per Freccia Decorativa Vintage Look: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 0.8 Cm</li><li>Profondita': 40 Cm</li><li>Peso: 0.376 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346325</p>","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno.</br><a href=""#maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""> Maggiori Informazioni</a>",0.376,1,"Taxable Goods","Catalog, Search",13.9,,,,Freccia-Decorativa-Vintage-Look-Go Left,"Freccia Decorativa Vintage Look Go Left","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Go Left,Go Left,","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno",http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,,,,,,"2016-02-29 10:39:59",,,,,,,,,,,,,,,,,"GTIN=4029811346325",4,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89759.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89758.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89757.jpg","GTIN=4029811346325",,,,,,,,,, +BB-V0000257,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Freccia Decorativa Vintage Look Exit","<a id=""maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""><p>Stupisci</a> tutti con la divertente ed originale <strong>freccia decorativa Vintage Look</strong>! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno. Misure appross.: 25 x 40 x 1 cm.</p><p> Dimenzioni per Freccia Decorativa Vintage Look: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 0.8 Cm</li><li>Profondita': 40 Cm</li><li>Peso: 0.376 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346332</p>","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno.</br><a href=""#maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""> Maggiori Informazioni</a>",0.376,1,"Taxable Goods","Catalog, Search",13.9,,,,Freccia-Decorativa-Vintage-Look-Exit,"Freccia Decorativa Vintage Look Exit","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Exit,Exit,","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno",http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,,,,,,"2016-02-29 10:39:59",,,,,,,,,,,,,,,,,"GTIN=4029811346332",19,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89759.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89758.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89757.jpg","GTIN=4029811346332",,,,,,,,,, +BB-V0000258,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Altri articoli decorativi",base,"Freccia Decorativa Vintage Look Cold beer here","<a id=""maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""><p>Stupisci</a> tutti con la divertente ed originale <strong>freccia decorativa Vintage Look</strong>! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno. Misure appross.: 25 x 40 x 1 cm.</p><p> Dimenzioni per Freccia Decorativa Vintage Look: </br><ul><li>Altezza: 25.5 Cm</li><li>Larghezza: 0.8 Cm</li><li>Profondita': 40 Cm</li><li>Peso: 0.376 Kg</li></ul></p><p>Codice Prodotto (EAN): 4029811346349</p>","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno.</br><a href=""#maggiorni_informazioni"" title=""Freccia Decorativa Vintage Look""> Maggiori Informazioni</a>",0.376,1,"Taxable Goods","Catalog, Search",13.9,,,,Freccia-Decorativa-Vintage-Look-Cold beer here,"Freccia Decorativa Vintage Look Cold beer here","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Altri articoli decorativi,Altri,articoli,decorativi,Design Cold beer here,Cold beer here,","Stupisci tutti con la divertente ed originale freccia decorativa Vintage Look! Le pareti di casa tua non resteranno indifferenti a nessuno! Fabbricata in legno",http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,,http://dropshipping.bigbuy.eu/imgs/V0000255_89760.jpg,,,,,,"2016-02-29 10:39:59",,,,,,,,,,,,,,,,,"GTIN=4029811346349",20,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0000255_89756.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89761.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89759.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89758.jpg,http://dropshipping.bigbuy.eu/imgs/V0000255_89757.jpg","GTIN=4029811346349",,,,,,,,,, +BB-V0200190,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Centrotavola e Vasi",base,"Ciotola in Bambù TakeTokio Bianco","<a id=""maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""><p>Arricchisci</a> la selezione dei tuoi <strong>utensili da cucina</strong> con la <strong>ciotola in bambù</strong> <strong>TakeTokio</strong>, una <strong>ciotola da cucina</strong> funzionale e dal design moderno è perfetta come <strong>insalatiera</strong>, portafrutta, ecc. Realizzata in pregiato legno di bambù. Dimensioni (diametro x altezza): 25 x 15 cm circa. Diametro della base: 9 cm circa.</p><p><a href=""http://www.taketokio.com/"" target=""_blank""><strong>www.taketokio.com</strong></a></p><p> Dimenzioni per Ciotola in Bambù TakeTokio: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 15 Cm</li><li>Peso: 0.39 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158904577</p>","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc.</br><a href=""#maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""> Maggiori Informazioni</a>",0.39,1,"Taxable Goods","Catalog, Search",19.8,,,,Ciotola-in-Bambù-TakeTokio-Bianco,"Ciotola in Bambù TakeTokio Bianco","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Centrotavola e Vasi,Centrotavola,Vasi,Colore Bianco,Bianco,","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc",http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8718158904577",0,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90744.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90743.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90742.jpg","GTIN=8718158904577",,,,,,,,,, +BB-V0200192,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Centrotavola e Vasi",base,"Ciotola in Bambù TakeTokio Grigio","<a id=""maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""><p>Arricchisci</a> la selezione dei tuoi <strong>utensili da cucina</strong> con la <strong>ciotola in bambù</strong> <strong>TakeTokio</strong>, una <strong>ciotola da cucina</strong> funzionale e dal design moderno è perfetta come <strong>insalatiera</strong>, portafrutta, ecc. Realizzata in pregiato legno di bambù. Dimensioni (diametro x altezza): 25 x 15 cm circa. Diametro della base: 9 cm circa.</p><p><a href=""http://www.taketokio.com/"" target=""_blank""><strong>www.taketokio.com</strong></a></p><p> Dimenzioni per Ciotola in Bambù TakeTokio: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 15 Cm</li><li>Peso: 0.39 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158904577</p>","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc.</br><a href=""#maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""> Maggiori Informazioni</a>",0.39,1,"Taxable Goods","Catalog, Search",19.8,,,,Ciotola-in-Bambù-TakeTokio-Grigio,"Ciotola in Bambù TakeTokio Grigio","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Centrotavola e Vasi,Centrotavola,Vasi,Colore Grigio,Grigio,","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc",http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8718158904577",26,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90744.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90743.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90742.jpg","GTIN=8718158904577",,,,,,,,,, +BB-V0200191,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Decorazione e Illuminazione,Default Category/Casa Giardino/Decorazione e Illuminazione/Centrotavola e Vasi",base,"Ciotola in Bambù TakeTokio Nero","<a id=""maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""><p>Arricchisci</a> la selezione dei tuoi <strong>utensili da cucina</strong> con la <strong>ciotola in bambù</strong> <strong>TakeTokio</strong>, una <strong>ciotola da cucina</strong> funzionale e dal design moderno è perfetta come <strong>insalatiera</strong>, portafrutta, ecc. Realizzata in pregiato legno di bambù. Dimensioni (diametro x altezza): 25 x 15 cm circa. Diametro della base: 9 cm circa.</p><p><a href=""http://www.taketokio.com/"" target=""_blank""><strong>www.taketokio.com</strong></a></p><p> Dimenzioni per Ciotola in Bambù TakeTokio: </br><ul><li>Altezza: 25 Cm</li><li>Larghezza: 25 Cm</li><li>Profondita': 15 Cm</li><li>Peso: 0.39 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158904577</p>","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc.</br><a href=""#maggiorni_informazioni"" title=""Ciotola in Bambù TakeTokio""> Maggiori Informazioni</a>",0.39,1,"Taxable Goods","Catalog, Search",19.8,,,,Ciotola-in-Bambù-TakeTokio-Nero,"Ciotola in Bambù TakeTokio Nero","Casa Giardino,Casa,Giardino,Decorazione e Illuminazione,Decorazione,Illuminazione,Centrotavola e Vasi,Centrotavola,Vasi,Colore Nero,Nero,","Arricchisci la selezione dei tuoi utensili da cucina con la ciotola in bambù TakeTokio, una ciotola da cucina funzionale e dal design moderno è perfetta come insalatiera, portafrutta, ecc",http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200189_90745.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8718158904577",22,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200189_90746.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90747.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90744.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90743.jpg,http://dropshipping.bigbuy.eu/imgs/V0200189_90742.jpg","GTIN=8718158904577",,,,,,,,,, +BB-V0200360,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Scatola porta Tè Flower Vintage Coconut Rosa","<a id=""maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""><p>Gli</a> amanti della moda vintage non potranno resistere di fronte all'adorabile <strong>scatola porta tè Flower Vintage Coconut</strong>! Una <strong>scatola vintage</strong> in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi. Dispone di un coperchio in cristallo e vari scompartimenti. Dimensioni: circa 23 x 7 x 23 cm.</p><p><a href=""http://www.vintagecoconut.com"" target=""_blank""><strong>www.vintagecoconut.com</strong></a></p><p> Dimenzioni per Scatola porta Tè Flower Vintage Coconut: </br><ul><li>Altezza: 23 Cm</li><li>Larghezza: 7.1 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.795 Kg</li></ul></p><p>Codice Prodotto (EAN): 8711295889547</p>","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi.</br><a href=""#maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""> Maggiori Informazioni</a>",0.795,1,"Taxable Goods","Catalog, Search",25.9,,,,Scatola-porta-Tè-Flower-Vintage-Coconut-Rosa,"Scatola porta Tè Flower Vintage Coconut Rosa","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,Colore Rosa,Rosa,","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi",http://dropshipping.bigbuy.eu/imgs/V0200328_92649.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200328_92649.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8711295889547",13,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200328_92648.jpg,http://dropshipping.bigbuy.eu/imgs/V0200328_92647.jpg","GTIN=8711295889547",,,,,,,,,, +BB-V0200361,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Scatola porta Tè Flower Vintage Coconut Azzurro","<a id=""maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""><p>Gli</a> amanti della moda vintage non potranno resistere di fronte all'adorabile <strong>scatola porta tè Flower Vintage Coconut</strong>! Una <strong>scatola vintage</strong> in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi. Dispone di un coperchio in cristallo e vari scompartimenti. Dimensioni: circa 23 x 7 x 23 cm.</p><p><a href=""http://www.vintagecoconut.com"" target=""_blank""><strong>www.vintagecoconut.com</strong></a></p><p> Dimenzioni per Scatola porta Tè Flower Vintage Coconut: </br><ul><li>Altezza: 23 Cm</li><li>Larghezza: 7.1 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.795 Kg</li></ul></p><p>Codice Prodotto (EAN): 8711295889547</p>","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi.</br><a href=""#maggiorni_informazioni"" title=""Scatola porta Tè Flower Vintage Coconut""> Maggiori Informazioni</a>",0.795,1,"Taxable Goods","Catalog, Search",25.9,,,,Scatola-porta-Tè-Flower-Vintage-Coconut-Azzurro,"Scatola porta Tè Flower Vintage Coconut Azzurro","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,Colore Azzurro,Azzurro,","Gli amanti della moda vintage non potranno resistere di fronte all'adorabile scatola porta tè Flower Vintage Coconut! Una scatola vintage in legno ottima come decorazione e utilissima per sistemare le bustine di tè o di altri infusi",http://dropshipping.bigbuy.eu/imgs/V0200328_92648.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200328_92648.jpg,,,,,,"-0001-11-30 00:00:00",,,,,,,,,,,,,,,,,"GTIN=8711295889547",21,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200328_92649.jpg,http://dropshipping.bigbuy.eu/imgs/V0200328_92647.jpg","GTIN=8711295889547",,,,,,,,,, +BB-V0200353,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Giardino e Terrazza,Default Category/Casa Giardino/Giardino e Terrazza/Barbecue",base,"Ventilatore a Pistola classico per Barbecue BBQ Classics","<a id=""maggiorni_informazioni"" title=""Ventilatore a Pistola classico per Barbecue BBQ Classics""><p>Utilizza</a> i migliori barbecue alimentando il fuoco con il <strong>ventilatore a pistola classico per babecue BBQ Classics</strong>! Basterà solo fare una leggera pressione sul pulsante per far uscire l'aria.</p><p><a href=""http://www.bbqclassics.com"" target=""_blank""><strong>www.bbqclassics.com</strong></a></p><ul><li>Realizzato in plastica e metallo</li><li>Dimensioni: 25 x 18 x 4 cm circa</li></ul><p> Dimenzioni per Ventilatore a Pistola classico per Barbecue BBQ Classics: </br><ul><li>Altezza: 5.5 Cm</li><li>Larghezza: 11 Cm</li><li>Profondita': 22 Cm</li><li>Peso: 0.167 Kg</li></ul></p><p>Codice Prodotto (EAN): 8718158032706</p>","Utilizza i migliori barbecue alimentando il fuoco con il ventilatore a pistola classico per babecue BBQ Classics! Basterà solo fare una leggera pressione sul pulsante per far uscire l'aria.</br><a href=""#maggiorni_informazioni"" title=""Ventilatore a Pistola classico per Barbecue BBQ Classics""> Maggiori Informazioni</a>",0.167,1,"Taxable Goods","Catalog, Search",9.3,,,,Ventilatore-a-Pistola-classico-per-Barbecue-BBQ-Classics,"Ventilatore a Pistola classico per Barbecue BBQ Classics","Casa Giardino,Casa,Giardino,Giardino e Terrazza,Giardino,Terrazza,Barbecue,","Utilizza i migliori barbecue alimentando il fuoco con il ventilatore a pistola classico per babecue BBQ Classics! Basterà solo fare una leggera pressione sul pulsante per far uscire l'aria",http://dropshipping.bigbuy.eu/imgs/V0200353_92695.jpg,,http://dropshipping.bigbuy.eu/imgs/V0200353_92695.jpg,,,,,,"2016-09-13 09:56:07",,,,,,,,,,,,,,,,,"GTIN=8718158032706",60,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V0200353_92696.jpg,http://dropshipping.bigbuy.eu/imgs/V0200353_92694.jpg","GTIN=8718158032706",,,,,,,,,, +BB-V1600123,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Frozen (32 x 23 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (32 x 23 cm)""><p>Non</a> c'è nulla di meglio che tenere le camere dei più piccini in ordine in modo originale e divertente. Con il <strong>contenitore portagiochi Frozen (32 x 23 cm)</strong> sarà semplicissimo!</p><ul><li>Realizzato in polipropilene</li><li>Dimensioni aprossimative: 32 x 15 x 23 cm</li><li>Età consigliata: +3 anni</li></ul><p> </p><p> Dimenzioni per Contenitore Portagiochi Frozen (32 x 23 cm): </br><ul><li>Altezza: 15 Cm</li><li>Larghezza: 34 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.331 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766006</p>","Non c'è nulla di meglio che tenere le camere dei più piccini in ordine in modo originale e divertente.</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (32 x 23 cm)""> Maggiori Informazioni</a>",0.331,1,"Taxable Goods","Catalog, Search",21.9,,,,Contenitore-Portagiochi-Frozen-(32-x-23-cm),"Contenitore Portagiochi Frozen (32 x 23 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","Non c'è nulla di meglio che tenere le camere dei più piccini in ordine in modo originale e divertente",http://dropshipping.bigbuy.eu/imgs/V1600123_93002.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600123_93002.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8412842766006",48,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600123_93005.jpg,http://dropshipping.bigbuy.eu/imgs/V1600123_93004.jpg,http://dropshipping.bigbuy.eu/imgs/V1600123_93003.jpg","GTIN=8412842766006",,,,,,,,,, +BB-V1600124,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Spiderman (32 x 23 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (32 x 23 cm)""><p>Desideri</a> sorprendere i più piccini con un regalo molto originale? Il <strong>contenitore portagiochi Spiderman (32 x 23 cm)</strong> decorerà e metterà in ordine le loro camerette.</p><ul><li>Realizzato in polipropilene</li><li>Dimensioni approssimative: 32 x 15 x 23 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Contenitore Portagiochi Spiderman (32 x 23 cm): </br><ul><li>Altezza: 15 Cm</li><li>Larghezza: 34 Cm</li><li>Profondita': 23 Cm</li><li>Peso: 0.331 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766037</p>","Desideri sorprendere i più piccini con un regalo molto originale? Il contenitore portagiochi Spiderman (32 x 23 cm) decorerà e metterà in ordine le loro camerette.</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (32 x 23 cm)""> Maggiori Informazioni</a>",0.331,1,"Taxable Goods","Catalog, Search",21.9,,,,Contenitore-Portagiochi-Spiderman--(32-x-23-cm),"Contenitore Portagiochi Spiderman (32 x 23 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","Desideri sorprendere i più piccini con un regalo molto originale? Il contenitore portagiochi Spiderman (32 x 23 cm) decorerà e metterà in ordine le loro camerette",http://dropshipping.bigbuy.eu/imgs/V1600124_93006.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600124_93006.jpg,,,,,,"2016-08-08 21:09:24",,,,,,,,,,,,,,,,,"GTIN=8412842766037",52,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600124_93008.jpg,http://dropshipping.bigbuy.eu/imgs/V1600124_93007.jpg","GTIN=8412842766037",,,,,,,,,, +BB-V1600125,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Frozen (45 x 32 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (45 x 32 cm)""><p>Insegna</a> ai tuoi bambini a tenere i giocattoli conservati ben in ordine con l'aiuto del <strong>contenitore portagiochi Frozen (45 x 32 cm)</strong>. Il <strong>portagiocattoli</strong> che tutte le bambine sognano!</p><ul><li>Realizzato in polipropilene</li><li>Dimensioni: circa 45 x 22 x 32 cm</li><li>Età consigliata: +3 anni</li></ul><p> Dimenzioni per Contenitore Portagiochi Frozen (45 x 32 cm): </br><ul><li>Altezza: 22 Cm</li><li>Larghezza: 37 Cm</li><li>Profondita': 45 Cm</li><li>Peso: 0.775 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766129</p>","Insegna ai tuoi bambini a tenere i giocattoli conservati ben in ordine con l'aiuto del contenitore portagiochi Frozen (45 x 32 cm).</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Frozen (45 x 32 cm)""> Maggiori Informazioni</a>",0.775,1,"Taxable Goods","Catalog, Search",39.6,,,,Contenitore-Portagiochi-Frozen-(45-x-32-cm),"Contenitore Portagiochi Frozen (45 x 32 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","Insegna ai tuoi bambini a tenere i giocattoli conservati ben in ordine con l'aiuto del contenitore portagiochi Frozen (45 x 32 cm)",http://dropshipping.bigbuy.eu/imgs/V1600125_93010.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600125_93010.jpg,,,,,,"2016-08-08 21:04:27",,,,,,,,,,,,,,,,,"GTIN=8412842766129",17,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600125_93011.jpg,http://dropshipping.bigbuy.eu/imgs/V1600125_93009.jpg","GTIN=8412842766129",,,,,,,,,, +BB-V1600126,,Default,simple,"Default Category/Casa Giardino,Default Category/Casa Giardino/Arredamento,Default Category/Casa Giardino/Arredamento/Soluzioni per Organizzare",base,"Contenitore Portagiochi Spiderman (45 x 32 cm)","<a id=""maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (45 x 32 cm)""><p>I</a> piccoli di casa ora possono ordinare e riporre i loro giocattoli facilmente grazie al <strong>c<strong>ontenitore <strong>portagiochi</strong></strong> Spiderman</strong><strong> (45 x 32 cm)</strong>. Il <strong>c<strong>ontenitore <strong>portagiochi</strong></strong> </strong>preferito dai bambini!</p><ul><li>Fabbricato in polipropilene</li><li>Dimensioni: circa 45 x 22 x 32 cm</li><li>Età raccomandata: +3 anni</li></ul><p> Dimenzioni per Contenitore Portagiochi Spiderman (45 x 32 cm): </br><ul><li>Altezza: 22 Cm</li><li>Larghezza: 37 Cm</li><li>Profondita': 45 Cm</li><li>Peso: 0.775 Kg</li></ul></p><p>Codice Prodotto (EAN): 8412842766150</p>","I piccoli di casa ora possono ordinare e riporre i loro giocattoli facilmente grazie al contenitore portagiochi Spiderman (45 x 32 cm).</br><a href=""#maggiorni_informazioni"" title=""Contenitore Portagiochi Spiderman (45 x 32 cm)""> Maggiori Informazioni</a>",0.775,1,"Taxable Goods","Catalog, Search",39.6,,,,Contenitore-Portagiochi-Spiderman-(45-x-32-cm),"Contenitore Portagiochi Spiderman (45 x 32 cm)","Casa Giardino,Casa,Giardino,Arredamento,Soluzioni per Organizzare,Soluzioni,Organizzare,","I piccoli di casa ora possono ordinare e riporre i loro giocattoli facilmente grazie al contenitore portagiochi Spiderman (45 x 32 cm)",http://dropshipping.bigbuy.eu/imgs/V1600126_93012.jpg,,http://dropshipping.bigbuy.eu/imgs/V1600126_93012.jpg,,,,,,"2016-08-08 21:09:24",,,,,,,,,,,,,,,,,"GTIN=8412842766150",18,0,1,0,1,1,1,1,10000,1,1,,1,0,1,1,1,1,0,0,0,,,,,,,"http://dropshipping.bigbuy.eu/imgs/V1600126_93014.jpg,http://dropshipping.bigbuy.eu/imgs/V1600126_93013.jpg","GTIN=8412842766150",,,,,,,,,, diff --git a/dev/tests/acceptance/tests/_data/catalog_product_err_img.csv b/dev/tests/acceptance/tests/_data/catalog_product_err_img.csv new file mode 100644 index 0000000000000..97ac55e8e5a20 --- /dev/null +++ b/dev/tests/acceptance/tests/_data/catalog_product_err_img.csv @@ -0,0 +1,11 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,additional_images +simple1,,Default,simple,,base,simple1,,,,1,Taxable Goods,"Catalog, Search",100,,,,simple1,test.jpg +simple2,,Default,simple,,base,simple2,,,,2,Taxable Goods,"Catalog, Search",101,,,,simple2,test.jpg +simple3,,Default,simple,,base,simple3,,,,3,Taxable Goods,"Catalog, Search",102,,,,simple3,test.jpg +simple4,,Default,simple,,base,simple4,,,,4,Taxable Goods,"Catalog, Search",103,,,,simple4,test.jpg +simple5,,Default,simple,,base,simple5,,,,5,Taxable Goods,"Catalog, Search",104,,,,simple5,test.jpg +simple6,,Default,simple,,base,simple6,,,,6,Taxable Goods,"Catalog, Search",105,,,,simple6,test.jpg +simple7,,Default,simple,,base,simple7,,,,7,Taxable Goods,"Catalog, Search",106,,,,simple7,test.jpg +simple8,,Default,simple,,base,simple8,,,,8,Taxable Goods,"Catalog, Search",107,,,,simple8,test.jpg +simple9,,Default,simple,,base,simple9,,,,9,Taxable Goods,"Catalog, Search",108,,,,simple9,test.jpg +simple10,,Default,simple,,base,simple10,,,,10,Taxable Goods,"Catalog, Search",109,,,,simple10,test.jpg diff --git a/dev/tests/acceptance/tests/_data/export_import_configurable_product.csv b/dev/tests/acceptance/tests/_data/export_import_configurable_product.csv new file mode 100644 index 0000000000000..97e63d06abe71 --- /dev/null +++ b/dev/tests/acceptance/tests/_data/export_import_configurable_product.csv @@ -0,0 +1,2 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,swatch_image,swatch_image_label,created_at,updated_at,new_from_date,new_to_date,display_product_options_in,map_price,msrp_price,map_enabled,gift_message_available,custom_design,custom_design_from,custom_design_to,custom_layout_update,page_layout,product_options_container,msrp_display_actual_price_type,country_of_manufacture,additional_attributes,qty,out_of_stock_qty,use_config_min_qty,is_qty_decimal,allow_backorders,use_config_backorders,min_cart_qty,use_config_min_sale_qty,max_cart_qty,use_config_max_sale_qty,is_in_stock,notify_on_stock_below,use_config_notify_stock_qty,manage_stock,use_config_manage_stock,use_config_qty_increments,qty_increments,use_config_enable_qty_inc,enable_qty_increments,is_decimal_divided,website_id,related_skus,related_position,crosssell_skus,crosssell_position,upsell_skus,upsell_position,additional_images,additional_image_labels,hide_from_product_page,custom_options,bundle_price_type,bundle_sku_type,bundle_price_view,bundle_weight_type,bundle_values,bundle_shipment_type,associated_skus,configurable_variations,configurable_variation_labels +api-configurable-export-import-product,,Default,configurable,Default Category/CategoryExportImport,base,API Configurable Export Import Product,,,2,1,Taxable Goods,"Catalog, Search",123,,,,api-configurable-export-import-product,,,,/m/a/magento-logo_1.png,Magento Logo,/m/a/magento-logo_1.png,Magento Logo,/m/a/magento-logo_1.png,Magento Logo,,,"7/26/19, 8:21 AM","7/26/19, 8:21 AM",,,Block after Info Column,,,,,,,,,,,Use config,,,0,0,1,0,0,1,1,1,0,1,1,,1,0,1,1,0,1,0,0,0,,,,,,,,,,,,,,,,,,"sku=api-simple-one-export-import,attribute=option1|sku=api-simple-two-export-import,attribute=option2",attribute=attributeExportImport diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php index bc55d81b455ff..cc0ef7aaf0f5c 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php @@ -476,7 +476,7 @@ public function testAnchorCategory() { category(id: {$categoryId}) { name - products(sort: {sku: ASC}) { + products(sort: {name: DESC}) { total_count items { sku @@ -493,8 +493,8 @@ public function testAnchorCategory() 'total_count' => 3, 'items' => [ ['sku' => '12345'], - ['sku' => 'simple'], - ['sku' => 'simple-4'] + ['sku' => 'simple-4'], + ['sku' => 'simple'] ] ] ] diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeOptionsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeOptionsTest.php new file mode 100644 index 0000000000000..517a1c966b04d --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeOptionsTest.php @@ -0,0 +1,105 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Catalog; + +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\Eav\Api\Data\AttributeOptionInterface; + +class ProductAttributeOptionsTest extends GraphQlAbstract +{ + /** + * Test that custom attribute options are returned correctly + * + * @magentoApiDataFixture Magento/Catalog/_files/dropdown_attribute.php + */ + public function testCustomAttributeMetadataOptions() + { + /** @var \Magento\Eav\Model\Config $eavConfig */ + $eavConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); + $attribute = $eavConfig->getAttribute('catalog_product', 'dropdown_attribute'); + /** @var AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + array_shift($options); + $optionValues = []; + // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ($i = 0; $i < count($options); $i++) { + $optionValues[] = $options[$i]->getValue(); + } + $query + = <<<QUERY +{ + customAttributeMetadata(attributes: + [ + { + attribute_code:"description", + entity_type:"catalog_product" + }, + { + attribute_code:"status", + entity_type:"catalog_product" + }, + { + attribute_code:"dropdown_attribute", + entity_type:"catalog_product" + } + ] + ) + { + items + { + attribute_code + attribute_type + entity_type + input_type + attribute_options{ + label + value + } + } + } + } +QUERY; + $response = $this->graphQlQuery($query); + + $expectedOptionArray = [ + [], // description attribute has no options + [ + [ + 'label' => 'Enabled', + 'value' => '1' + ], + [ + 'label' => 'Disabled', + 'value' => '2' + ] + ], + [ + [ + 'label' => 'Option 1', + 'value' => $optionValues[0] + ], + [ + 'label' => 'Option 2', + 'value' => $optionValues[1] + ], + [ + 'label' => 'Option 3', + 'value' => $optionValues[2] + ] + ] + ]; + + $this->assertNotEmpty($response['customAttributeMetadata']['items']); + $actualAttributes = $response['customAttributeMetadata']['items']; + + foreach ($expectedOptionArray as $index => $expectedOptions) { + $actualOption = $actualAttributes[$index]['attribute_options']; + $this->assertEquals($expectedOptions, $actualOption); + } + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeTypeTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeTypeTest.php index 063da7c11bf7f..a34d5e21704af 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeTypeTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeTypeTest.php @@ -50,7 +50,8 @@ public function testAttributeTypeResolver() { attribute_code attribute_type - entity_type + entity_type + input_type } } } @@ -71,7 +72,8 @@ public function testAttributeTypeResolver() \Magento\Catalog\Api\Data\ProductInterface::class ]; $attributeTypes = ['String', 'Int', 'Float','Boolean', 'Float']; - $this->assertAttributeType($attributeTypes, $expectedAttributeCodes, $entityType, $response); + $inputTypes = ['textarea', 'select', 'price', 'boolean', 'price']; + $this->assertAttributeType($attributeTypes, $expectedAttributeCodes, $entityType, $inputTypes, $response); } /** @@ -121,7 +123,8 @@ public function testComplexAttributeTypeResolver() { attribute_code attribute_type - entity_type + entity_type + input_type } } } @@ -154,7 +157,16 @@ public function testComplexAttributeTypeResolver() 'CustomerDataRegionInterface', 'ProductMediaGallery' ]; - $this->assertAttributeType($attributeTypes, $expectedAttributeCodes, $entityTypes, $response); + $inputTypes = [ + 'select', + 'multiselect', + 'select', + 'select', + 'text', + 'text', + 'gallery' + ]; + $this->assertAttributeType($attributeTypes, $expectedAttributeCodes, $entityTypes, $inputTypes, $response); } /** @@ -213,11 +225,17 @@ public function testUnDefinedAttributeType() * @param array $attributeTypes * @param array $expectedAttributeCodes * @param array $entityTypes + * @param array $inputTypes * @param array $actualResponse * @SuppressWarnings(PHPMD.UnusedLocalVariable) */ - private function assertAttributeType($attributeTypes, $expectedAttributeCodes, $entityTypes, $actualResponse) - { + private function assertAttributeType( + $attributeTypes, + $expectedAttributeCodes, + $entityTypes, + $inputTypes, + $actualResponse + ) { $attributeMetaDataItems = array_map(null, $actualResponse['customAttributeMetadata']['items'], $attributeTypes); foreach ($attributeMetaDataItems as $itemIndex => $itemArray) { @@ -225,8 +243,9 @@ private function assertAttributeType($attributeTypes, $expectedAttributeCodes, $ $attributeMetaDataItems[$itemIndex][0], [ "attribute_code" => $expectedAttributeCodes[$itemIndex], - "attribute_type" =>$attributeTypes[$itemIndex], - "entity_type" => $entityTypes[$itemIndex] + "attribute_type" => $attributeTypes[$itemIndex], + "entity_type" => $entityTypes[$itemIndex], + "input_type" => $inputTypes[$itemIndex] ] ); } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php index 4ce8ad8dab393..91f1795935f6a 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php @@ -13,12 +13,16 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Category; use Magento\Catalog\Model\CategoryLinkManagement; -use Magento\Framework\EntityManager\MetadataPool; +use Magento\Eav\Model\Config; +use Magento\Indexer\Model\Indexer; use Magento\TestFramework\ObjectManager; use Magento\TestFramework\TestCase\GraphQlAbstract; use Magento\Catalog\Model\Product; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Eav\Api\Data\AttributeOptionInterface; use Magento\Framework\DataObject; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Helper\CacheCleaner; /** * @SuppressWarnings(PHPMD.TooManyPublicMethods) @@ -28,8 +32,9 @@ class ProductSearchTest extends GraphQlAbstract { /** - * Verify that layered navigation filters are returned for product query + * Verify that layered navigation filters and aggregations are correct for product query * + * Filter products by an array of skus * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -40,7 +45,7 @@ public function testFilterLn() products ( filter: { sku: { - like:"simple%" + in:["simple1", "simple2"] } } pageSize: 4 @@ -72,9 +77,6 @@ public function testFilterLn() } } QUERY; - /** - * @var ProductRepositoryInterface $productRepository - */ $response = $this->graphQlQuery($query); $this->assertArrayHasKey( @@ -89,6 +91,809 @@ public function testFilterLn() ); } + /** + * Layered navigation for Configurable products with out of stock options + * Two configurable products each having two variations and one of the child products of one Configurable set to OOS + * + * @magentoApiDataFixture Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testLayeredNavigationForConfigurableProducts() + { + CacheCleaner::cleanAll(); + $attributeCode = 'test_configurable'; + + /** @var \Magento\Eav\Model\Config $eavConfig */ + $eavConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(Config::class); + $attribute = $eavConfig->getAttribute('catalog_product', $attributeCode); + /** @var AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + array_shift($options); + $firstOption = $options[0]->getValue(); + $secondOption = $options[1]->getValue(); + $query = $this->getQueryProductsWithArrayOfCustomAttributes($attributeCode, $firstOption, $secondOption); + $this->reIndexAndCleanCache(); + $response = $this->graphQlQuery($query); + + $this->assertEquals(2, $response['products']['total_count']); + $this->assertNotEmpty($response['products']['aggregations']); + $this->assertNotEmpty($response['products']['filters'], 'Filters is empty'); + $this->assertCount(2, $response['products']['aggregations'], 'Aggregation count does not match'); + + // Custom attribute filter layer data + $this->assertResponseFields( + $response['products']['aggregations'][1], + [ + 'attribute_code' => $attribute->getAttributeCode(), + 'label'=> $attribute->getDefaultFrontendLabel(), + 'count'=> 2, + 'options' => [ + [ + 'label' => 'Option 1', + 'value' => $firstOption, + 'count' =>'2' + ], + [ + 'label' => 'Option 2', + 'value' => $secondOption, + 'count' =>'2' + ] + ], + ] + ); + } + + /** + * + * @return string + */ + private function getQueryProductsWithArrayOfCustomAttributes($attributeCode, $firstOption, $secondOption) : string + { + return <<<QUERY +{ + products(filter:{ + $attributeCode: {in:["{$firstOption}", "{$secondOption}"]} + } + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + } + page_info{ + current_page + page_size + total_pages + } + filters{ + name + request_var + filter_items_count + filter_items{ + label + items_count + value_string + __typename + } + } + aggregations{ + attribute_code + count + label + options{ + label + value + count + } + } + + } +} +QUERY; + } + + /** + * Filter products by custom attribute of dropdown type and filterTypeInput eq + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterProductsByDropDownCustomAttribute() + { + CacheCleaner::cleanAll(); + $attributeCode = 'second_test_configurable'; + $optionValue = $this->getDefaultAttributeOptionValue($attributeCode); + $query = <<<QUERY +{ + products(filter:{ + $attributeCode: {eq: "{$optionValue}"} + } + + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + } + page_info{ + current_page + page_size + total_pages + } + filters{ + name + request_var + filter_items_count + filter_items{ + label + items_count + value_string + __typename + } + + } + aggregations{ + attribute_code + count + label + options + { + label + count + value + } + } + + } +} +QUERY; + + $objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->get(ProductRepositoryInterface::class); + $product1 = $productRepository->get('simple'); + $product2 = $productRepository->get('12345'); + $product3 = $productRepository->get('simple-4'); + $filteredProducts = [$product1, $product2, $product3 ]; + $countOfFilteredProducts = count($filteredProducts); + $this->reIndexAndCleanCache(); + $response = $this->graphQlQuery($query); + $this->assertEquals(3, $response['products']['total_count'], 'Number of products returned is incorrect'); + $this->assertTrue(count($response['products']['filters']) > 0, 'Product filters is not empty'); + $this->assertCount(3, $response['products']['aggregations'], 'Incorrect count of aggregations'); + + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + for ($itemIndex = 0; $itemIndex < $countOfFilteredProducts; $itemIndex++) { + $this->assertNotEmpty($productItemsInResponse[$itemIndex]); + //validate that correct products are returned + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ 'name' => $filteredProducts[$itemIndex]->getName(), + 'sku' => $filteredProducts[$itemIndex]->getSku() + ] + ); + } + + /** @var \Magento\Eav\Model\Config $eavConfig */ + $eavConfig = $objectManager->get(Config::class); + $attribute = $eavConfig->getAttribute('catalog_product', 'second_test_configurable'); + // Validate custom attribute filter layer data from aggregations + $this->assertResponseFields( + $response['products']['aggregations'][2], + [ + 'attribute_code' => $attribute->getAttributeCode(), + 'count'=> 1, + 'label'=> $attribute->getDefaultFrontendLabel(), + 'options' => [ + [ + 'label' => 'Option 3', + 'count' => 3, + 'value' => $optionValue + ], + ], + ] + ); + } + + /** + * @return void + */ + private function reIndexAndCleanCache() : void + { + $objectManager = Bootstrap::getObjectManager(); + $indexer = $objectManager->create(Indexer::class); + $indexer->load('catalogsearch_fulltext'); + $indexer->reindexAll(); + CacheCleaner::cleanAll(); + } + /** + * Filter products using an array of multi select custom attributes + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterProductsByMultiSelectCustomAttributes() + { + $objectManager = Bootstrap::getObjectManager(); + $this->reIndexAndCleanCache(); + $attributeCode = 'multiselect_attribute'; + /** @var \Magento\Eav\Model\Config $eavConfig */ + $eavConfig = $objectManager->get(\Magento\Eav\Model\Config::class); + $attribute = $eavConfig->getAttribute('catalog_product', $attributeCode); + /** @var AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + array_shift($options); + $countOptions = count($options); + $optionValues = []; + for ($i = 0; $i < $countOptions; $i++) { + $optionValues[] = $options[$i]->getValue(); + } + $query = <<<QUERY +{ + products(filter:{ + $attributeCode: {in:["{$optionValues[0]}", "{$optionValues[1]}", "{$optionValues[2]}"]} + } + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + } + page_info{ + current_page + page_size + total_pages + } + filters{ + name + request_var + filter_items_count + filter_items{ + label + items_count + value_string + __typename + } + } + aggregations{ + attribute_code + count + label + options + { + label + value + + } + } + + } +} +QUERY; + + $response = $this->graphQlQuery($query); + $this->assertEquals(3, $response['products']['total_count']); + $this->assertNotEmpty($response['products']['filters']); + $this->assertNotEmpty($response['products']['aggregations']); + } + + /** + * Get the option value for the custom attribute to be used in the graphql query + * + * @param string $attributeCode + * @return string + */ + private function getDefaultAttributeOptionValue(string $attributeCode) : string + { + /** @var \Magento\Eav\Model\Config $eavConfig */ + $eavConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); + $attribute = $eavConfig->getAttribute('catalog_product', $attributeCode); + /** @var AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + array_shift($options); + $defaultOptionValue = $options[0]->getValue(); + return $defaultOptionValue; + } + + /** + * Full text search for Products and then filter the results by custom attribute ( sort is by defaulty by relevance) + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testSearchAndFilterByCustomAttribute() + { + $this->reIndexAndCleanCache(); + $attribute_code = 'second_test_configurable'; + $optionValue = $this->getDefaultAttributeOptionValue($attribute_code); + + $query = <<<QUERY +{ + products(search:"Simple", + filter:{ + $attribute_code: {in:["{$optionValue}"]} + } + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + } + page_info{ + current_page + page_size + total_pages + } + filters{ + name + request_var + filter_items_count + filter_items{ + label + items_count + value_string + __typename + } + + } + aggregations + { + attribute_code + count + label + options + { + count + label + value + } + } + + } + +} +QUERY; + $response = $this->graphQlQuery($query); + //Verify total count of the products returned + $this->assertEquals(3, $response['products']['total_count']); + $this->assertArrayHasKey('filters', $response['products']); + $this->assertCount(3, $response['products']['aggregations']); + $expectedFilterLayers = + [ + ['name' => 'Category', + 'request_var'=> 'cat' + ], + ['name' => 'Second Test Configurable', + 'request_var'=> 'second_test_configurable' + ] + ]; + $layers = array_map(null, $expectedFilterLayers, $response['products']['filters']); + + //Verify all the three layers from filters : Price, Category and Custom attribute layers + foreach ($layers as $layerIndex => $layerFilterData) { + $this->assertNotEmpty($layerFilterData); + $this->assertEquals( + $layers[$layerIndex][0]['name'], + $response['products']['filters'][$layerIndex]['name'], + 'Layer name does not match' + ); + $this->assertEquals( + $layers[$layerIndex][0]['request_var'], + $response['products']['filters'][$layerIndex]['request_var'], + 'request_var does not match' + ); + } + + // Validate the price layer of aggregations from the response + $this->assertResponseFields( + $response['products']['aggregations'][0], + [ + 'attribute_code' => 'price', + 'count'=> 2, + 'label'=> 'Price', + 'options' => [ + [ + 'count' => 2, + 'label' => '10-20', + 'value' => '10_20', + + ], + [ + 'count' => 1, + 'label' => '40-*', + 'value' => '40_*', + + ], + ], + ] + ); + // Validate the custom attribute layer of aggregations from the response + $this->assertResponseFields( + $response['products']['aggregations'][2], + [ + 'attribute_code' => $attribute_code, + 'count'=> 1, + 'label'=> 'Second Test Configurable', + 'options' => [ + [ + 'count' => 3, + 'label' => 'Option 3', + 'value' => $optionValue, + + ] + + ], + ] + ); + // 7 categories including the subcategories to which the items belong to , are returned + $this->assertCount(7, $response['products']['aggregations'][1]['options']); + unset($response['products']['aggregations'][1]['options']); + $this->assertResponseFields( + $response['products']['aggregations'][1], + [ + 'attribute_code' => 'category_id', + 'count'=> 7, + 'label'=> 'Category' + ] + ); + } + + /** + * Filter by category and custom attribute + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterByCategoryIdAndCustomAttribute() + { + $this->reIndexAndCleanCache(); + $categoryId = 13; + $optionValue = $this->getDefaultAttributeOptionValue('second_test_configurable'); + $query = <<<QUERY +{ + products(filter:{ + category_id : {eq:"{$categoryId}"} + second_test_configurable: {eq: "{$optionValue}"} + }, + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + } + page_info{ + current_page + page_size + total_pages + } + filters{ + name + request_var + filter_items_count + filter_items{ + label + items_count + value_string + __typename + } + } + aggregations + { + attribute_code + count + label + options + { + count + label + value + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(2, $response['products']['total_count']); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); + $product1 = $productRepository->get('simple'); + $product2 = $productRepository->get('simple-4'); + $filteredProducts = [$product1, $product2]; + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + //phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ($itemIndex = 0; $itemIndex < count($filteredProducts); $itemIndex++) { + $this->assertNotEmpty($productItemsInResponse[$itemIndex]); + //validate that correct products are returned + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ 'name' => $filteredProducts[$itemIndex]->getName(), + 'sku' => $filteredProducts[$itemIndex]->getSku() + ] + ); + } + $this->assertNotEmpty($response['products']['filters'], 'filters is empty'); + $this->assertNotEmpty($response['products']['aggregations'], 'Aggregations should not be empty'); + $this->assertCount(3, $response['products']['aggregations']); + + $actualCategoriesFromResponse = $response['products']['aggregations'][1]['options']; + + //Validate the number of categories/sub-categories that contain the products with the custom attribute + $this->assertCount(6, $actualCategoriesFromResponse); + + $expectedCategoryInAggregrations = + [ + [ + 'count' => 2, + 'label' => 'Category 1', + 'value'=> '3' + ], + [ + 'count'=> 1, + 'label' => 'Category 1.1', + 'value'=> '4' + + ], + [ + 'count'=> 1, + 'label' => 'Movable Position 2', + 'value'=> '10' + + ], + [ + 'count'=> 1, + 'label' => 'Movable Position 3', + 'value'=> '11' + ], + [ + 'count'=> 1, + 'label' => 'Category 12', + 'value'=> '12' + + ], + [ + 'count'=> 2, + 'label' => 'Category 1.2', + 'value'=> '13' + ], + ]; + $categoryInAggregations = array_map(null, $expectedCategoryInAggregrations, $actualCategoriesFromResponse); + +//Validate the categories and sub-categories data in the filter layer + foreach ($categoryInAggregations as $index => $categoryAggregationsData) { + $this->assertNotEmpty($categoryAggregationsData); + $this->assertEquals( + $categoryInAggregations[$index][0]['label'], + $actualCategoriesFromResponse[$index]['label'], + 'Category is incorrect' + ); + $this->assertEquals( + $categoryInAggregations[$index][0]['count'], + $actualCategoriesFromResponse[$index]['count'], + 'Products count in the category is incorrect' + ); + } + } + + /** + * Filter by exact match of product url key + * + * @magentoApiDataFixture Magento/Catalog/_files/categories.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterBySingleProductUrlKey() + { + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); + /** @var Product $product */ + $product = $productRepository->get('simple-4'); + $urlKey = $product->getUrlKey(); + + $query = <<<QUERY +{ + products(filter:{ + url_key:{eq:"{$urlKey}"} + } + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + url_key + } + page_info{ + current_page + page_size + total_pages + } + filters{ + name + request_var + filter_items_count + filter_items{ + label + items_count + value_string + __typename + } + } + aggregations + { + attribute_code + count + label + options + { + count + label + value + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(1, $response['products']['total_count'], 'More than 1 product found'); + $this->assertCount(2, $response['products']['aggregations']); + $this->assertResponseFields( + $response['products']['items'][0], + [ + 'name' => $product->getName(), + 'sku' => $product->getSku(), + 'url_key'=> $product->getUrlKey() + ] + ); + $this->assertEquals('Price', $response['products']['aggregations'][0]['label']); + $this->assertEquals('Category', $response['products']['aggregations'][1]['label']); + //Disable the product + $product->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_DISABLED); + $productRepository->save($product); + $query2 = <<<QUERY +{ + products(filter:{ + url_key:{eq:"{$urlKey}"} + } + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + url_key + } + + filters{ + name + request_var + filter_items_count + } + aggregations + { + attribute_code + count + label + options + { + count + label + value + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query2); + $this->assertEquals(0, $response['products']['total_count'], 'Total count should be zero'); + $this->assertEmpty($response['products']['items']); + $this->assertEmpty($response['products']['aggregations']); + } + + /** + * Filter by multiple product url keys + * + * @magentoApiDataFixture Magento/Catalog/_files/categories.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterByMultipleProductUrlKeys() + { + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); + /** @var Product $product */ + $product1 = $productRepository->get('simple'); + $product2 = $productRepository->get('12345'); + $product3 = $productRepository->get('simple-4'); + $filteredProducts = [$product1, $product2, $product3]; + $urlKey =[]; + foreach ($filteredProducts as $product) { + $urlKey[] = $product->getUrlKey(); + } + + $query = <<<QUERY +{ + products(filter:{ + url_key:{in:["{$urlKey[0]}", "{$urlKey[1]}", "{$urlKey[2]}"]} + } + pageSize: 3 + currentPage: 1 + ) + { + total_count + items + { + name + sku + url_key + } + page_info{ + current_page + page_size + + } + filters{ + name + request_var + filter_items_count + } + aggregations + { + attribute_code + count + label + options + { + count + label + value + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(3, $response['products']['total_count'], 'Total count is incorrect'); + $this->assertCount(2, $response['products']['aggregations']); + + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + //phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ($itemIndex = 0; $itemIndex < count($filteredProducts); $itemIndex++) { + $this->assertNotEmpty($productItemsInResponse[$itemIndex]); + //validate that correct products are returned + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ 'name' => $filteredProducts[$itemIndex]->getName(), + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'url_key'=> $filteredProducts[$itemIndex]->getUrlKey() + ] + ); + } + } + /** * Get array with expected data for layered navigation filters * @@ -157,8 +962,8 @@ private function getExpectedFiltersDataSet() private function assertFilters($response, $expectedFilters, $message = '') { $this->assertArrayHasKey('filters', $response['products'], 'Product has filters'); - $this->assertTrue(is_array(($response['products']['filters'])), 'Product filters is array'); - $this->assertTrue(count($response['products']['filters']) > 0, 'Product filters is not empty'); + $this->assertTrue(is_array(($response['products']['filters'])), 'Product filters is not array'); + $this->assertTrue(count($response['products']['filters']) > 0, 'Product filters is empty'); foreach ($expectedFilters as $expectedFilter) { $found = false; foreach ($response['products']['filters'] as $responseFilter) { @@ -175,12 +980,12 @@ private function assertFilters($response, $expectedFilters, $message = '') } /** - * Verify that items between the price range of 5 and 50 are returned after sorting name in DESC + * Verify product filtering using price range AND matching skus AND name sorted in DESC order * * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function testFilterProductsWithinSpecificPriceRangeSortedByNameDesc() + public function testFilterWithinSpecificPriceRangeSortedByNameDesc() { $query = <<<QUERY @@ -188,12 +993,9 @@ public function testFilterProductsWithinSpecificPriceRangeSortedByNameDesc() products( filter: { - price:{gt: "5", lt: "50"} - or: - { - sku:{like:"simple%"} - name:{like:"Simple%"} - } + price:{from: "5", to: "50"} + sku:{in:["simple1", "simple2"]} + name:{match:"Simple"} } pageSize:4 currentPage:1 @@ -245,79 +1047,6 @@ public function testFilterProductsWithinSpecificPriceRangeSortedByNameDesc() $this->assertEquals(4, $response['products']['page_info']['page_size']); } - /** - * Test a visible product with matching sku or name with special price - * - * Requesting for items that has a special price and price < $60, that are visible in Catalog, Search or Both which - * either has a sku like “simple” or name like “configurable”sorted by price in DESC - * - * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - public function testFilterVisibleProductsWithMatchingSkuOrNameWithSpecialPrice() - { - $query - = <<<QUERY -{ - products( - filter: - { - special_price:{neq:"null"} - price:{lt:"60"} - or: - { - sku:{like:"%simple%"} - name:{like:"%configurable%"} - } - weight:{eq:"1"} - } - pageSize:6 - currentPage:1 - sort: - { - price:DESC - } - ) - { - items - { - sku - price { - minimalPrice { - amount { - value - currency - } - } - } - name - ... on PhysicalProductInterface { - weight - } - type_id - attribute_set_id - } - total_count - page_info - { - page_size - current_page - } - } -} -QUERY; - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); - $product1 = $productRepository->get('simple1'); - $product2 = $productRepository->get('simple2'); - $filteredProducts = [$product2, $product1]; - - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('total_count', $response['products']); - $this->assertEquals(2, $response['products']['total_count']); - $this->assertProductItems($filteredProducts, $response); - } - /** * pageSize = total_count and current page = 2 * expected - error is thrown @@ -329,6 +1058,7 @@ public function testFilterVisibleProductsWithMatchingSkuOrNameWithSpecialPrice() public function testSearchWithFilterWithPageSizeEqualTotalCount() { + $query = <<<QUERY { @@ -336,14 +1066,7 @@ public function testSearchWithFilterWithPageSizeEqualTotalCount() search : "simple" filter: { - special_price:{neq:"null"} - price:{lt:"60"} - or: - { - sku:{like:"%simple%"} - name:{like:"%configurable%"} - } - weight:{eq:"1"} + price:{from:"5.59"} } pageSize:2 currentPage:2 @@ -389,12 +1112,12 @@ public function testSearchWithFilterWithPageSizeEqualTotalCount() } /** - * Requesting for items that match a specific SKU or NAME within a certain price range sorted by Price in ASC order + * Filtering for products and sorting using multiple sort parameters * * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function testQueryProductsInCurrentPageSortedByPriceASC() + public function testFilterByMultipleFilterFieldsSortedByMultipleSortFields() { $query = <<<QUERY @@ -402,18 +1125,17 @@ public function testQueryProductsInCurrentPageSortedByPriceASC() products( filter: { - price:{gt: "5", lt: "50"} - or: - { - sku:{like:"simple%"} - name:{like:"simple%"} - } + price:{to :"50"} + sku:{in:["simple1", "simple2"]} + name:{match:"Simple"} + } pageSize:4 currentPage:1 sort: { price:ASC + name:ASC } ) { @@ -478,22 +1200,25 @@ public function testQueryProductsInCurrentPageSortedByPriceASC() } /** - * Verify the items is correct after sorting their name in ASC order + * Filtering products by fuzzy name match * - * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php + * @magentoApiDataFixture Magento/Catalog/_files/products_for_relevance_sorting.php */ - public function testQueryProductsSortedByNameASC() + public function testFilterProductsForExactMatchingName() { + $query = <<<QUERY { products( filter: { - sku:{in:["simple2", "simple1"]} + name: { + match:"shorts" + } } - pageSize:1 - currentPage:2 + pageSize:2 + currentPage:1 sort: { name:ASC @@ -510,6 +1235,16 @@ public function testQueryProductsSortedByNameASC() { page_size current_page + } + aggregations{ + attribute_code + count + label + options{ + label + value + count + } } } } @@ -518,72 +1253,98 @@ public function testQueryProductsSortedByNameASC() * @var ProductRepositoryInterface $productRepository */ $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); - $product = $productRepository->get('simple2'); - + $product1 = $productRepository->get('grey_shorts'); + $product2 = $productRepository->get('white_shorts'); $response = $this->graphQlQuery($query); $this->assertEquals(2, $response['products']['total_count']); - $this->assertEquals(['page_size' => 1, 'current_page' => 2], $response['products']['page_info']); + $this->assertEquals(['page_size' => 2, 'current_page' => 1], $response['products']['page_info']); $this->assertEquals( - [['sku' => $product->getSku(), 'name' => $product->getName()]], + [ + ['sku' => $product1->getSku(), 'name' => $product1->getName()], + ['sku' => $product2->getSku(), 'name' => $product2->getName()] + ], $response['products']['items'] ); + $this->assertArrayHasKey('aggregations', $response['products']); + $this->assertCount(2, $response['products']['aggregations']); + $expectedAggregations =[ + [ + 'attribute_code' => 'price', + 'count' => 2, + 'label' => 'Price', + 'options' => [ + [ + 'label' => '10-20', + 'value' => '10_20', + 'count' => 1, + ], + [ + 'label' => '20-*', + 'value' => '20_*', + 'count' => 1, + ] + ] + ], + [ + 'attribute_code' => 'category_id', + 'count' => 1, + 'label' => 'Category', + 'options' => [ + [ + 'label' => 'Colorful Category', + 'value' => '330', + 'count' => 2, + ], + ], + ] + ]; + $this->assertEquals($expectedAggregations, $response['products']['aggregations']); } /** - * @magentoApiDataFixture Magento/Catalog/_files/product_in_multiple_categories.php + * @magentoApiDataFixture Magento/Catalog/_files/categories.php */ - public function testFilteringForProductInMultipleCategories() + public function testFilteringForProductsFromMultipleCategories() { - $productSku = 'simple333'; $query = <<<QUERY { - products(filter:{sku:{eq:"{$productSku}"}}) + products(filter:{ + category_id :{in:["4","5","12"]} + }) { - items{ - id - sku - name - attribute_set_id - categories { - id + items + { + sku + name + } + total_count + filters{ + request_var + name + filter_items_count + filter_items{ + value_string + label + } + } } - } - } } QUERY; $response = $this->graphQlQuery($query); /** @var ProductRepositoryInterface $productRepository */ - $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); - /** @var ProductInterface $product */ - $product = $productRepository->get('simple333'); - $categoryIds = $product->getCategoryIds(); - foreach ($categoryIds as $index => $value) { - $categoryIds[$index] = [ 'id' => (int)$value]; - } - $this->assertNotEmpty($response['products']['items'][0]['categories'], "Categories must not be empty"); - $this->assertNotNull($response['products']['items'][0]['categories'], "categories must not be null"); - $this->assertEquals($categoryIds, $response['products']['items'][0]['categories']); - /** @var MetadataPool $metaData */ - $metaData = ObjectManager::getInstance()->get(MetadataPool::class); - $linkField = $metaData->getMetadata(ProductInterface::class)->getLinkField(); - $assertionMap = [ - - ['response_field' => 'id', 'expected_value' => $product->getData($linkField)], - ['response_field' => 'sku', 'expected_value' => $product->getSku()], - ['response_field' => 'name', 'expected_value' => $product->getName()], - ['response_field' => 'attribute_set_id', 'expected_value' => $product->getAttributeSetId()] - ]; - $this->assertResponseFields($response['products']['items'][0], $assertionMap); + $this->assertEquals(3, $response['products']['total_count']); } /** + * Filter products by single category + * * @magentoApiDataFixture Magento/Catalog/_files/product_in_multiple_categories.php * @return void */ - public function testFilterProductsByCategoryIds() + public function testFilterProductsBySingleCategoryId() { $queryCategoryId = 333; $query @@ -619,7 +1380,7 @@ public function testFilterProductsByCategoryIds() QUERY; $response = $this->graphQlQuery($query); - + $this->assertEquals(2, $response['products']['total_count'], 'Incorrect count of products returned'); /** @var CategoryLinkManagement $productLinks */ $productLinks = ObjectManager::getInstance()->get(CategoryLinkManagement::class); /** @var CategoryRepositoryInterface $categoryRepository */ @@ -663,12 +1424,84 @@ public function testFilterProductsByCategoryIds() } /** - * Sorting by price in the DESC order from the filtered items with default pageSize + * Sorting the search results by relevance (DESC => most relevant) + * + * Search for products for a fuzzy match and checks if all matching results returned including + * results based on matching keywords from description + * + * @magentoApiDataFixture Magento/Catalog/_files/products_for_relevance_sorting.php + * @return void + */ + public function testSearchAndSortByRelevance() + { + $this->reIndexAndCleanCache(); + $search_term ="blue"; + $query + = <<<QUERY +{ + products( + search:"{$search_term}" + sort:{relevance:DESC} + pageSize: 5 + currentPage: 1 + ) + { + total_count + items + { + name + sku + } + page_info{ + current_page + page_size + total_pages + } + filters{ + name + request_var + filter_items_count + filter_items{ + label + items_count + value_string + __typename + } + } + aggregations{ + attribute_code + count + label + options{ + label + value + count + } + } + } + +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(3, $response['products']['total_count']); + $this->assertNotEmpty($response['products']['filters'], 'Filters should have the Category layer'); + $this->assertEquals('Colorful Category', $response['products']['filters'][0]['filter_items'][0]['label']); + $productsInResponse = ['Blue briefs','Navy Blue Striped Shoes','Grey shorts']; + $count = count($response['products']['items']); + for ($i = 0; $i < $count; $i++) { + $this->assertEquals($productsInResponse[$i], $response['products']['items'][$i]['name']); + } + $this->assertCount(2, $response['products']['aggregations']); + } + + /** + * Filtering for product with sku "equals" a specific value + * If pageSize and current page are not requested, default values are returned * * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function testQuerySortByPriceDESCWithDefaultPageSize() + public function testFilterByExactSkuAndSortByPriceDesc() { $query = <<<QUERY @@ -676,16 +1509,10 @@ public function testQuerySortByPriceDESCWithDefaultPageSize() products( filter: { - price:{gt: "5", lt: "60"} - or: - { - sku:{like:"%simple%"} - name:{like:"%Configurable%"} - } + sku:{eq:"simple1"} } sort: { - price:DESC } ) @@ -720,83 +1547,35 @@ public function testQuerySortByPriceDESCWithDefaultPageSize() /** @var ProductRepositoryInterface $productRepository */ $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); $visibleProduct1 = $productRepository->get('simple1'); - $visibleProduct2 = $productRepository->get('simple2'); - $filteredProducts = [$visibleProduct2, $visibleProduct1]; + + $filteredProducts = [$visibleProduct1]; $response = $this->graphQlQuery($query); - $this->assertEquals(2, $response['products']['total_count']); + $this->assertEquals(1, $response['products']['total_count']); $this->assertProductItems($filteredProducts, $response); $this->assertEquals(20, $response['products']['page_info']['page_size']); $this->assertEquals(1, $response['products']['page_info']['current_page']); } - - /** - * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php - */ - public function testProductQueryUsingFromAndToFilterInput() - { - $query - = <<<QUERY -{ - products( - filter: { - price:{ - from:"5" to:"20" - } - } - sort: { - sku: DESC - } - ) { - total_count - items { - attribute_set_id - sku - name - price { - minimalPrice { - amount { - value - currency - } - } - maximalPrice { - amount { - value - currency - } - } - } - type_id - ...on PhysicalProductInterface { - weight - } - } - } -} -QUERY; - - $response = $this->graphQlQuery($query); - $this->assertEquals(2, $response['products']['total_count']); - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); - $product1 = $productRepository->get('simple1'); - $product2 = $productRepository->get('simple2'); - $filteredProducts = [$product2, $product1]; - - $this->assertProductItemsWithMaximalAndMinimalPriceCheck($filteredProducts, $response); - } - /** - * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php + * Fuzzy search filtered for price and sorted by price and name + * + * @magentoApiDataFixture Magento/Catalog/_files/products_for_relevance_sorting.php */ public function testProductBasicFullTextSearchQuery() { - $textToSearch = 'Simple'; + $this->reIndexAndCleanCache(); + $textToSearch = 'blue'; $query =<<<QUERY { products( search: "{$textToSearch}" + filter:{ + price:{to:"50"} + } + sort:{ + price:DESC + name:ASC + } ) { total_count @@ -816,18 +1595,36 @@ public function testProductBasicFullTextSearchQuery() page_size current_page } + filters{ + filter_items { + items_count + label + value_string + } + } + aggregations{ + attribute_code + count + label + options{ + count + label + value + } + } } } QUERY; /** @var ProductRepositoryInterface $productRepository */ $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); - $prod1 = $productRepository->get('simple1'); - + $prod1 = $productRepository->get('blue_briefs'); + $prod2 = $productRepository->get('grey_shorts'); + $prod3 = $productRepository->get('navy-striped-shoes'); $response = $this->graphQlQuery($query); - $this->assertEquals(1, $response['products']['total_count']); + $this->assertEquals(3, $response['products']['total_count']); - $filteredProducts = [$prod1]; + $filteredProducts = [$prod1, $prod2, $prod3]; $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); foreach ($productItemsInResponse as $itemIndex => $itemArray) { $this->assertNotEmpty($itemArray); @@ -839,7 +1636,7 @@ public function testProductBasicFullTextSearchQuery() 'price' => [ 'minimalPrice' => [ 'amount' => [ - 'value' => $filteredProducts[$itemIndex]->getSpecialPrice(), + 'value' => $filteredProducts[$itemIndex]->getPrice(), 'currency' => 'USD' ] ] @@ -850,24 +1647,43 @@ public function testProductBasicFullTextSearchQuery() } /** + * Filter products purely in a given price range + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php */ - public function testProductsThatMatchWithPricesFromList() + public function testFilterWithinASpecificPriceRangeSortedByPriceDESC() { + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); + + $prod1 = $productRepository->get('simple1'); + $prod2 = $productRepository->get('simple2'); + $filteredProducts = [$prod1, $prod2]; + /** @var \Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ + $categoryLinkManagement = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->create(\Magento\Catalog\Api\CategoryLinkManagementInterface::class); + foreach ($filteredProducts as $product) { + $categoryLinkManagement->assignProductToCategories( + $product->getSku(), + [333] + ); + } + $query =<<<QUERY - { +{ products( filter: { - price:{in:["10","20"]} + price:{from:"5" to: "20"} } pageSize:4 currentPage:1 sort: { - name:DESC + price:ASC } ) { @@ -876,6 +1692,18 @@ public function testProductsThatMatchWithPricesFromList() attribute_set_id sku price { + minimalPrice { + amount { + value + currency + } + } + maximalPrice { + amount { + value + currency + } + } regularPrice { amount { value @@ -890,6 +1718,12 @@ public function testProductsThatMatchWithPricesFromList() type_id } total_count + filters + { + request_var + name + filter_items_count + } page_info { page_size @@ -898,34 +1732,16 @@ public function testProductsThatMatchWithPricesFromList() } } QUERY; + $response = $this->graphQlQuery($query); $this->assertEquals(2, $response['products']['total_count']); - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); - - $prod1 = $productRepository->get('simple2'); - $prod2 = $productRepository->get('simple1'); - $filteredProducts = [$prod1, $prod2]; - $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); - foreach ($productItemsInResponse as $itemIndex => $itemArray) { - $this->assertNotEmpty($itemArray); - $this->assertResponseFields( - $productItemsInResponse[$itemIndex][0], - ['attribute_set_id' => $filteredProducts[$itemIndex]->getAttributeSetId(), - 'sku' => $filteredProducts[$itemIndex]->getSku(), - 'name' => $filteredProducts[$itemIndex]->getName(), - 'price' => [ - 'regularPrice' => [ - 'amount' => [ - 'value' => $filteredProducts[$itemIndex]->getPrice(), - 'currency' => 'USD' - ] - ] - ], - 'type_id' =>$filteredProducts[$itemIndex]->getTypeId(), - 'weight' => $filteredProducts[$itemIndex]->getWeight() - ] - ); + $this->assertProductItemsWithPriceCheck($filteredProducts, $response); + //verify that by default Price and category are the only layers available + $filterNames = ['Category', 'Price']; + $this->assertCount(2, $response['products']['filters'], 'Filter count does not match'); + $productCount = count($response['products']['filters']); + for ($i = 0; $i < $productCount; $i++) { + $this->assertEquals($filterNames[$i], $response['products']['filters'][$i]['name']); } } @@ -942,21 +1758,17 @@ public function testQueryFilterNoMatchingItems() { products( filter: - { - special_price:{lt:"15"} - price:{lt:"50"} - weight:{gt:"4"} - or: - { - sku:{like:"simple%"} - name:{like:"%simple%"} - } + { + price:{from:"50"} + + description:{match:"Description"} + } pageSize:2 currentPage:1 sort: { - sku:ASC + position:ASC } ) { @@ -1006,7 +1818,7 @@ public function testQueryPageOutOfBoundException() products( filter: { - price:{eq:"10"} + price:{to:"10"} } pageSize:2 currentPage:2 @@ -1053,6 +1865,7 @@ public function testQueryPageOutOfBoundException() } /** + * No filter or search arguments used * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testQueryWithNoSearchOrFilterArgumentException() @@ -1098,7 +1911,7 @@ public function testFilterProductsThatAreOutOfStockWithConfigSettings() products( filter: { - sku:{like:"simple%"} + sku:{eq:"simple_visible_in_stock"} } pageSize:20 @@ -1151,7 +1964,7 @@ public function testInvalidCurrentPage() products ( filter: { sku: { - like:"simple%" + eq:"simple1" } } pageSize: 4 @@ -1180,7 +1993,7 @@ public function testInvalidPageSize() products ( filter: { sku: { - like:"simple%" + eq:"simple2" } } pageSize: 0 @@ -1204,8 +2017,8 @@ public function testInvalidPageSize() private function assertProductItems(array $filteredProducts, array $actualResponse) { $productItemsInResponse = array_map(null, $actualResponse['products']['items'], $filteredProducts); - // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall - for ($itemIndex = 0; $itemIndex < count($filteredProducts); $itemIndex++) { + $count = count($filteredProducts); + for ($itemIndex = 0; $itemIndex < $count; $itemIndex++) { $this->assertNotEmpty($productItemsInResponse[$itemIndex]); $this->assertResponseFields( $productItemsInResponse[$itemIndex][0], @@ -1227,7 +2040,7 @@ private function assertProductItems(array $filteredProducts, array $actualRespon } } - private function assertProductItemsWithMaximalAndMinimalPriceCheck(array $filteredProducts, array $actualResponse) + private function assertProductItemsWithPriceCheck(array $filteredProducts, array $actualResponse) { $productItemsInResponse = array_map(null, $actualResponse['products']['items'], $filteredProducts); @@ -1250,7 +2063,14 @@ private function assertProductItemsWithMaximalAndMinimalPriceCheck(array $filter 'value' => $filteredProducts[$itemIndex]->getSpecialPrice(), 'currency' => 'USD' ] - ] + ], + 'regularPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getPrice(), + 'currency' => 'USD' + ] + ] + ], 'type_id' =>$filteredProducts[$itemIndex]->getTypeId(), 'weight' => $filteredProducts[$itemIndex]->getWeight() diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php index e11e2e8d108c2..dae71c1767caf 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php @@ -282,7 +282,6 @@ public function testQueryAllFieldsSimpleProduct() $this->assertBaseFields($product, $response['products']['items'][0]); $this->assertEavAttributes($product, $response['products']['items'][0]); $this->assertOptions($product, $response['products']['items'][0]); - $this->assertTierPrices($product, $response['products']['items'][0]); $this->assertArrayHasKey('websites', $response['products']['items'][0]); $this->assertWebsites($product, $response['products']['items'][0]['websites']); self::assertEquals( @@ -592,7 +591,7 @@ public function testProductPrices() $secondProductSku = 'simple-156'; $query = <<<QUERY { - products(filter: {min_price: {gt: "100.0"}, max_price: {gt: "150.0", lt: "250.0"}}) + products(filter: {price: {from: "150.0", to: "250.0"}}) { items { attribute_set_id @@ -723,24 +722,7 @@ private function assertCustomAttribute($actualResponse) $customAttribute = null; $this->assertEquals($customAttribute, $actualResponse['attribute_code_custom']); } - - /** - * @param ProductInterface $product - * @param $actualResponse - */ - private function assertTierPrices($product, $actualResponse) - { - $tierPrices = $product->getTierPrices(); - $this->assertNotEmpty($actualResponse['tier_prices'], "Precondition failed: 'tier_prices' must not be empty"); - foreach ($actualResponse['tier_prices'] as $tierPriceIndex => $tierPriceArray) { - foreach ($tierPriceArray as $key => $value) { - /** @var \Magento\Catalog\Model\Product\TierPrice $tierPrice */ - $tierPrice = $tierPrices[$tierPriceIndex]; - $this->assertEquals($value, $tierPrice->getData($key)); - } - } - } - + /** * @param ProductInterface $product * @param $actualResponse @@ -795,6 +777,7 @@ private function assertOptions($product, $actualResponse) ]; $this->assertResponseFields($value, $assertionMapValues); } else { + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $assertionMap = array_merge( $assertionMap, [ @@ -823,7 +806,7 @@ private function assertOptions($product, $actualResponse) $valueKeyName = 'date_option'; $valueAssertionMap = []; } - + // phpcs:ignore Magento2.Performance.ForeachArrayMerge $valueAssertionMap = array_merge( $valueAssertionMap, [ @@ -980,7 +963,7 @@ public function testProductInAllAnchoredCategories() { $query = <<<QUERY { - products(filter: {sku: {like: "12345%"}}) + products(filter: {sku: {in: ["12345"]}}) { items { @@ -1030,7 +1013,7 @@ public function testProductWithNonAnchoredParentCategory() { $query = <<<QUERY { - products(filter: {sku: {like: "12345%"}}) + products(filter: {sku: {in: ["12345"]}}) { items { @@ -1084,7 +1067,11 @@ public function testProductInNonAnchoredSubCategories() { $query = <<<QUERY { - products(filter: {sku: {like: "12345%"}}) + products(filter: + { + sku: {in:["12345"]} + } + ) { items { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogCustomer/TierPricesForCustomersTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogCustomer/TierPricesForCustomersTest.php new file mode 100644 index 0000000000000..95f012f798d02 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogCustomer/TierPricesForCustomersTest.php @@ -0,0 +1,332 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\CatalogCustomer; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\Data\ProductTierPriceInterfaceFactory; +use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; +use Magento\Integration\Api\CustomerTokenServiceInterface; + +class TierPricesForCustomersTest extends GraphQlAbstract +{ + /** @var \Magento\TestFramework\ObjectManager */ + private $objectManager; + + /** @var GetMaskedQuoteIdByReservedOrderId */ + private $getMaskedQuoteIdByReservedOrderId; + + /** @var CustomerTokenServiceInterface */ + private $customerTokenService; + + protected function setUp() + { + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $this->getMaskedQuoteIdByReservedOrderId = $this->objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + $this->customerTokenService = $this->objectManager->get(CustomerTokenServiceInterface::class); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + */ + public function testTierPricesForGeneralGroup() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $tierPriceData =[ + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + // Query response with headers for customers + $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap()); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(1, $response['products']['items'][0]['tier_prices']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + */ + public function testTierPricesForGeneralAndAllCustomerGroups() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 3, + 'value'=> 6 + ], + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 7 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + // Query response with headers for customers + $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap()); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 3, + 'value'=> 6 + ], + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 7 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(2, $response['products']['items'][0]['tier_prices']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + */ + public function testTierPricesForNotLoggedInGroupOnly() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + // Query response with headers for customers + $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap()); + $this->assertEmpty($response['products']['items'][0]['tier_prices']); + + $expectedResponse = []; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + */ + public function testTierPricesForNotLoggedInAndGeneralGroups() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 7, + 'value'=> 6.5 + ], + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 5 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + // Query response with headers for customers + $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap()); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 5 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(1, $response['products']['items'][0]['tier_prices']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + */ + public function testTierPricesForAllCustomerGroupsOnly() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6 + ], + + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + // Query response with headers for customers + $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap()); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(1, $response['products']['items'][0]['tier_prices']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + */ + public function testTierPricesForAllCustomerGroupsAndNotLoggedInGroup() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ], + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6.5 + ], + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + // Query response with headers for customers + $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap()); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(1, $response['products']['items'][0]['tier_prices']); + } + + /** + * @param ProductInterface $product + * @param array $tierPriceData + */ + private function saveTierPrices($product, $tierPriceData) + { + $tierPrices = []; + /** @var ProductTierPriceInterfaceFactory $tierPriceFactory */ + $tierPriceFactory = $this->objectManager + ->get(\Magento\Catalog\Api\Data\ProductTierPriceInterfaceFactory::class); + foreach ($tierPriceData as $tierPrice) { + $tierPrices[] = $tierPriceFactory->create( + [ + 'data' => $tierPrice + ] + ); + } + $product->setTierPrices($tierPrices); + $product->save(); + } + + /** + * @param string $maskedQuoteId + * @return string + */ + private function getProductSearchQuery(string $productSku): string + { + return <<<QUERY +{ + products(filter: {sku: {eq: "{$productSku}"}}) + { + items { + sku + name + tier_prices { + customer_group_id + percentage_value + qty + value + } + } + } +} +QUERY; + } + + /** + * @param string $username + * @param string $password + * @return array + */ + private function getHeaderMap(string $username = 'customer@example.com', string $password = 'password'): array + { + $customerToken = $this->customerTokenService->createCustomerAccessToken($username, $password); + + $headerMap = [ 'Authorization' => 'Bearer ' . $customerToken]; + return $headerMap; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogCustomer/TierPricesForGuestsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogCustomer/TierPricesForGuestsTest.php new file mode 100644 index 0000000000000..d4c834c0aea6a --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogCustomer/TierPricesForGuestsTest.php @@ -0,0 +1,301 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\CatalogCustomer; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\Data\ProductTierPriceInterfaceFactory; + +class TierPricesForGuestsTest extends GraphQlAbstract +{ + /** + * @var \Magento\TestFramework\ObjectManager + */ + private $objectManager; + + protected function setUp() + { + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + */ + public function testTierPricesForGeneralGroup() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $tierPriceData =[ + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + $response = $this->graphQlQuery($query); + + $expectedResponse = []; + $this->assertEmpty($response['products']['items'][0]['tier_prices']); + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + */ + public function testTierPricesForGeneralAndAllCustomerGroups() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 6 + ], + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 6 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + */ + public function testTierPricesForNotLoggedInGroupOnly() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(1, $response['products']['items'][0]['tier_prices']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + */ + public function testTierPricesForNotLoggedInAndGeneralGroups() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 6.5 + ], + [ + 'customer_group_id' => 1, + 'percentage_value'=> null, + 'qty'=> 5, + 'value'=> 6 + ] + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 6.5 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(1, $response['products']['items'][0]['tier_prices']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + */ + public function testTierPricesForAllCustomerGroupsOnly() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 5 + ], + + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 6, + 'value'=> 5 + ] + ]; + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(1, $response['products']['items'][0]['tier_prices']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/product_simple.php + */ + public function testTierPricesForAllCustomerGroupsAndNotLoggedInGroup() + { + $productSku = 'simple'; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + + $tierPriceData = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ], + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6.5 + ], + ]; + + $this->saveTierPrices($product, $tierPriceData); + $query = $this->getProductSearchQuery($productSku); + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['products']['items'][0]['tier_prices']); + $this->assertArrayHasKey('tier_prices', $response['products']['items'][0]); + $expectedResponse = [ + [ + 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, + 'percentage_value'=> null, + 'qty'=> 2, + 'value'=> 8 + ], + [ + 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, + 'percentage_value'=> null, + 'qty'=> 4, + 'value'=> 6.5 + ] + ]; + + $this->assertResponseFields($response['products']['items'][0]['tier_prices'], $expectedResponse); + $this->assertCount(2, $response['products']['items'][0]['tier_prices']); + } + + /** + * @param ProductInterface $product + * @param array $tierPriceData + */ + private function saveTierPrices($product, $tierPriceData) + { + $tierPrices = []; + /** @var ProductTierPriceInterfaceFactory $tierPriceFactory */ + $tierPriceFactory = $this->objectManager + ->get(\Magento\Catalog\Api\Data\ProductTierPriceInterfaceFactory::class); + foreach ($tierPriceData as $tierPrice) { + $tierPrices[] = $tierPriceFactory->create( + [ + 'data' => $tierPrice + ] + ); + } + $product->setTierPrices($tierPrices); + $product->save(); + } + + /** + * @param string $maskedQuoteId + * @return string + */ + private function getProductSearchQuery(string $productSku): string + { + return <<<QUERY +{ + products(filter: {sku: {eq: "{$productSku}"}}) + { + items { + sku + name + tier_prices { + customer_group_id + percentage_value + qty + value + } + } + } +} +QUERY; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogUrlRewrite/UrlResolverTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogUrlRewrite/UrlResolverTest.php new file mode 100644 index 0000000000000..b15cfe3a5b913 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogUrlRewrite/UrlResolverTest.php @@ -0,0 +1,391 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\CatalogUrlRewrite; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\TestFramework\ObjectManager; +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\UrlRewrite\Model\UrlFinderInterface; +use Magento\UrlRewrite\Model\UrlRewrite; + +/** + * Test the GraphQL endpoint's URLResolver query to verify canonical URL's are correctly returned. + */ +class UrlResolverTest extends GraphQlAbstract +{ + /** @var ObjectManager */ + private $objectManager; + + /** + * {@inheritdoc} + */ + protected function setUp() + { + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + } + + /** + * Tests if target_path(relative_url) is resolved for Product entity + * + * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php + */ + public function testProductUrlResolver() + { + $productSku = 'p002'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $storeId = $product->getStoreId(); + + $query + = <<<QUERY +{ + products(filter: {sku: {eq: "{$productSku}"}}) + { + items { + url_key + url_suffix + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $urlPath = $response['products']['items'][0]['url_key'] . $response['products']['items'][0]['url_suffix']; + + /** @var UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(UrlFinderInterface::class); + $actualUrls = $urlFinder->findOneByData( + [ + 'request_path' => $urlPath, + 'store_id' => $storeId + ] + ); + $targetPath = $actualUrls->getTargetPath(); + $expectedType = $actualUrls->getEntityType(); + + $query + = <<<QUERY +{ + urlResolver(url:"{$urlPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals($product->getEntityId(), $response['urlResolver']['id']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); + } + + /** + * Tests the use case where relative_url is provided as resolver input in the Query + * + * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php + */ + public function testProductUrlWithCanonicalUrlInput() + { + $productSku = 'p002'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $storeId = $product->getStoreId(); + + $query + = <<<QUERY +{ + products(filter: {sku: {eq: "{$productSku}"}}) + { + items { + url_key + url_suffix + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $urlPath = $response['products']['items'][0]['url_key'] . $response['products']['items'][0]['url_suffix']; + + /** @var UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(UrlFinderInterface::class); + $actualUrls = $urlFinder->findOneByData( + [ + 'request_path' => $urlPath, + 'store_id' => $storeId + ] + ); + $targetPath = $actualUrls->getTargetPath(); + $expectedType = $actualUrls->getEntityType(); + $canonicalPath = $actualUrls->getTargetPath(); + $query + = <<<QUERY +{ + urlResolver(url:"{$canonicalPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals($product->getEntityId(), $response['urlResolver']['id']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); + } + + /** + * Test for category entity + * + * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php + */ + public function testCategoryUrlResolver() + { + $productSku = 'p002'; + $categoryUrlPath = 'cat-1.html'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $storeId = $product->getStoreId(); + + /** @var UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(UrlFinderInterface::class); + $actualUrls = $urlFinder->findOneByData( + [ + 'request_path' => $categoryUrlPath, + 'store_id' => $storeId + ] + ); + $categoryId = $actualUrls->getEntityId(); + $targetPath = $actualUrls->getTargetPath(); + $expectedType = $actualUrls->getEntityType(); + + $query + = <<<QUERY +{ + category(id:{$categoryId}) { + url_key + url_suffix + } +} +QUERY; + $response = $this->graphQlQuery($query); + $urlPath = $response['category']['url_key'] . $response['category']['url_suffix']; + + $query + = <<<QUERY +{ + urlResolver(url:"{$urlPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals($categoryId, $response['urlResolver']['id']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); + } + + /** + * Tests the use case where the url_key of the existing product is changed + * + * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php + */ + public function testProductUrlRewriteResolver() + { + $productSku = 'p002'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $storeId = $product->getStoreId(); + $product->setUrlKey('p002-new')->save(); + + $query + = <<<QUERY +{ + products(filter: {sku: {eq: "{$productSku}"}}) + { + items { + url_key + url_suffix + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $urlPath = $response['products']['items'][0]['url_key'] . $response['products']['items'][0]['url_suffix']; + + $this->assertEquals($urlPath, 'p002-new' . $response['products']['items'][0]['url_suffix']); + + /** @var UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(UrlFinderInterface::class); + $actualUrls = $urlFinder->findOneByData( + [ + 'request_path' => $urlPath, + 'store_id' => $storeId + ] + ); + $targetPath = $actualUrls->getTargetPath(); + $expectedType = $actualUrls->getEntityType(); + $query + = <<<QUERY +{ + urlResolver(url:"{$urlPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals($product->getEntityId(), $response['urlResolver']['id']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); + } + + /** + * Tests if null is returned when an invalid request_path is provided as input to urlResolver + * + * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php + */ + public function testInvalidUrlResolverInput() + { + $productSku = 'p002'; + $urlPath = 'p002'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $storeId = $product->getStoreId(); + + /** @var UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(UrlFinderInterface::class); + $urlFinder->findOneByData( + [ + 'request_path' => $urlPath, + 'store_id' => $storeId + ] + ); + $query + = <<<QUERY +{ + urlResolver(url:"{$urlPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertNull($response['urlResolver']); + } + + /** + * Test for category entity with leading slash + * + * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php + */ + public function testCategoryUrlWithLeadingSlash() + { + $productSku = 'p002'; + $categoryUrlPath = 'cat-1.html'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($productSku, false, null, true); + $storeId = $product->getStoreId(); + + /** @var UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(UrlFinderInterface::class); + $actualUrls = $urlFinder->findOneByData( + [ + 'request_path' => $categoryUrlPath, + 'store_id' => $storeId + ] + ); + $categoryId = $actualUrls->getEntityId(); + $targetPath = $actualUrls->getTargetPath(); + $expectedType = $actualUrls->getEntityType(); + + $query + = <<<QUERY +{ + category(id:{$categoryId}) { + url_key + url_suffix + } +} +QUERY; + $response = $this->graphQlQuery($query); + $urlPath = $response['category']['url_key'] . $response['category']['url_suffix']; + + $query = <<<QUERY +{ + urlResolver(url:"/{$urlPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals($categoryId, $response['urlResolver']['id']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); + } + + /** + * Test for custom type which point to the valid product/category/cms page. + * + * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php + */ + public function testGetNonExistentUrlRewrite() + { + $urlPath = 'non-exist-product.html'; + /** @var UrlRewrite $urlRewrite */ + $urlRewrite = $this->objectManager->create(UrlRewrite::class); + $urlRewrite->load($urlPath, 'request_path'); + + /** @var UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(UrlFinderInterface::class); + $actualUrls = $urlFinder->findOneByData( + [ + 'request_path' => $urlPath, + 'store_id' => 1 + ] + ); + $targetPath = $actualUrls->getTargetPath(); + + $query = <<<QUERY +{ + urlResolver(url:"{$urlPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals('PRODUCT', $response['urlResolver']['type']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CmsUrlRewrite/UrlResolverTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CmsUrlRewrite/UrlResolverTest.php new file mode 100644 index 0000000000000..ece421925a31c --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CmsUrlRewrite/UrlResolverTest.php @@ -0,0 +1,101 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\CmsUrlRewrite; + +use Magento\CmsUrlRewrite\Model\CmsPageUrlRewriteGenerator; +use Magento\TestFramework\ObjectManager; +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\Cms\Helper\Page as PageHelper; +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Test the GraphQL endpoint's URLResolver query to verify canonical URL's are correctly returned. + */ +class UrlResolverTest extends GraphQlAbstract +{ + /** @var ObjectManager */ + private $objectManager; + + protected function setUp() + { + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + } + + /** + * @magentoApiDataFixture Magento/Cms/_files/pages.php + */ + public function testCMSPageUrlResolver() + { + /** @var \Magento\Cms\Model\Page $page */ + $page = $this->objectManager->get(\Magento\Cms\Model\Page::class); + $page->load('page100'); + $cmsPageId = $page->getId(); + $requestPath = $page->getIdentifier(); + + /** @var \Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator $urlPathGenerator */ + $urlPathGenerator = $this->objectManager->get(\Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator::class); + + /** @param \Magento\Cms\Api\Data\PageInterface $page */ + $targetPath = $urlPathGenerator->getCanonicalUrlPath($page); + $expectedEntityType = CmsPageUrlRewriteGenerator::ENTITY_TYPE; + + $query + = <<<QUERY +{ + urlResolver(url:"{$requestPath}") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals($cmsPageId, $response['urlResolver']['id']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + $this->assertEquals(strtoupper(str_replace('-', '_', $expectedEntityType)), $response['urlResolver']['type']); + } + + /** + * Test resolution of '/' path to home page + */ + public function testResolveSlash() + { + /** @var \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfigInterface */ + $scopeConfigInterface = $this->objectManager->get(ScopeConfigInterface::class); + $homePageIdentifier = $scopeConfigInterface->getValue( + PageHelper::XML_PATH_HOME_PAGE, + ScopeInterface::SCOPE_STORE + ); + /** @var \Magento\Cms\Model\Page $page */ + $page = $this->objectManager->get(\Magento\Cms\Model\Page::class); + $page->load($homePageIdentifier); + $homePageId = $page->getId(); + /** @var \Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator $urlPathGenerator */ + $urlPathGenerator = $this->objectManager->get(\Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator::class); + /** @param \Magento\Cms\Api\Data\PageInterface $page */ + $targetPath = $urlPathGenerator->getCanonicalUrlPath($page); + $query + = <<<QUERY +{ + urlResolver(url:"/") + { + id + relative_url + type + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals($homePageId, $response['urlResolver']['id']); + $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); + $this->assertEquals('CMS_PAGE', $response['urlResolver']['type']); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php index 378d3e7dcd9aa..5ea5cef63a13f 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php @@ -7,6 +7,7 @@ namespace Magento\GraphQl\ConfigurableProduct; +use Exception; use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\GraphQlAbstract; @@ -139,6 +140,67 @@ public function testAddMultipleConfigurableProductToCart() } } + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + * + * @expectedException Exception + * @expectedExceptionMessage You need to choose options for your item. + */ + public function testAddVariationFromAnotherConfigurableProductWithTheSameSuperAttributeToCart() + { + $this->markTestSkipped( + 'Magento automatically selects the correct child product according to the super attribute + https://github.com/magento/graphql-ce/issues/940' + ); + + $searchResponse = $this->graphQlQuery($this->getFetchProductQuery('configurable_12345')); + $product = current($searchResponse['products']['items']); + + $quantity = 2; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + $parentSku = $product['sku']; + + $sku = 'simple_20'; + + $query = $this->getQuery( + $maskedQuoteId, + $parentSku, + $sku, + $quantity + ); + + $this->graphQlMutation($query); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_different_super_attribute.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + * + * @expectedException Exception + * @expectedExceptionMessage You need to choose options for your item. + */ + public function testAddVariationFromAnotherConfigurableProductWithDifferentSuperAttributeToCart() + { + $searchResponse = $this->graphQlQuery($this->getFetchProductQuery('configurable_12345')); + $product = current($searchResponse['products']['items']); + + $quantity = 2; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + $parentSku = $product['sku']; + + $sku = 'simple_20'; + + $query = $this->getQuery( + $maskedQuoteId, + $parentSku, + $sku, + $quantity + ); + + $this->graphQlMutation($query); + } + /** * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/UpdateConfigurableCartItemsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/UpdateConfigurableCartItemsTest.php new file mode 100644 index 0000000000000..4f4e7ecab6fe3 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/UpdateConfigurableCartItemsTest.php @@ -0,0 +1,129 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\GraphQl\ConfigurableProduct; + +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Framework\Exception\NoSuchEntityException as NoSuchEntityException; +use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; +use Magento\Quote\Model\Quote\Item; +use Magento\Quote\Model\QuoteFactory; +use Magento\Quote\Model\QuoteIdMaskFactory; +use Magento\Quote\Model\ResourceModel\Quote as QuoteResource; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * checks that qty of configurable product is updated in cart + */ +class UpdateConfigurableCartItemsTest extends GraphQlAbstract +{ + /** + * @var QuoteIdMaskFactory + */ + protected $quoteIdMaskFactory; + + /** + * @var GetMaskedQuoteIdByReservedOrderId + */ + private $getMaskedQuoteIdByReservedOrderId; + + /** + * @var QuoteFactory + */ + private $quoteFactory; + + /** + * @var QuoteResource + */ + private $quoteResource; + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/quote_with_configurable_product.php + */ + public function testUpdateConfigurableCartItemQuantity() + { + $reservedOrderId = 'test_cart_with_configurable'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute($reservedOrderId); + + $productSku = 'simple_10'; + $newQuantity = 123; + $quoteItem = $this->getQuoteItemBySku($productSku, $reservedOrderId); + + $query = $this->getQuery($maskedQuoteId, (int)$quoteItem->getId(), $newQuantity); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('updateCartItems', $response); + self::assertArrayHasKey('quantity', $response['updateCartItems']['cart']['items']['0']); + self::assertEquals($newQuantity, $response['updateCartItems']['cart']['items']['0']['quantity']); + } + + /** + * @inheritdoc + */ + protected function setUp() + { + $objectManager = Bootstrap::getObjectManager(); + $this->getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + $this->quoteFactory = $objectManager->get(QuoteFactory::class); + $this->quoteResource = $objectManager->get(QuoteResource::class); + $this->quoteIdMaskFactory = Bootstrap::getObjectManager()->get(QuoteIdMaskFactory::class); + } + + /** + * @param string $maskedQuoteId + * @param int $quoteItemId + * @param int $newQuantity + * @return string + */ + private function getQuery(string $maskedQuoteId, int $quoteItemId, int $newQuantity): string + { + return <<<QUERY +mutation { + updateCartItems(input: { + cart_id:"$maskedQuoteId" + cart_items: [ + { + cart_item_id: $quoteItemId + quantity: $newQuantity + } + ] + }) { + cart { + items { + quantity + } + } + } +} +QUERY; + } + + /** + * Returns quote item by product SKU + * + * @param string $sku + * @return Item|bool + * @throws NoSuchEntityException + */ + private function getQuoteItemBySku(string $sku, string $reservedOrderId) + { + $quote = $this->quoteFactory->create(); + $this->quoteResource->load($quote, $reservedOrderId, 'reserved_order_id'); + $item = false; + foreach ($quote->getAllItems() as $quoteItem) { + if ($quoteItem->getSku() == $sku && $quoteItem->getProductType() == Configurable::TYPE_CODE && + !$quoteItem->getParentItemId()) { + $item = $quoteItem; + break; + } + } + + return $item; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerAddressTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerAddressTest.php index 203e9b5cb42e5..04fb304305250 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerAddressTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerAddressTest.php @@ -43,7 +43,6 @@ protected function setUp() */ public function testCreateCustomerAddress() { - $customerId = 1; $newAddress = [ 'region' => [ 'region' => 'Arizona', @@ -124,11 +123,12 @@ public function testCreateCustomerAddress() $response = $this->graphQlMutation($mutation, [], '', $this->getCustomerAuthHeaders($userName, $password)); $this->assertArrayHasKey('createCustomerAddress', $response); $this->assertArrayHasKey('customer_id', $response['createCustomerAddress']); - $this->assertEquals($customerId, $response['createCustomerAddress']['customer_id']); + $this->assertEquals(null, $response['createCustomerAddress']['customer_id']); $this->assertArrayHasKey('id', $response['createCustomerAddress']); $address = $this->addressRepository->getById($response['createCustomerAddress']['id']); $this->assertEquals($address->getId(), $response['createCustomerAddress']['id']); + $address->setCustomerId(null); $this->assertCustomerAddressesFields($address, $response['createCustomerAddress']); $this->assertCustomerAddressesFields($address, $newAddress); } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerTest.php index c5714012f38c9..5b3ff041d481b 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/CreateCustomerTest.php @@ -68,6 +68,7 @@ public function testCreateCustomerAccountWithPassword() QUERY; $response = $this->graphQlMutation($query); + $this->assertEquals(null, $response['createCustomer']['customer']['id']); $this->assertEquals($newFirstname, $response['createCustomer']['customer']['firstname']); $this->assertEquals($newLastname, $response['createCustomer']['customer']['lastname']); $this->assertEquals($newEmail, $response['createCustomer']['customer']['email']); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetAddressesTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetAddressesTest.php index e0c6841b2ea2b..c1573d7dbd8af 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetAddressesTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetAddressesTest.php @@ -62,7 +62,7 @@ public function testGetCustomerWithAddresses() is_array([$response['customer']['addresses']]), " Addresses field must be of an array type." ); - self::assertEquals($customer->getId(), $response['customer']['id']); + self::assertEquals(null, $response['customer']['id']); $this->assertCustomerAddressesFields($customer, $response); } @@ -105,7 +105,7 @@ public function testGetCustomerAddressIfUserIsNotAuthorized() * @param CustomerInterface $customer * @param array $actualResponse */ - public function assertCustomerAddressesFields($customer, $actualResponse) + private function assertCustomerAddressesFields($customer, $actualResponse) { /** @var AddressInterface $addresses */ $addresses = $customer->getAddresses(); @@ -113,7 +113,7 @@ public function assertCustomerAddressesFields($customer, $actualResponse) $this->assertNotEmpty($addressValue); $assertionMap = [ ['response_field' => 'id', 'expected_value' => $addresses[$addressKey]->getId()], - ['response_field' => 'customer_id', 'expected_value' => $addresses[$addressKey]->getCustomerId()], + ['response_field' => 'customer_id', 'expected_value' => 0], ['response_field' => 'region_id', 'expected_value' => $addresses[$addressKey]->getRegionId()], ['response_field' => 'country_id', 'expected_value' => $addresses[$addressKey]->getCountryId()], ['response_field' => 'telephone', 'expected_value' => $addresses[$addressKey]->getTelephone()], diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetCustomerTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetCustomerTest.php index 928a263e8531b..b15a799ae7521 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetCustomerTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetCustomerTest.php @@ -50,6 +50,7 @@ public function testGetCustomer() $query = <<<QUERY query { customer { + id firstname lastname email @@ -58,6 +59,7 @@ public function testGetCustomer() QUERY; $response = $this->graphQlQuery($query, [], '', $this->getCustomerAuthHeaders($currentEmail, $currentPassword)); + $this->assertEquals(null, $response['customer']['id']); $this->assertEquals('John', $response['customer']['firstname']); $this->assertEquals('Smith', $response['customer']['lastname']); $this->assertEquals($currentEmail, $response['customer']['email']); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/UpdateCustomerAddressTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/UpdateCustomerAddressTest.php index 9840236dc9896..625d027f58d24 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/UpdateCustomerAddressTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/UpdateCustomerAddressTest.php @@ -59,7 +59,6 @@ public function testUpdateCustomerAddress() { $userName = 'customer@example.com'; $password = 'password'; - $customerId = 1; $addressId = 1; $mutation = $this->getMutation($addressId); @@ -67,7 +66,7 @@ public function testUpdateCustomerAddress() $response = $this->graphQlMutation($mutation, [], '', $this->getCustomerAuthHeaders($userName, $password)); $this->assertArrayHasKey('updateCustomerAddress', $response); $this->assertArrayHasKey('customer_id', $response['updateCustomerAddress']); - $this->assertEquals($customerId, $response['updateCustomerAddress']['customer_id']); + $this->assertEquals(null, $response['updateCustomerAddress']['customer_id']); $this->assertArrayHasKey('id', $response['updateCustomerAddress']); $address = $this->addressRepository->getById($addressId); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/CartPromotionsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/CartPromotionsTest.php new file mode 100644 index 0000000000000..2588de97bad7d --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/CartPromotionsTest.php @@ -0,0 +1,549 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Quote; + +use Magento\Catalog\Api\CategoryLinkManagementInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\SalesRule\Model\ResourceModel\Rule\Collection; +use Magento\SalesRule\Model\Rule; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\Tax\Model\ClassModel as TaxClassModel; +use Magento\Tax\Model\ResourceModel\TaxClass\CollectionFactory as TaxClassCollectionFactory; + +/** + * Test cases for applying cart promotions to items in cart + */ +class CartPromotionsTest extends GraphQlAbstract +{ + /** + * Test adding single cart rule to multiple products in a cart + * + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + * @magentoApiDataFixture Magento/SalesRule/_files/rules_category.php + */ + public function testCartPromotionSingleCartRule() + { + $skus =['simple1', 'simple2']; + $objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->get(ProductRepositoryInterface::class); + /** @var Product $prod2 */ + $prod1 = $productRepository->get('simple1'); + $prod2 = $productRepository->get('simple2'); + $categoryId = 66; + /** @var \Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ + $categoryLinkManagement = $objectManager->create(CategoryLinkManagementInterface::class); + foreach ($skus as $sku) { + $categoryLinkManagement->assignProductToCategories( + $sku, + [$categoryId] + ); + } + /** @var Collection $ruleCollection */ + $ruleCollection = $objectManager->get(Collection::class); + $ruleLabels = []; + /** @var Rule $rule */ + foreach ($ruleCollection as $rule) { + $ruleLabels = $rule->getStoreLabels(); + } + $qty = 2; + $cartId = $this->createEmptyCart(); + $this->addMultipleSimpleProductsToCart($cartId, $qty, $skus[0], $skus[1]); + $query = $this->getCartItemPricesQuery($cartId); + $response = $this->graphQlMutation($query); + $this->assertCount(2, $response['cart']['items']); + $productsInCart = [$prod1, $prod2]; + //validating the line item prices, quantity and discount + $this->assertLineItemDiscountPrices($response, $productsInCart, $qty, $ruleLabels); + //total discount on the cart which is the sum of the individual row discounts + $this->assertEquals($response['cart']['prices']['discounts'][0]['amount']['value'], 21.98); + } + + /** + * Assert the row total discounts and individual discount break down and cart rule labels + * + * @param $response + * @param $productsInCart + * @param $qty + * @param $ruleLabels + */ + private function assertLineItemDiscountPrices($response, $productsInCart, $qty, $ruleLabels) + { + $productsInResponse = array_map(null, $response['cart']['items'], $productsInCart); + $count = count($productsInCart); + for ($itemIndex = 0; $itemIndex < $count; $itemIndex++) { + $this->assertNotEmpty($productsInResponse[$itemIndex]); + $this->assertResponseFields( + $productsInResponse[$itemIndex][0], + [ + 'quantity' => $qty, + 'prices' => [ + 'row_total' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty], + 'row_total_including_tax' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty], + 'total_item_discount' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty*0.5], + 'discounts' => [ + 0 =>[ + 'amount' => + ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty*0.5], + 'label' => $ruleLabels[0] + ] + ] + ], + ] + ); + } + } + + /** + * Apply multiple cart rules to multiple products in a cart + * + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + * @magentoApiDataFixture Magento/SalesRule/_files/rules_category.php + * @magentoApiDataFixture Magento/SalesRule/_files/cart_rule_10_percent_off_qty_more_than_2_items.php + */ + public function testCartPromotionsMultipleCartRules() + { + $objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->get(ProductRepositoryInterface::class); + /** @var Product $prod2 */ + $prod1 = $productRepository->get('simple1'); + $prod2 = $productRepository->get('simple2'); + $productsInCart = [$prod1, $prod2]; + $skus =['simple1', 'simple2']; + $categoryId = 66; + /** @var \Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ + $categoryLinkManagement = $objectManager->create(CategoryLinkManagementInterface::class); + foreach ($skus as $sku) { + $categoryLinkManagement->assignProductToCategories( + $sku, + [$categoryId] + ); + } + /** @var Collection $ruleCollection */ + $ruleCollection = $objectManager->get(Collection::class); + $ruleLabels = []; + /** @var Rule $rule */ + foreach ($ruleCollection as $rule) { + $ruleLabels[] = $rule->getStoreLabels(); + } + $qty = 2; + $cartId = $this->createEmptyCart(); + $this->addMultipleSimpleProductsToCart($cartId, $qty, $skus[0], $skus[1]); + $query = $this->getCartItemPricesQuery($cartId); + $response = $this->graphQlMutation($query); + $this->assertCount(2, $response['cart']['items']); + + //validating the individual discounts per line item and total discounts per line item + $productsInResponse = array_map(null, $response['cart']['items'], $productsInCart); + $count = count($productsInCart); + for ($itemIndex = 0; $itemIndex < $count; $itemIndex++) { + $this->assertNotEmpty($productsInResponse[$itemIndex]); + $lineItemDiscount = $productsInResponse[$itemIndex][0]['prices']['discounts']; + $expectedTotalDiscountValue = ($productsInCart[$itemIndex]->getSpecialPrice()*$qty*0.5) + + ($productsInCart[$itemIndex]->getSpecialPrice()*$qty*0.5*0.1); + $this->assertEquals( + $productsInCart[$itemIndex]->getSpecialPrice()*$qty*0.5, + current($lineItemDiscount)['amount']['value'] + ); + $this->assertEquals('TestRule_Label', current($lineItemDiscount)['label']); + + $lineItemDiscountValue = next($lineItemDiscount)['amount']['value']; + $this->assertEquals( + round($productsInCart[$itemIndex]->getSpecialPrice()*$qty*0.5)*0.1, + $lineItemDiscountValue + ); + $this->assertEquals('10% off with two items_Label', end($lineItemDiscount)['label']); + $actualTotalDiscountValue = $lineItemDiscount[0]['amount']['value']+$lineItemDiscount[1]['amount']['value']; + $this->assertEquals(round($expectedTotalDiscountValue, 2), $actualTotalDiscountValue); + + //removing the elements from the response so that the rest of the response values can be compared + unset($productsInResponse[$itemIndex][0]['prices']['discounts']); + unset($productsInResponse[$itemIndex][0]['prices']['total_item_discount']); + $this->assertResponseFields( + $productsInResponse[$itemIndex][0], + [ + 'quantity' => $qty, + 'prices' => [ + 'row_total' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty], + 'row_total_including_tax' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty] + ], + ] + ); + } + $this->assertEquals($response['cart']['prices']['discounts'][0]['amount']['value'], 21.98); + $this->assertEquals($response['cart']['prices']['discounts'][1]['amount']['value'], 2.2); + } + + /** + * Apply cart rules to multiple products in a cart with taxes + * Tax settings : Including and Excluding tax for Price Display and Shopping cart display + * Discount on Prices Includes Tax + * Tax rate = 7.5% + * Cart rule to apply 50% for products assigned to a specific category + * + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + * @magentoApiDataFixture Magento/GraphQl/Tax/_files/tax_rule_for_region_1.php + * @magentoApiDataFixture Magento/GraphQl/Tax/_files/tax_calculation_price_and_cart_display_settings.php + * @magentoApiDataFixture Magento/SalesRule/_files/rules_category.php + */ + public function testCartPromotionsCartRulesWithTaxes() + { + $objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->get(ProductRepositoryInterface::class); + /** @var Product $prod2 */ + $prod1 = $productRepository->get('simple1'); + $prod2 = $productRepository->get('simple2'); + $productsInCart = [$prod1, $prod2]; + $skus =['simple1', 'simple2']; + + /** @var TaxClassCollectionFactory $taxClassCollectionFactory */ + $taxClassCollectionFactory = $objectManager->get(TaxClassCollectionFactory::class); + $taxClassCollection = $taxClassCollectionFactory->create(); + + /** @var TaxClassModel $taxClass */ + $taxClassCollection->addFieldToFilter('class_type', TaxClassModel::TAX_CLASS_TYPE_PRODUCT); + $taxClass = $taxClassCollection->getFirstItem(); + foreach ($productsInCart as $product) { + $product->setCustomAttribute('tax_class_id', $taxClass->getClassId()); + $productRepository->save($product); + } + $categoryId = 66; + /** @var \Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ + $categoryLinkManagement = $objectManager->create(CategoryLinkManagementInterface::class); + foreach ($skus as $sku) { + $categoryLinkManagement->assignProductToCategories( + $sku, + [$categoryId] + ); + } + $qty = 1; + $cartId = $this->createEmptyCart(); + $this->addMultipleSimpleProductsToCart($cartId, $qty, $skus[0], $skus[1]); + $this->setShippingAddressOnCart($cartId); + $query = $this->getCartItemPricesQuery($cartId); + $response = $this->graphQlMutation($query); + $this->assertCount(2, $response['cart']['items']); + $productsInResponse = array_map(null, $response['cart']['items'], $productsInCart); + $count = count($productsInCart); + for ($itemIndex = 0; $itemIndex < $count; $itemIndex++) { + $this->assertNotEmpty($productsInResponse[$itemIndex]); + $rowTotalIncludingTax = round( + $productsInCart[$itemIndex]->getSpecialPrice()*$qty + + $productsInCart[$itemIndex]->getSpecialPrice()*$qty*.075, + 2 + ); + $this->assertResponseFields( + $productsInResponse[$itemIndex][0], + [ + 'quantity' => $qty, + 'prices' => [ + // row_total is the line item price without the tax + 'row_total' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty], + // row_total including tax is the price + price * tax rate + 'row_total_including_tax' => ['value' => $rowTotalIncludingTax], + // discount from cart rule after tax is applied : 50% of row_total_including_tax + 'total_item_discount' => ['value' => round($rowTotalIncludingTax/2, 2)], + 'discounts' => [ + 0 =>[ + 'amount' => + ['value' => round($rowTotalIncludingTax/2, 2)], + 'label' => 'TestRule_Label' + ] + ] + ], + ] + ); + } + // checking the total discount on the entire cart + $this->assertEquals($response['cart']['prices']['discounts'][0]['amount']['value'], 11.82); + } + + /** + * Apply cart rule with a fixed discount when specific coupon code + * + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + * @magentoApiDataFixture Magento/SalesRule/_files/coupon_code_with_wildcard.php + */ + public function testCartPromotionsWithCoupons() + { + $objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->get(ProductRepositoryInterface::class); + /** @var Product $prod2 */ + $prod1 = $productRepository->get('simple1'); + $prod2 = $productRepository->get('simple2'); + $productsInCart = [$prod1, $prod2]; + + $skus =['simple1', 'simple2']; + + /** @var Collection $ruleCollection */ + $ruleCollection = $objectManager->get(Collection::class); + $ruleLabels = []; + /** @var Rule $rule */ + foreach ($ruleCollection as $rule) { + $ruleLabels = $rule->getStoreLabels(); + } + $qty = 2; + // coupon code obtained from the fixture + $couponCode = '2?ds5!2d'; + $cartId = $this->createEmptyCart(); + $this->addMultipleSimpleProductsToCart($cartId, $qty, $skus[0], $skus[1]); + $this->applyCouponsToCart($cartId, $couponCode); + $query = $this->getCartItemPricesQuery($cartId); + $response = $this->graphQlMutation($query); + $this->assertCount(2, $response['cart']['items']); + $productsInResponse = array_map(null, $response['cart']['items'], $productsInCart); + $count = count($productsInCart); + for ($itemIndex = 0; $itemIndex < $count; $itemIndex++) { + $this->assertNotEmpty($productsInResponse[$itemIndex]); + $sumOfPricesForBothProducts = 43.96; + $rowTotal = ($productsInCart[$itemIndex]->getSpecialPrice()*$qty); + $this->assertResponseFields( + $productsInResponse[$itemIndex][0], + [ + 'quantity' => $qty, + 'prices' => [ + 'row_total' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty], + 'row_total_including_tax' => ['value' => $productsInCart[$itemIndex]->getSpecialPrice()*$qty], + 'total_item_discount' => ['value' => round(($rowTotal/$sumOfPricesForBothProducts)*5, 2)], + 'discounts' => [ + 0 =>[ + 'amount' => + ['value' => round(($rowTotal/$sumOfPricesForBothProducts)*5, 2)], + 'label' => $ruleLabels[0] + ] + ] + ], + ] + ); + } + $this->assertEquals($response['cart']['prices']['discounts'][0]['amount']['value'], 5); + } + + /** + * If no discount is applicable to the cart, row total discount should be zero and no rule label shown + * + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + * @magentoApiDataFixture Magento/SalesRule/_files/buy_3_get_1_free.php + */ + public function testCartPromotionsWhenNoDiscountIsAvailable() + { + $skus =['simple1', 'simple2']; + $qty = 2; + $cartId = $this->createEmptyCart(); + $this->addMultipleSimpleProductsToCart($cartId, $qty, $skus[0], $skus[1]); + $query = $this->getCartItemPricesQuery($cartId); + $response = $this->graphQlMutation($query); + $this->assertCount(2, $response['cart']['items']); + foreach ($response['cart']['items'] as $cartItems) { + $this->assertEquals(0, $cartItems['prices']['total_item_discount']['value']); + $this->assertNull($cartItems['prices']['discounts']); + } + } + + /** + * Validating if the discount label in the response shows the default value if no label is available on cart rule + * + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + * @magentoApiDataFixture Magento/SalesRule/_files/cart_rule_10_percent_off.php + */ + public function testCartPromotionsWithNoRuleLabels() + { + $skus =['simple1', 'simple2']; + $qty = 1; + $cartId = $this->createEmptyCart(); + $this->addMultipleSimpleProductsToCart($cartId, $qty, $skus[0], $skus[1]); + $query = $this->getCartItemPricesQuery($cartId); + $response = $this->graphQlMutation($query); + //total items added to cart + $this->assertCount(2, $response['cart']['items']); + //checking the default label for individual line item when cart rule doesn't have a label set + foreach ($response['cart']['items'] as $cartItem) { + $this->assertEquals('Discount', $cartItem['prices']['discounts'][0]['label']); + } + } + + /** + * Apply coupon to the cart + * + * @param string $cartId + * @param string $couponCode + */ + private function applyCouponsToCart(string $cartId, string $couponCode) + { + $query = <<<QUERY +mutation { + applyCouponToCart(input: {cart_id: "$cartId", coupon_code: "$couponCode"}) { + cart { + applied_coupons { + code + } + } + } +} +QUERY; + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('applyCouponToCart', $response); + self::assertEquals($couponCode, $response['applyCouponToCart']['cart']['applied_coupons'][0]['code']); + } + + /** + * @param string $cartId + * @return string + */ + private function getCartItemPricesQuery(string $cartId): string + { + return <<<QUERY +{ + cart(cart_id:"{$cartId}"){ + items{ + quantity + prices{ + row_total{ + value + } + row_total_including_tax{ + value + } + total_item_discount{value} + discounts{ + amount{value} + label + } + } + } + prices{ + discounts{ + amount{value} + } + + } + } +} + +QUERY; + } + + /** + * @return string + */ + private function createEmptyCart(): string + { + $query = <<<QUERY +mutation { + createEmptyCart +} +QUERY; + $response = $this->graphQlMutation($query); + $cartId = $response['createEmptyCart']; + return $cartId; + } + + /** + * @param string $cartId + * @param int $sku1 + * @param int $qty + * @param string $sku2 + */ + private function addMultipleSimpleProductsToCart(string $cartId, int $qty, string $sku1, string $sku2): void + { + $query = <<<QUERY +mutation { + addSimpleProductsToCart(input: { + cart_id: "{$cartId}", + cart_items: [ + { + data: { + quantity: $qty + sku: "$sku1" + } + } + { + data: { + quantity: $qty + sku: "$sku2" + } + } + ] + } + ) { + cart { + items { + product{sku} + quantity + } + } + } +} +QUERY; + + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('cart', $response['addSimpleProductsToCart']); + self::assertEquals($qty, $response['addSimpleProductsToCart']['cart']['items'][0]['quantity']); + self::assertEquals($sku1, $response['addSimpleProductsToCart']['cart']['items'][0]['product']['sku']); + self::assertEquals($qty, $response['addSimpleProductsToCart']['cart']['items'][1]['quantity']); + self::assertEquals($sku2, $response['addSimpleProductsToCart']['cart']['items'][1]['product']['sku']); + } + + /** + * Set shipping address for the region for which tax rule is set + * + * @param string $cartId + * @return void + */ + private function setShippingAddressOnCart(string $cartId) :void + { + $query = <<<QUERY +mutation { + setShippingAddressesOnCart( + input: { + cart_id: "$cartId" + shipping_addresses: [ + { + address: { + firstname: "John" + lastname: "Doe" + company: "Magento" + street: ["test street 1", "test street 2"] + city: "Montgomery" + region: "AL" + postcode: "36043" + country_code: "US" + telephone: "88776655" + save_in_address_book: false + } + } + ] + } + ) { + cart { + shipping_addresses { + city + region{label} + } + } + } +} +QUERY; + $response = $this->graphQlMutation($query); + self::assertEquals( + 'Montgomery', + $response['setShippingAddressesOnCart']['cart']['shipping_addresses'][0]['city'] + ); + self::assertEquals( + 'Alabama', + $response['setShippingAddressesOnCart']['cart']['shipping_addresses'][0]['region']['label'] + ); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php index 5a4cc88d69623..6a06b143d5fcf 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php @@ -158,7 +158,7 @@ private function findProduct(): string products ( filter: { sku: { - like:"simple%" + eq:"simple1" } } pageSize: 1 diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/ApplyCouponsToCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/ApplyCouponsToCartTest.php new file mode 100644 index 0000000000000..9adafa7e097f2 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/ApplyCouponsToCartTest.php @@ -0,0 +1,192 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Quote\Guest; + +use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * Test Apply Coupons to Cart functionality for guest + */ +class ApplyCouponsToCartTest extends GraphQlAbstract +{ + /** + * @var GetMaskedQuoteIdByReservedOrderId + */ + private $getMaskedQuoteIdByReservedOrderId; + + protected function setUp() + { + $objectManager = Bootstrap::getObjectManager(); + $this->getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + } + + /** + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php + * @magentoApiDataFixture Magento/SalesRule/_files/coupon_code_with_wildcard.php + */ + public function testApplyCouponsToCart() + { + $couponCode = '2?ds5!2d'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + $query = $this->getQuery($maskedQuoteId, $couponCode); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('applyCouponToCart', $response); + self::assertEquals($couponCode, $response['applyCouponToCart']['cart']['applied_coupons'][0]['code']); + } + + /** + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @magentoApiDataFixture Magento/SalesRule/_files/coupon_code_with_wildcard.php + * @expectedException \Exception + * @expectedExceptionMessage Cart does not contain products. + */ + public function testApplyCouponsToCartWithoutItems() + { + $couponCode = '2?ds5!2d'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + $query = $this->getQuery($maskedQuoteId, $couponCode); + + $this->graphQlMutation($query); + } + + /** + * _security + * @magentoApiDataFixture Magento/SalesRule/_files/coupon_code_with_wildcard.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/customer/create_empty_cart.php + * @expectedException \Exception + */ + public function testApplyCouponsToCustomerCart() + { + $couponCode = '2?ds5!2d'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + $query = $this->getQuery($maskedQuoteId, $couponCode); + + self::expectExceptionMessage('The current user cannot perform operations on cart "' . $maskedQuoteId . '"'); + $this->graphQlMutation($query); + } + + /** + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php + * @expectedException \Exception + * @expectedExceptionMessage The coupon code isn't valid. Verify the code and try again. + */ + public function testApplyNonExistentCouponToCart() + { + $couponCode = 'non_existent_coupon_code'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + $query = $this->getQuery($maskedQuoteId, $couponCode); + + $this->graphQlMutation($query); + } + + /** + * @magentoApiDataFixture Magento/SalesRule/_files/coupon_code_with_wildcard.php + * @expectedException \Exception + */ + public function testApplyCouponsToNonExistentCart() + { + $couponCode = '2?ds5!2d'; + $maskedQuoteId = 'non_existent_masked_id'; + $query = $this->getQuery($maskedQuoteId, $couponCode); + + self::expectExceptionMessage('Could not find a cart with ID "' . $maskedQuoteId . '"'); + $this->graphQlMutation($query); + } + + /** + * Products in cart don't fit to the coupon + * + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php + * @magentoApiDataFixture Magento/SalesRule/_files/coupon_code_with_wildcard.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/restrict_coupon_usage_for_simple_product.php + * @expectedException \Exception + * @expectedExceptionMessage The coupon code isn't valid. Verify the code and try again. + */ + public function testApplyCouponsWhichIsNotApplicable() + { + $couponCode = '2?ds5!2d'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + $query = $this->getQuery($maskedQuoteId, $couponCode); + + $this->graphQlMutation($query); + } + + /** + * @param string $input + * @param string $message + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @dataProvider dataProviderUpdateWithMissedRequiredParameters + * @expectedException \Exception + */ + public function testApplyCouponsWithMissedRequiredParameters(string $input, string $message) + { + $query = <<<QUERY +mutation { + applyCouponToCart(input: {{$input}}) { + cart { + applied_coupons { + code + } + } + } +} +QUERY; + + $this->expectExceptionMessage($message); + $this->graphQlMutation($query); + } + + /** + * @return array + */ + public function dataProviderUpdateWithMissedRequiredParameters(): array + { + return [ + 'missed_cart_id' => [ + 'coupon_code: "test"', + 'Required parameter "cart_id" is missing' + ], + 'missed_coupon_code' => [ + 'cart_id: "test_quote"', + 'Required parameter "coupon_code" is missing' + ], + ]; + } + + /** + * @param string $maskedQuoteId + * @param string $couponCode + * @return string + */ + private function getQuery(string $maskedQuoteId, string $couponCode): string + { + return <<<QUERY +mutation { + applyCouponToCart(input: {cart_id: "$maskedQuoteId", coupon_code: "$couponCode"}) { + cart { + applied_coupons { + code + } + } + } +} +QUERY; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CheckoutEndToEndTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CheckoutEndToEndTest.php index ed5aa9303d875..95308a350c953 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CheckoutEndToEndTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CheckoutEndToEndTest.php @@ -95,7 +95,7 @@ private function findProduct(): string products ( filter: { sku: { - like:"simple%" + eq:"simple1" } } pageSize: 1 diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetSelectedShippingMethodTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetSelectedShippingMethodTest.php index 6a75cab1ff4c3..a6e4a4afa9825 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetSelectedShippingMethodTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetSelectedShippingMethodTest.php @@ -133,12 +133,7 @@ public function testGetGetSelectedShippingMethodIfShippingMethodIsNotSet() $shippingAddress = current($response['cart']['shipping_addresses']); self::assertArrayHasKey('selected_shipping_method', $shippingAddress); - - self::assertNull($shippingAddress['selected_shipping_method']['carrier_code']); - self::assertNull($shippingAddress['selected_shipping_method']['method_code']); - self::assertNull($shippingAddress['selected_shipping_method']['carrier_title']); - self::assertNull($shippingAddress['selected_shipping_method']['method_title']); - self::assertNull($shippingAddress['selected_shipping_method']['amount']); + self::assertNull($shippingAddress['selected_shipping_method']); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrdersTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrdersTest.php index 9c969befa328b..11a2216b6668f 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrdersTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrdersTest.php @@ -38,9 +38,7 @@ public function testOrdersQuery() query { customerOrders { items { - id - increment_id - created_at + order_number grand_total status } @@ -54,27 +52,27 @@ public function testOrdersQuery() $expectedData = [ [ - 'increment_id' => '100000002', + 'order_number' => '100000002', 'status' => 'processing', 'grand_total' => 120.00 ], [ - 'increment_id' => '100000003', + 'order_number' => '100000003', 'status' => 'processing', 'grand_total' => 130.00 ], [ - 'increment_id' => '100000004', + 'order_number' => '100000004', 'status' => 'closed', 'grand_total' => 140.00 ], [ - 'increment_id' => '100000005', + 'order_number' => '100000005', 'status' => 'complete', 'grand_total' => 150.00 ], [ - 'increment_id' => '100000006', + 'order_number' => '100000006', 'status' => 'complete', 'grand_total' => 160.00 ] @@ -84,19 +82,19 @@ public function testOrdersQuery() foreach ($expectedData as $key => $data) { $this->assertEquals( - $data['increment_id'], - $actualData[$key]['increment_id'], - "increment_id is different than the expected for order - " . $data['increment_id'] + $data['order_number'], + $actualData[$key]['order_number'], + "order_number is different than the expected for order - " . $data['order_number'] ); $this->assertEquals( $data['grand_total'], $actualData[$key]['grand_total'], - "grand_total is different than the expected for order - " . $data['increment_id'] + "grand_total is different than the expected for order - " . $data['order_number'] ); $this->assertEquals( $data['status'], $actualData[$key]['status'], - "status is different than the expected for order - " . $data['increment_id'] + "status is different than the expected for order - " . $data['order_number'] ); } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Store/StoreConfigResolverTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Store/StoreConfigResolverTest.php index 4657a1e763ae1..076c7bece5ff7 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Store/StoreConfigResolverTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Store/StoreConfigResolverTest.php @@ -30,6 +30,7 @@ protected function setUp() /** * @magentoApiDataFixture Magento/Store/_files/store.php + * @magentoConfigFixture default_store store/information/name Test Store */ public function testGetStoreConfig() { @@ -62,7 +63,8 @@ public function testGetStoreConfig() secure_base_url, secure_base_link_url, secure_base_static_url, - secure_base_media_url + secure_base_media_url, + store_name } } QUERY; @@ -89,5 +91,6 @@ public function testGetStoreConfig() $response['storeConfig']['secure_base_static_url'] ); $this->assertEquals($storeConfig->getSecureBaseMediaUrl(), $response['storeConfig']['secure_base_media_url']); + $this->assertEquals('Test Store', $response['storeConfig']['store_name']); } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSearchTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSearchTest.php index f0397c51c4660..8ba8b534cfe5c 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSearchTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSearchTest.php @@ -30,7 +30,7 @@ public function testFilterLn() products ( filter:{ sku:{ - like:"%simple%" + in:["simple1", "simple2", "simple3"] } } pageSize: 4 diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/UrlRewrite/UrlResolverTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/UrlRewrite/UrlResolverTest.php index 8eaf33483531d..bd20741fb7417 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/UrlRewrite/UrlResolverTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/UrlRewrite/UrlResolverTest.php @@ -7,23 +7,15 @@ namespace Magento\GraphQl\UrlRewrite; -use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\CmsUrlRewrite\Model\CmsPageUrlRewriteGenerator; use Magento\TestFramework\ObjectManager; use Magento\TestFramework\TestCase\GraphQlAbstract; -use Magento\UrlRewrite\Model\UrlFinderInterface; -use Magento\Cms\Helper\Page as PageHelper; -use Magento\Store\Model\ScopeInterface; -use Magento\Framework\App\Config\ScopeConfigInterface; -use Magento\UrlRewrite\Model\UrlRewrite; /** * Test the GraphQL endpoint's URLResolver query to verify canonical URL's are correctly returned. */ class UrlResolverTest extends GraphQlAbstract { - - /** @var ObjectManager */ + /** @var ObjectManager */ private $objectManager; protected function setUp() @@ -31,370 +23,6 @@ protected function setUp() $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); } - /** - * Tests if target_path(relative_url) is resolved for Product entity - * - * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php - */ - public function testProductUrlResolver() - { - $productSku = 'p002'; - $urlPath = 'p002.html'; - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); - $product = $productRepository->get($productSku, false, null, true); - $storeId = $product->getStoreId(); - - /** @var UrlFinderInterface $urlFinder */ - $urlFinder = $this->objectManager->get(UrlFinderInterface::class); - $actualUrls = $urlFinder->findOneByData( - [ - 'request_path' => $urlPath, - 'store_id' => $storeId - ] - ); - $targetPath = $actualUrls->getTargetPath(); - $expectedType = $actualUrls->getEntityType(); - $query - = <<<QUERY -{ - urlResolver(url:"{$urlPath}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals($product->getEntityId(), $response['urlResolver']['id']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); - } - - /** - * Tests the use case where relative_url is provided as resolver input in the Query - * - * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php - */ - public function testProductUrlWithCanonicalUrlInput() - { - $productSku = 'p002'; - $urlPath = 'p002.html'; - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); - $product = $productRepository->get($productSku, false, null, true); - $storeId = $product->getStoreId(); - $product->getUrlKey(); - - /** @var UrlFinderInterface $urlFinder */ - $urlFinder = $this->objectManager->get(UrlFinderInterface::class); - $actualUrls = $urlFinder->findOneByData( - [ - 'request_path' => $urlPath, - 'store_id' => $storeId - ] - ); - $targetPath = $actualUrls->getTargetPath(); - $expectedType = $actualUrls->getEntityType(); - $canonicalPath = $actualUrls->getTargetPath(); - $query - = <<<QUERY -{ - urlResolver(url:"{$canonicalPath}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals($product->getEntityId(), $response['urlResolver']['id']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); - } - - /** - * Test for category entity - * - * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php - */ - public function testCategoryUrlResolver() - { - $productSku = 'p002'; - $urlPath2 = 'cat-1.html'; - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); - $product = $productRepository->get($productSku, false, null, true); - $storeId = $product->getStoreId(); - - /** @var UrlFinderInterface $urlFinder */ - $urlFinder = $this->objectManager->get(UrlFinderInterface::class); - $actualUrls = $urlFinder->findOneByData( - [ - 'request_path' => $urlPath2, - 'store_id' => $storeId - ] - ); - $categoryId = $actualUrls->getEntityId(); - $targetPath = $actualUrls->getTargetPath(); - $expectedType = $actualUrls->getEntityType(); - $query - = <<<QUERY -{ - urlResolver(url:"{$urlPath2}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals($categoryId, $response['urlResolver']['id']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); - } - - /** - * @magentoApiDataFixture Magento/Cms/_files/pages.php - */ - public function testCMSPageUrlResolver() - { - /** @var \Magento\Cms\Model\Page $page */ - $page = $this->objectManager->get(\Magento\Cms\Model\Page::class); - $page->load('page100'); - $cmsPageId = $page->getId(); - $requestPath = $page->getIdentifier(); - - /** @var \Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator $urlPathGenerator */ - $urlPathGenerator = $this->objectManager->get(\Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator::class); - - /** @param \Magento\Cms\Api\Data\PageInterface $page */ - $targetPath = $urlPathGenerator->getCanonicalUrlPath($page); - $expectedEntityType = CmsPageUrlRewriteGenerator::ENTITY_TYPE; - - $query - = <<<QUERY -{ - urlResolver(url:"{$requestPath}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertEquals($cmsPageId, $response['urlResolver']['id']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - $this->assertEquals(strtoupper(str_replace('-', '_', $expectedEntityType)), $response['urlResolver']['type']); - } - - /** - * Tests the use case where the url_key of the existing product is changed - * - * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php - */ - public function testProductUrlRewriteResolver() - { - $productSku = 'p002'; - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); - $product = $productRepository->get($productSku, false, null, true); - $storeId = $product->getStoreId(); - $product->setUrlKey('p002-new')->save(); - $urlPath = $product->getUrlKey() . '.html'; - $this->assertEquals($urlPath, 'p002-new.html'); - - /** @var UrlFinderInterface $urlFinder */ - $urlFinder = $this->objectManager->get(UrlFinderInterface::class); - $actualUrls = $urlFinder->findOneByData( - [ - 'request_path' => $urlPath, - 'store_id' => $storeId - ] - ); - $targetPath = $actualUrls->getTargetPath(); - $expectedType = $actualUrls->getEntityType(); - $query - = <<<QUERY -{ - urlResolver(url:"{$urlPath}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals($product->getEntityId(), $response['urlResolver']['id']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); - } - - /** - * Tests if null is returned when an invalid request_path is provided as input to urlResolver - * - * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php - */ - public function testInvalidUrlResolverInput() - { - $productSku = 'p002'; - $urlPath = 'p002'; - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); - $product = $productRepository->get($productSku, false, null, true); - $storeId = $product->getStoreId(); - - /** @var UrlFinderInterface $urlFinder */ - $urlFinder = $this->objectManager->get(UrlFinderInterface::class); - $urlFinder->findOneByData( - [ - 'request_path' => $urlPath, - 'store_id' => $storeId - ] - ); - $query - = <<<QUERY -{ - urlResolver(url:"{$urlPath}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertNull($response['urlResolver']); - } - - /** - * Test for category entity with leading slash - * - * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php - */ - public function testCategoryUrlWithLeadingSlash() - { - $productSku = 'p002'; - $urlPath = 'cat-1.html'; - /** @var ProductRepositoryInterface $productRepository */ - $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); - $product = $productRepository->get($productSku, false, null, true); - $storeId = $product->getStoreId(); - - /** @var UrlFinderInterface $urlFinder */ - $urlFinder = $this->objectManager->get(UrlFinderInterface::class); - $actualUrls = $urlFinder->findOneByData( - [ - 'request_path' => $urlPath, - 'store_id' => $storeId - ] - ); - $categoryId = $actualUrls->getEntityId(); - $targetPath = $actualUrls->getTargetPath(); - $expectedType = $actualUrls->getEntityType(); - - $query = <<<QUERY -{ - urlResolver(url:"/{$urlPath}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals($categoryId, $response['urlResolver']['id']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - $this->assertEquals(strtoupper($expectedType), $response['urlResolver']['type']); - } - - /** - * Test resolution of '/' path to home page - */ - public function testResolveSlash() - { - /** @var \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfigInterface */ - $scopeConfigInterface = $this->objectManager->get(ScopeConfigInterface::class); - $homePageIdentifier = $scopeConfigInterface->getValue( - PageHelper::XML_PATH_HOME_PAGE, - ScopeInterface::SCOPE_STORE - ); - /** @var \Magento\Cms\Model\Page $page */ - $page = $this->objectManager->get(\Magento\Cms\Model\Page::class); - $page->load($homePageIdentifier); - $homePageId = $page->getId(); - /** @var \Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator $urlPathGenerator */ - $urlPathGenerator = $this->objectManager->get(\Magento\CmsUrlRewrite\Model\CmsPageUrlPathGenerator::class); - /** @param \Magento\Cms\Api\Data\PageInterface $page */ - $targetPath = $urlPathGenerator->getCanonicalUrlPath($page); - $query - = <<<QUERY -{ - urlResolver(url:"/") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals($homePageId, $response['urlResolver']['id']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - $this->assertEquals('CMS_PAGE', $response['urlResolver']['type']); - } - - /** - * Test for custom type which point to the valid product/category/cms page. - * - * @magentoApiDataFixture Magento/CatalogUrlRewrite/_files/product_with_category.php - */ - public function testGetNonExistentUrlRewrite() - { - $urlPath = 'non-exist-product.html'; - /** @var UrlRewrite $urlRewrite */ - $urlRewrite = $this->objectManager->create(UrlRewrite::class); - $urlRewrite->load($urlPath, 'request_path'); - - /** @var UrlFinderInterface $urlFinder */ - $urlFinder = $this->objectManager->get(UrlFinderInterface::class); - $actualUrls = $urlFinder->findOneByData( - [ - 'request_path' => $urlPath, - 'store_id' => 1 - ] - ); - $targetPath = $actualUrls->getTargetPath(); - - $query = <<<QUERY -{ - urlResolver(url:"{$urlPath}") - { - id - relative_url - type - } -} -QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals('PRODUCT', $response['urlResolver']['type']); - $this->assertEquals($targetPath, $response['urlResolver']['relative_url']); - } - /** * Test for custom type which point to the invalid product/category/cms page. * diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/VariablesSupportQueryTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/VariablesSupportQueryTest.php index 7448b165fc234..3221026871bc8 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/VariablesSupportQueryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/VariablesSupportQueryTest.php @@ -33,7 +33,7 @@ public function testQueryObjectVariablesSupport() $query = <<<'QUERY' -query GetProductsQuery($pageSize: Int, $filterInput: ProductFilterInput, $priceSort: SortEnum) { +query GetProductsQuery($pageSize: Int, $filterInput: ProductAttributeFilterInput, $priceSort: SortEnum) { products( pageSize: $pageSize filter: $filterInput @@ -58,8 +58,8 @@ public function testQueryObjectVariablesSupport() 'pageSize' => 1, 'priceSort' => 'ASC', 'filterInput' => [ - 'min_price' => [ - 'gt' => 150, + 'price' => [ + 'from' => 150, ], ], ]; diff --git a/dev/tests/api-functional/testsuite/Magento/Swatches/Api/ProductAttributeOptionManagementInterfaceTest.php b/dev/tests/api-functional/testsuite/Magento/Swatches/Api/ProductAttributeOptionManagementInterfaceTest.php new file mode 100644 index 0000000000000..5cbaa76631c23 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/Swatches/Api/ProductAttributeOptionManagementInterfaceTest.php @@ -0,0 +1,225 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Swatches\Api; + +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Api\Data\AttributeOptionInterface; +use Magento\Eav\Api\Data\AttributeOptionLabelInterface; +use Magento\Eav\Model\AttributeRepository; +use Magento\Framework\Webapi\Rest\Request; +use Magento\Swatches\Model\ResourceModel\Swatch\Collection; +use Magento\Swatches\Model\ResourceModel\Swatch\CollectionFactory; +use Magento\Swatches\Model\Swatch; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\WebapiAbstract; + +/** + * Test product attribute option management API for swatch attribute type + */ +class ProductAttributeOptionManagementInterfaceTest extends WebapiAbstract +{ + private const ATTRIBUTE_CODE = 'select_attribute'; + private const SERVICE_NAME = 'catalogProductAttributeOptionManagementV1'; + private const SERVICE_VERSION = 'V1'; + private const RESOURCE_PATH = '/V1/products/attributes'; + + /** + * Test add option to swatch attribute + * + * @magentoApiDataFixture Magento/Catalog/Model/Product/Attribute/_files/select_attribute.php + * @param array $data + * @param array $payload + * @param string $expectedSwatchType + * @param string $expectedLabel + * @param string $expectedValue + * + * @dataProvider addDataProvider + */ + public function testAdd( + array $data, + array $payload, + string $expectedSwatchType, + string $expectedLabel, + string $expectedValue + ) { + $objectManager = Bootstrap::getObjectManager(); + /** @var $attributeRepository AttributeRepository */ + $attributeRepository = $objectManager->get(AttributeRepository::class); + /** @var $attribute Attribute */ + $attribute = $attributeRepository->get(ProductAttributeInterface::ENTITY_TYPE_CODE, self::ATTRIBUTE_CODE); + $attribute->addData($data); + $attributeRepository->save($attribute); + $response = $this->_webApiCall( + [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH . '/' . self::ATTRIBUTE_CODE . '/options', + 'httpMethod' => Request::HTTP_METHOD_POST, + ], + 'soap' => [ + 'service' => self::SERVICE_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::SERVICE_NAME . 'add', + ], + ], + [ + 'attributeCode' => self::ATTRIBUTE_CODE, + 'option' => $payload, + ] + ); + + $this->assertNotNull($response); + $optionId = (int) ltrim($response, 'id_'); + $swatch = $this->getSwatch($optionId); + $this->assertEquals($expectedValue, $swatch->getValue()); + $this->assertEquals($expectedSwatchType, $swatch->getType()); + $options = $attribute->setStoreId(0)->getOptions(); + $this->assertCount(3, $options); + $this->assertEquals($expectedLabel, $options[2]->getLabel()); + } + + /** + * @return array + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function addDataProvider() + { + return [ + 'visual swatch option with value' => [ + 'data' => [ + Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_VISUAL, + 'option' => [ + + ] + ], + 'payload' => [ + AttributeOptionInterface::LABEL => 'Black', + AttributeOptionInterface::VALUE => '#000000', + AttributeOptionInterface::SORT_ORDER => 3, + AttributeOptionInterface::IS_DEFAULT => true, + AttributeOptionInterface::STORE_LABELS => [ + [ + AttributeOptionLabelInterface::LABEL => 'Noir', + AttributeOptionLabelInterface::STORE_ID => 1, + ], + ], + ], + 'expectedSwatchType' => Swatch::SWATCH_TYPE_VISUAL_COLOR, + 'expectedLabel' => 'Black', + 'expectedValue' => '#000000', + ], + 'visual swatch option without value' => [ + 'data' => [ + Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_VISUAL, + 'option' => [ + + ] + ], + 'payload' => [ + AttributeOptionInterface::LABEL => 'Black', + AttributeOptionInterface::VALUE => '', + AttributeOptionInterface::SORT_ORDER => 3, + AttributeOptionInterface::IS_DEFAULT => true, + AttributeOptionInterface::STORE_LABELS => [ + [ + AttributeOptionLabelInterface::LABEL => 'Noir', + AttributeOptionLabelInterface::STORE_ID => 1, + ], + ], + ], + 'expectedSwatchType' => Swatch::SWATCH_TYPE_EMPTY, + 'expectedLabel' => 'Black', + 'expectedValue' => '', + ], + 'text swatch option with value' => [ + 'data' => [ + Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_TEXT, + 'option' => [ + + ] + ], + 'payload' => [ + AttributeOptionInterface::LABEL => 'Small', + AttributeOptionInterface::VALUE => 'S', + AttributeOptionInterface::SORT_ORDER => 3, + AttributeOptionInterface::IS_DEFAULT => true, + AttributeOptionInterface::STORE_LABELS => [ + [ + AttributeOptionLabelInterface::LABEL => 'Petit', + AttributeOptionLabelInterface::STORE_ID => 1, + ], + ], + ], + 'expectedSwatchType' => Swatch::SWATCH_TYPE_TEXTUAL, + 'expectedLabel' => 'Small', + 'expectedValue' => 'S', + ], + 'text swatch option without value' => [ + 'data' => [ + Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_TEXT, + 'option' => [ + + ] + ], + 'payload' => [ + AttributeOptionInterface::LABEL => 'Small', + AttributeOptionInterface::VALUE => '', + AttributeOptionInterface::SORT_ORDER => 3, + AttributeOptionInterface::IS_DEFAULT => true, + AttributeOptionInterface::STORE_LABELS => [ + [ + AttributeOptionLabelInterface::LABEL => 'Petit', + AttributeOptionLabelInterface::STORE_ID => 1, + ], + ], + ], + 'expectedSwatchType' => Swatch::SWATCH_TYPE_TEXTUAL, + 'expectedLabel' => 'Small', + 'expectedValue' => '', + ], + 'text swatch option with value - redeclare store ID 0 in store_labels' => [ + 'data' => [ + Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_TEXT, + 'option' => [ + + ] + ], + 'payload' => [ + AttributeOptionInterface::LABEL => 'Small', + AttributeOptionInterface::VALUE => 'S', + AttributeOptionInterface::SORT_ORDER => 3, + AttributeOptionInterface::IS_DEFAULT => true, + AttributeOptionInterface::STORE_LABELS => [ + [ + AttributeOptionLabelInterface::LABEL => 'Slim', + AttributeOptionLabelInterface::STORE_ID => 0, + ], + ], + ], + 'expectedSwatchType' => Swatch::SWATCH_TYPE_TEXTUAL, + 'expectedLabel' => 'Slim', + 'expectedValue' => 'S', + ], + ]; + } + + /** + * Get swatch model + * + * @param int $optionId + * @return Swatch + */ + private function getSwatch(int $optionId) + { + /** @var Collection $collection */ + $collection = Bootstrap::getObjectManager()->get(CollectionFactory::class)->create(); + $collection->addFieldToFilter('option_id', $optionId); + $collection->setPageSize(1); + return $collection->getFirstItem(); + } +} diff --git a/dev/tests/functional/tests/app/Magento/Backend/Test/TestCase/ConfigPageVisibilityTest.xml b/dev/tests/functional/tests/app/Magento/Backend/Test/TestCase/ConfigPageVisibilityTest.xml index 2d7e609c1c389..0694966c7eaa5 100644 --- a/dev/tests/functional/tests/app/Magento/Backend/Test/TestCase/ConfigPageVisibilityTest.xml +++ b/dev/tests/functional/tests/app/Magento/Backend/Test/TestCase/ConfigPageVisibilityTest.xml @@ -9,6 +9,7 @@ <testCase name="Magento\Backend\Test\TestCase\ConfigPageVisibilityTest" summary="Check Developer section and Locale field"> <variation name="VisibilityOfDeveloperSectionAndLocaleField" summary="Check Developer section and Locale field" ticketId="MAGETWO-63625, MAGETWO-63624"> <data name="tag" xsi:type="string">severity:S1</data> + <data name="tag" xsi:type="string">mftf_migrated:yes</data> <constraint name="Magento\Backend\Test\Constraint\AssertLocaleCodeVisibility" /> <constraint name="Magento\Backend\Test\Constraint\AssertDeveloperSectionVisibility" /> </variation> diff --git a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml index 5fa1cfe5e5911..732dac98e0779 100644 --- a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml +++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml @@ -11,6 +11,7 @@ <data name="productOrigin" xsi:type="string">catalogProductSimple::default</data> <data name="product" xsi:type="string">configurableProduct::default</data> <data name="actionName" xsi:type="string">-</data> + <data name="tag" xsi:type="string">mftf_migrated:yes</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductsInGrid" /> @@ -34,6 +35,7 @@ <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation4"> + <data name="tag" xsi:type="string">mftf_migrated:yes</data> <data name="productOrigin" xsi:type="string">configurableProduct::default</data> <data name="product" xsi:type="string">catalogProductVirtual::required_fields</data> <data name="actionName" xsi:type="string">deleteVariations</data> @@ -48,6 +50,7 @@ <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation6"> + <data name="tag" xsi:type="string">mftf_migrated:yes</data> <data name="productOrigin" xsi:type="string">catalogProductVirtual::default</data> <data name="product" xsi:type="string">configurableProduct::not_virtual_for_type_switching</data> <data name="actionName" xsi:type="string">-</data> @@ -60,6 +63,7 @@ <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductIsNotDisplayedSeparately" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation7"> + <data name="tag" xsi:type="string">mftf_migrated:yes</data> <data name="productOrigin" xsi:type="string">catalogProductVirtual::default</data> <data name="product" xsi:type="string">downloadableProduct::default</data> <data name="actionName" xsi:type="string">-</data> @@ -71,6 +75,7 @@ <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation8"> + <data name="tag" xsi:type="string">mftf_migrated:yes</data> <data name="productOrigin" xsi:type="string">downloadableProduct::default</data> <data name="product" xsi:type="string">catalogProductSimple::default</data> <data name="actionName" xsi:type="string">-</data> @@ -78,6 +83,7 @@ <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation9"> + <data name="tag" xsi:type="string">test_type:acceptance_test</data> <data name="productOrigin" xsi:type="string">downloadableProduct::default</data> <data name="product" xsi:type="string">configurableProduct::not_virtual_for_type_switching</data> <data name="actionName" xsi:type="string">clearDownloadableData</data> @@ -97,6 +103,7 @@ <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation11"> + <data name="tag" xsi:type="string">mftf_migrated:yes</data> <data name="productOrigin" xsi:type="string">catalogProductSimple::default</data> <data name="product" xsi:type="string">downloadableProduct::default</data> <data name="actionName" xsi:type="string">-</data> diff --git a/dev/tests/functional/tests/app/Magento/CheckoutAgreements/Test/Block/Adminhtml/Block/Agreement/Edit/AgreementsForm.xml b/dev/tests/functional/tests/app/Magento/CheckoutAgreements/Test/Block/Adminhtml/Block/Agreement/Edit/AgreementsForm.xml index 95d99f9fa76cd..f98f9ca7cfe24 100644 --- a/dev/tests/functional/tests/app/Magento/CheckoutAgreements/Test/Block/Adminhtml/Block/Agreement/Edit/AgreementsForm.xml +++ b/dev/tests/functional/tests/app/Magento/CheckoutAgreements/Test/Block/Adminhtml/Block/Agreement/Edit/AgreementsForm.xml @@ -18,7 +18,7 @@ <input>select</input> </mode> <stores> - <selector>[name="stores[0]"]</selector> + <selector>[name="stores[]"]</selector> <input>multiselectgrouplist</input> </stores> <checkbox_text /> diff --git a/dev/tests/functional/tests/app/Magento/Customer/Test/Block/Form/CustomerForm.php b/dev/tests/functional/tests/app/Magento/Customer/Test/Block/Form/CustomerForm.php index 61166339475b7..dc1e901a3feae 100644 --- a/dev/tests/functional/tests/app/Magento/Customer/Test/Block/Form/CustomerForm.php +++ b/dev/tests/functional/tests/app/Magento/Customer/Test/Block/Form/CustomerForm.php @@ -29,7 +29,7 @@ class CustomerForm extends Form * * @var string */ - protected $customerAttribute = "[orig-name='%s[]']"; + protected $customerAttribute = "[name='%s[]']"; /** * Validation text message for a field. diff --git a/dev/tests/functional/tests/app/Magento/Newsletter/Test/Block/Adminhtml/Queue/Edit/QueueForm.xml b/dev/tests/functional/tests/app/Magento/Newsletter/Test/Block/Adminhtml/Queue/Edit/QueueForm.xml index 4d2acc76c8703..c1970955013e8 100644 --- a/dev/tests/functional/tests/app/Magento/Newsletter/Test/Block/Adminhtml/Queue/Edit/QueueForm.xml +++ b/dev/tests/functional/tests/app/Magento/Newsletter/Test/Block/Adminhtml/Queue/Edit/QueueForm.xml @@ -11,7 +11,7 @@ <selector>input[name='start_at']</selector> </queue_start_at> <stores> - <selector>select[name="stores[0]"]</selector> + <selector>select[name="stores[]"]</selector> <input>multiselectgrouplist</input> </stores> <newsletter_subject> diff --git a/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/Coupons/Filter.xml b/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/Coupons/Filter.xml index 51809448e4edb..d66c3b702f076 100644 --- a/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/Coupons/Filter.xml +++ b/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/Coupons/Filter.xml @@ -29,7 +29,7 @@ <input>select</input> </price_rule_type> <order_statuses> - <selector>[name="order_statuses[0]"]</selector> + <selector>[name="order_statuses[]"]</selector> <input>multiselect</input> </order_statuses> <rules_list> diff --git a/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/TaxRule/Filter.xml b/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/TaxRule/Filter.xml index 5820de6772e1c..08e783e1329a4 100644 --- a/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/TaxRule/Filter.xml +++ b/dev/tests/functional/tests/app/Magento/Reports/Test/Block/Adminhtml/Sales/TaxRule/Filter.xml @@ -23,7 +23,7 @@ <input>select</input> </show_order_statuses> <order_statuses> - <selector>[name="order_statuses[0]"]</selector> + <selector>[name="order_statuses[]"]</selector> <input>multiselect</input> </order_statuses> <show_empty_rows> diff --git a/dev/tests/functional/tests/app/Magento/Review/Test/Block/Adminhtml/Rating/Edit/RatingForm.xml b/dev/tests/functional/tests/app/Magento/Review/Test/Block/Adminhtml/Rating/Edit/RatingForm.xml index 504ce64bf2a73..3e1a1c727c668 100644 --- a/dev/tests/functional/tests/app/Magento/Review/Test/Block/Adminhtml/Rating/Edit/RatingForm.xml +++ b/dev/tests/functional/tests/app/Magento/Review/Test/Block/Adminhtml/Rating/Edit/RatingForm.xml @@ -12,7 +12,7 @@ <strategy>css selector</strategy> <fields> <stores> - <selector>[name="stores[0]"]</selector> + <selector>[name="stores[]"]</selector> <input>multiselectgrouplist</input> </stores> <is_active> diff --git a/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Report/Filter/Form.xml b/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Report/Filter/Form.xml index 294f64966bde9..d868798eba79d 100644 --- a/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Report/Filter/Form.xml +++ b/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Report/Filter/Form.xml @@ -26,7 +26,7 @@ <input>select</input> </show_order_statuses> <order_statuses> - <selector>[name="order_statuses[0]"]</selector> + <selector>[name="order_statuses[]"]</selector> <input>multiselect</input> </order_statuses> <show_actual_columns> diff --git a/dev/tests/integration/testsuite/Magento/Backend/Model/Auth/SessionTest.php b/dev/tests/integration/testsuite/Magento/Backend/Model/Auth/SessionTest.php index 5ca2bf1f73175..5ef518fd2152f 100644 --- a/dev/tests/integration/testsuite/Magento/Backend/Model/Auth/SessionTest.php +++ b/dev/tests/integration/testsuite/Magento/Backend/Model/Auth/SessionTest.php @@ -3,8 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Backend\Model\Auth; +use Magento\TestFramework\Bootstrap as TestHelper; +use Magento\TestFramework\Helper\Bootstrap; + /** * @magentoAppArea adminhtml * @magentoAppIsolation enabled @@ -18,10 +22,15 @@ class SessionTest extends \PHPUnit\Framework\TestCase private $auth; /** - * @var \Magento\Backend\Model\Auth\Session + * @var Session */ private $authSession; + /** + * @var SessionFactory + */ + private $authSessionFactory; + /** * @var \Magento\Framework\ObjectManagerInterface */ @@ -30,11 +39,12 @@ class SessionTest extends \PHPUnit\Framework\TestCase protected function setUp() { parent::setUp(); - $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $this->objectManager = Bootstrap::getObjectManager(); $this->objectManager->get(\Magento\Framework\Config\ScopeInterface::class) ->setCurrentScope(\Magento\Backend\App\Area\FrontNameResolver::AREA_CODE); $this->auth = $this->objectManager->create(\Magento\Backend\Model\Auth::class); - $this->authSession = $this->objectManager->create(\Magento\Backend\Model\Auth\Session::class); + $this->authSession = $this->objectManager->create(Session::class); + $this->authSessionFactory = $this->objectManager->get(SessionFactory::class); $this->auth->setAuthStorage($this->authSession); $this->auth->logout(); } @@ -52,8 +62,8 @@ public function testIsLoggedIn($loggedIn) { if ($loggedIn) { $this->auth->login( - \Magento\TestFramework\Bootstrap::ADMIN_NAME, - \Magento\TestFramework\Bootstrap::ADMIN_PASSWORD + TestHelper::ADMIN_NAME, + TestHelper::ADMIN_PASSWORD ); } $this->assertEquals($loggedIn, $this->authSession->isLoggedIn()); @@ -63,4 +73,55 @@ public function loginDataProvider() { return [[false], [true]]; } + + /** + * Check that persisting user data is working. + */ + public function testStorage() + { + $this->auth->login(TestHelper::ADMIN_NAME, TestHelper::ADMIN_PASSWORD); + $user = $this->authSession->getUser(); + $acl = $this->authSession->getAcl(); + /** @var Session $session */ + $session = $this->authSessionFactory->create(); + $persistedUser = $session->getUser(); + $persistedAcl = $session->getAcl(); + + $this->assertEquals($user->getData(), $persistedUser->getData()); + $this->assertEquals($user->getAclRole(), $persistedUser->getAclRole()); + $this->assertEquals($acl->getRoles(), $persistedAcl->getRoles()); + $this->assertEquals($acl->getResources(), $persistedAcl->getResources()); + } + + /** + * Check that session manager can work with user storage in the old way. + */ + public function testInnerStorage(): void + { + /** @var \Magento\Framework\Session\StorageInterface $innerStorage */ + $innerStorage = Bootstrap::getObjectManager()->get(\Magento\Framework\Session\StorageInterface::class); + $this->authSession = $this->authSessionFactory->create(['storage' => $innerStorage]); + $this->auth->login(TestHelper::ADMIN_NAME, TestHelper::ADMIN_PASSWORD); + $user = $this->auth->getAuthStorage()->getUser(); + $acl = $this->auth->getAuthStorage()->getAcl(); + $this->assertNotEmpty($user); + $this->assertNotEmpty($acl); + $this->auth->logout(); + $this->assertEmpty($this->auth->getAuthStorage()->getUser()); + $this->assertEmpty($this->auth->getAuthStorage()->getAcl()); + $this->authSession->setUser($user); + $this->authSession->setAcl($acl); + $this->assertTrue($user === $this->authSession->getUser()); + $this->assertTrue($acl === $this->authSession->getAcl()); + $this->authSession->destroy(); + $innerStorage->setUser($user); + $innerStorage->setAcl($acl); + $this->assertTrue($user === $this->authSession->getUser()); + $this->assertTrue($acl === $this->authSession->getAcl()); + /** @var Session $newSession */ + $newSession = $this->authSessionFactory->create(['storage' => $innerStorage]); + $this->assertTrue($newSession->hasUser()); + $this->assertTrue($newSession->hasAcl()); + $this->assertEquals($user->getId(), $newSession->getUser()->getId()); + } } diff --git a/dev/tests/integration/testsuite/Magento/Backend/Model/Locale/ResolverTest.php b/dev/tests/integration/testsuite/Magento/Backend/Model/Locale/ResolverTest.php index d1252be2c4b53..88662a65c7428 100644 --- a/dev/tests/integration/testsuite/Magento/Backend/Model/Locale/ResolverTest.php +++ b/dev/tests/integration/testsuite/Magento/Backend/Model/Locale/ResolverTest.php @@ -3,9 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Backend\Model\Locale; use Magento\Framework\Locale\Resolver; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\User\Model\User; /** * @magentoAppArea adminhtml @@ -20,7 +23,7 @@ class ResolverTest extends \PHPUnit\Framework\TestCase protected function setUp() { parent::setUp(); - $this->_model = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->_model = Bootstrap::getObjectManager()->create( \Magento\Backend\Model\Locale\Resolver::class ); } @@ -38,12 +41,12 @@ public function testSetLocaleWithDefaultLocale() */ public function testSetLocaleWithBaseInterfaceLocale() { - $user = new \Magento\Framework\DataObject(); - $session = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + $user = Bootstrap::getObjectManager()->create(User::class); + $session = Bootstrap::getObjectManager()->get( \Magento\Backend\Model\Auth\Session::class ); $session->setUser($user); - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + Bootstrap::getObjectManager()->get( \Magento\Backend\Model\Auth\Session::class )->getUser()->setInterfaceLocale( 'fr_FR' @@ -56,7 +59,7 @@ public function testSetLocaleWithBaseInterfaceLocale() */ public function testSetLocaleWithSessionLocale() { - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + Bootstrap::getObjectManager()->get( \Magento\Backend\Model\Session::class )->setSessionLocale( 'es_ES' @@ -69,7 +72,7 @@ public function testSetLocaleWithSessionLocale() */ public function testSetLocaleWithRequestLocale() { - $request = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + $request = Bootstrap::getObjectManager() ->get(\Magento\Framework\App\RequestInterface::class); $request->setPostValue(['locale' => 'de_DE']); $this->_checkSetLocale('de_DE'); diff --git a/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/TypeTest.php b/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/TypeTest.php index 51250580eb6ae..77c1ade0fae3f 100644 --- a/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/TypeTest.php +++ b/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/TypeTest.php @@ -49,19 +49,20 @@ protected function setUp() /** * @magentoDataFixture Magento/Bundle/_files/product.php - * @covers \Magento\Indexer\Model\Indexer::reindexAll * @covers \Magento\Bundle\Model\Product\Type::getSearchableData * @magentoDbIsolation disabled */ - public function testPrepareProductIndexForBundleProduct() + public function testGetSearchableData() { - $this->indexer->reindexAll(); - - $select = $this->connectionMock->select()->from($this->resource->getTableName('catalogsearch_fulltext_scope1')) - ->where('`data_index` LIKE ?', '%' . 'Bundle Product Items' . '%'); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); + /** @var \Magento\Catalog\Model\Product $bundleProduct */ + $bundleProduct = $productRepository->get('bundle-product'); + $bundleType = $bundleProduct->getTypeInstance(); + /** @var \Magento\Bundle\Model\Product\Type $bundleType */ + $searchableData = $bundleType->getSearchableData($bundleProduct); - $result = $this->connectionMock->fetchAll($select); - $this->assertCount(1, $result); + $this->assertCount(1, $searchableData); + $this->assertEquals('Bundle Product Items', $searchableData[0]); } /** diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/FlatTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/FlatTest.php index cb2f436ae90a0..58a7a0fbb2ace 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/FlatTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/FlatTest.php @@ -246,6 +246,7 @@ public function testDeleteCategory() * * @magentoConfigFixture current_store catalog/frontend/flat_catalog_category true * @magentoAppArea frontend + * @magentoDbIsolation disabled */ public function testFlatAfterDeleted() { @@ -348,19 +349,21 @@ private function instantiateCategoryModel() */ private function createSubCategoriesInDefaultCategory() { - $this->executeWithFlatEnabledInAdminArea(function () { - $category = $this->getLoadedDefaultCategory(); - - $categoryOne = $this->instantiateCategoryModel(); - $categoryOne->setName('Category One')->setPath($category->getPath())->setIsActive(true); - $category->getResource()->save($categoryOne); - self::$categoryOne = $categoryOne->getId(); - - $categoryTwo = $this->instantiateCategoryModel(); - $categoryTwo->setName('Category Two')->setPath($categoryOne->getPath())->setIsActive(true); - $category->getResource()->save($categoryTwo); - self::$categoryTwo = $categoryTwo->getId(); - }); + $this->executeWithFlatEnabledInAdminArea( + function () { + $category = $this->getLoadedDefaultCategory(); + + $categoryOne = $this->instantiateCategoryModel(); + $categoryOne->setName('Category One')->setPath($category->getPath())->setIsActive(true); + $category->getResource()->save($categoryOne); + self::$categoryOne = $categoryOne->getId(); + + $categoryTwo = $this->instantiateCategoryModel(); + $categoryTwo->setName('Category Two')->setPath($categoryOne->getPath())->setIsActive(true); + $category->getResource()->save($categoryTwo); + self::$categoryTwo = $categoryTwo->getId(); + } + ); } /** @@ -371,11 +374,13 @@ private function createSubCategoriesInDefaultCategory() */ private function moveSubCategoriesInDefaultCategory() { - $this->executeWithFlatEnabledInAdminArea(function () { - $this->createSubCategoriesInDefaultCategory(); - $categoryTwo = $this->getLoadedCategory(self::$categoryTwo); - $categoryTwo->move(self::$defaultCategoryId, self::$categoryOne); - }); + $this->executeWithFlatEnabledInAdminArea( + function () { + $this->createSubCategoriesInDefaultCategory(); + $categoryTwo = $this->getLoadedCategory(self::$categoryTwo); + $categoryTwo->move(self::$defaultCategoryId, self::$categoryOne); + } + ); } /** @@ -386,10 +391,12 @@ private function moveSubCategoriesInDefaultCategory() */ private function deleteSubCategoriesInDefaultCategory() { - $this->executeWithFlatEnabledInAdminArea(function () { - $this->createSubCategoriesInDefaultCategory(); - $this->removeSubCategoriesInDefaultCategory(); - }); + $this->executeWithFlatEnabledInAdminArea( + function () { + $this->createSubCategoriesInDefaultCategory(); + $this->removeSubCategoriesInDefaultCategory(); + } + ); } /** @@ -398,13 +405,15 @@ private function deleteSubCategoriesInDefaultCategory() */ private function removeSubCategoriesInDefaultCategory() { - $this->executeWithFlatEnabledInAdminArea(function () { - $category = $this->instantiateCategoryModel(); - $category->load(self::$categoryTwo); - $category->delete(); - $category->load(self::$categoryOne); - $category->delete(); - }); + $this->executeWithFlatEnabledInAdminArea( + function () { + $category = $this->instantiateCategoryModel(); + $category->load(self::$categoryTwo); + $category->delete(); + $category->load(self::$categoryOne); + $category->delete(); + } + ); } /** @@ -468,12 +477,4 @@ private function getActiveConfigInstance() \Magento\Framework\App\Config\MutableScopeConfigInterface::class ); } - - /** - * teardown - */ - public function tearDown() - { - parent::tearDown(); - } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Eav/Action/FullTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Eav/Action/FullTest.php index d0a4f2ead4d6b..d1e040a307587 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Eav/Action/FullTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Eav/Action/FullTest.php @@ -32,7 +32,7 @@ public static function setUpBeforeClass() protected function setUp() { - $this->_processor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + $this->_processor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( \Magento\Catalog\Model\Indexer\Product\Eav\Processor::class ); } @@ -46,24 +46,24 @@ protected function setUp() public function testReindexAll() { /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attr **/ - $attr = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class) + $attr = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Eav\Model\Config::class) ->getAttribute('catalog_product', 'weight'); $attr->setIsFilterable(1)->save(); $this->assertTrue($attr->isIndexable()); - $priceIndexerProcessor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + $priceIndexerProcessor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( \Magento\Catalog\Model\Indexer\Product\Price\Processor::class ); $priceIndexerProcessor->reindexAll(); $this->_processor->reindexAll(); - $categoryFactory = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + $categoryFactory = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( \Magento\Catalog\Model\CategoryFactory::class ); /** @var \Magento\Catalog\Block\Product\ListProduct $listProduct */ - $listProduct = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + $listProduct = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( \Magento\Catalog\Block\Product\ListProduct::class ); @@ -82,12 +82,4 @@ public function testReindexAll() $this->assertEquals(1, $product->getWeight()); } } - - /** - * teardown - */ - public function tearDown() - { - parent::tearDown(); - } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Layer/Filter/DataProvider/PriceTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Layer/Filter/DataProvider/PriceTest.php index a6d1aa5be3e37..d2fb64813dd1e 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Layer/Filter/DataProvider/PriceTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Layer/Filter/DataProvider/PriceTest.php @@ -81,6 +81,7 @@ public function getRangeItemCountsDataProvider() /** * @magentoDataFixture Magento/Catalog/_files/categories.php * @magentoDbIsolation disabled + * @magentoConfigFixture default/catalog/search/engine mysql * @dataProvider getRangeItemCountsDataProvider */ public function testGetRangeItemCounts($inputRange, $expectedItemCounts) diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/DateTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/DateTest.php index a6538423f37a1..8463577c34ed9 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/DateTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/DateTest.php @@ -6,13 +6,16 @@ namespace Magento\Catalog\Model\Product\Option\Type; +use Magento\Catalog\Model\Product\Option; +use Magento\Framework\DataObject; + /** - * Test for \Magento\Catalog\Model\Product\Option\Type\Date + * Test for customizable product option with "Date" type */ class DateTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Catalog\Model\Product\Option\Type\Date + * @var Date */ protected $model; @@ -28,12 +31,13 @@ protected function setUp() { $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); $this->model = $this->objectManager->create( - \Magento\Catalog\Model\Product\Option\Type\Date::class + Date::class ); } /** - * @covers \Magento\Catalog\Model\Product\Option\Type\Date::prepareOptionValueForRequest() + * Check if option value for request is the same as expected + * * @dataProvider prepareOptionValueForRequestDataProvider * @param array $optionValue * @param array $infoBuyRequest @@ -54,10 +58,10 @@ public function testPrepareOptionValueForRequest( /** @var \Magento\Quote\Model\Quote\Item $item */ $item = $this->objectManager->create(\Magento\Quote\Model\Quote\Item::class); $item->addOption($option); - /** @var \Magento\Catalog\Model\Product\Option|null $productOption */ + /** @var Option|null $productOption */ $productOption = $productOptionData ? $this->objectManager->create( - \Magento\Catalog\Model\Product\Option::class, + Option::class, ['data' => $productOptionData] ) : null; @@ -69,6 +73,8 @@ public function testPrepareOptionValueForRequest( } /** + * Data provider for testPrepareOptionValueForRequest + * * @return array */ public function prepareOptionValueForRequestDataProvider() @@ -109,4 +115,76 @@ public function prepareOptionValueForRequestDataProvider() ], ]; } + + /** + * Check date in prepareForCart method with javascript calendar and Asia/Singapore timezone + * + * @dataProvider testPrepareForCartDataProvider + * @param array $dateData + * @param array $productOptionData + * @param array $requestData + * @param string $expectedOptionValueForRequest + * @magentoConfigFixture current_store catalog/custom_options/use_calendar 1 + * @magentoConfigFixture current_store general/locale/timezone Asia/Singapore + */ + public function testPrepareForCart( + array $dateData, + array $productOptionData, + array $requestData, + string $expectedOptionValueForRequest + ) { + $this->model->setData($dateData); + /** @var Option|null $productOption */ + $productOption = $productOptionData + ? $this->objectManager->create( + Option::class, + ['data' => $productOptionData] + ) + : null; + $this->model->setOption($productOption); + $request = new DataObject(); + $request->setData($requestData); + $this->model->setRequest($request); + $actualOptionValueForRequest = $this->model->prepareForCart(); + $this->assertSame($expectedOptionValueForRequest, $actualOptionValueForRequest); + } + + /** + * Data provider for testPrepareForCart + * + * @return array + */ + public function testPrepareForCartDataProvider() + { + return [ + [ + // $dateData + [ + 'is_valid' => true, + 'user_value' => [ + 'date' => '09/30/2019', + 'year' => 0, + 'month' => 0, + 'day' => 0, + 'hour' => 0, + 'minute' => 0, + 'day_part' => '', + 'date_internal' => '' + ] + ], + // $productOptionData + ['id' => '11', 'value' => '{"qty":12}', 'type' => 'date'], + // $requestData + [ + 'options' => [ + [ + 'date' => '09/30/2019' + ] + ] + ], + // $expectedOptionValueForRequest + '2019-09-30 00:00:00' + ] + ]; + } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UrlTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UrlTest.php index 4cf059d4bf692..663ee986bca3b 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UrlTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UrlTest.php @@ -49,6 +49,7 @@ public function testGetUrlInStore() * @magentoConfigFixture fixturestore_store web/unsecure/base_url http://sample-second.com/ * @magentoConfigFixture fixturestore_store web/unsecure/base_link_url http://sample-second.com/ * @magentoDataFixture Magento/Catalog/_files/product_simple_multistore.php + * @magentoDbIsolation disabled * @dataProvider getUrlsWithSecondStoreProvider * @magentoAppArea adminhtml */ diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Attribute/Entity/AttributeTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Attribute/Entity/AttributeTest.php new file mode 100644 index 0000000000000..8ecf3da8e1aae --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Attribute/Entity/AttributeTest.php @@ -0,0 +1,144 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\ResourceModel\Attribute\Entity; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Model\AttributeRepository; +use Magento\Eav\Model\ResourceModel\Entity\Attribute as AttributeResource; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Helper\CacheCleaner; + +/** + * Test Eav Resource Entity Attribute functionality + * + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Catalog/_files/dropdown_attribute.php + * @magentoDataFixture Magento/Catalog/_files/multiselect_attribute.php + * @magentoDataFixture Magento/Catalog/_files/product_without_options.php + */ +class AttributeTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var AttributeRepository + */ + protected $attributeRepository; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var AttributeResource + */ + private $model; + + /** + * @inheritdoc + */ + public function setUp() + { + CacheCleaner::cleanAll(); + $this->objectManager = Bootstrap::getObjectManager(); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->attributeRepository = $this->objectManager->get(AttributeRepository::class); + $this->model = $this->objectManager->get(Attribute::class); + } + + /** + * Test to Clear selected option in entities after remove + */ + public function testClearSelectedOptionInEntities() + { + $dropdownAttribute = $this->loadAttribute('dropdown_attribute'); + $dropdownOption = array_keys($dropdownAttribute->getOptions())[1]; + + $multiplyAttribute = $this->loadAttribute('multiselect_attribute'); + $multiplyOptions = array_keys($multiplyAttribute->getOptions()); + $multiplySelectedOptions = implode(',', $multiplyOptions); + $multiplyOptionToRemove = $multiplyOptions[1]; + unset($multiplyOptions[1]); + $multiplyOptionsExpected = implode(',', $multiplyOptions); + + $product = $this->loadProduct('simple'); + $product->setData('dropdown_attribute', $dropdownOption); + $product->setData('multiselect_attribute', $multiplySelectedOptions); + $this->productRepository->save($product); + + $product = $this->loadProduct('simple'); + $this->assertEquals( + $dropdownOption, + $product->getData('dropdown_attribute'), + 'The dropdown attribute is not selected' + ); + $this->assertEquals( + $multiplySelectedOptions, + $product->getData('multiselect_attribute'), + 'The multiselect attribute is not selected' + ); + + $this->removeAttributeOption($dropdownAttribute, $dropdownOption); + $this->removeAttributeOption($multiplyAttribute, $multiplyOptionToRemove); + + $product = $this->loadProduct('simple'); + $this->assertEmpty($product->getData('dropdown_attribute')); + $this->assertEquals($multiplyOptionsExpected, $product->getData('multiselect_attribute')); + } + + /** + * Remove option from attribute + * + * @param Attribute $attribute + * @param int $optionId + */ + private function removeAttributeOption(Attribute $attribute, int $optionId): void + { + $removalMarker = [ + 'option' => [ + 'value' => [$optionId => []], + 'delete' => [$optionId => '1'], + ], + ]; + $attribute->addData($removalMarker); + $attribute->save($attribute); + } + + /** + * Load product by sku + * + * @param string $sku + * @return Product + */ + private function loadProduct(string $sku): Product + { + return $this->productRepository->get($sku, true, null, true); + } + + /** + * Load attrubute by code + * + * @param string $attributeCode + * @return Attribute + */ + private function loadAttribute(string $attributeCode): Attribute + { + /** @var Attribute $attribute */ + $attribute = $this->objectManager->create(Attribute::class); + $attribute->loadByCode(4, $attributeCode); + + return $attribute; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Product/CollectionTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Product/CollectionTest.php index 4cc6265a992fa..de0e881474cf0 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Product/CollectionTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Product/CollectionTest.php @@ -3,10 +3,20 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Model\ResourceModel\Product; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Framework\App\Area; +use Magento\Framework\App\State; +use Magento\Store\Model\Store; +use Magento\TestFramework\Helper\Bootstrap; + /** * Collection test + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class CollectionTest extends \PHPUnit\Framework\TestCase { @@ -31,15 +41,15 @@ class CollectionTest extends \PHPUnit\Framework\TestCase */ protected function setUp() { - $this->collection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->collection = Bootstrap::getObjectManager()->create( \Magento\Catalog\Model\ResourceModel\Product\Collection::class ); - $this->processor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->processor = Bootstrap::getObjectManager()->create( \Magento\Catalog\Model\Indexer\Product\Price\Processor::class ); - $this->productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->productRepository = Bootstrap::getObjectManager()->create( \Magento\Catalog\Api\ProductRepositoryInterface::class ); } @@ -54,7 +64,7 @@ public function testAddPriceDataOnSchedule() $this->processor->getIndexer()->setScheduled(true); $this->assertTrue($this->processor->getIndexer()->isScheduled()); - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + $productRepository = Bootstrap::getObjectManager() ->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); /** @var \Magento\Catalog\Api\Data\ProductInterface $product */ $product = $productRepository->get('simple'); @@ -73,7 +83,7 @@ public function testAddPriceDataOnSchedule() //reindexing $this->processor->getIndexer()->reindexList([1]); - $this->collection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->collection = Bootstrap::getObjectManager()->create( \Magento\Catalog\Model\ResourceModel\Product\Collection::class ); $this->collection->addPriceData(0, 1); @@ -89,6 +99,69 @@ public function testAddPriceDataOnSchedule() $this->processor->getIndexer()->setScheduled(false); } + /** + * @magentoDataFixture Magento/Catalog/_files/products.php + * @magentoAppIsolation enabled + * @magentoDbIsolation disabled + */ + public function testSetVisibility() + { + $appState = Bootstrap::getObjectManager() + ->create(State::class); + $appState->setAreaCode(Area::AREA_CRONTAB); + $this->collection->setStoreId(Store::DEFAULT_STORE_ID); + $this->collection->setVisibility([Visibility::VISIBILITY_BOTH]); + $this->collection->load(); + /** @var \Magento\Catalog\Api\Data\ProductInterface[] $product */ + $items = $this->collection->getItems(); + $this->assertCount(2, $items); + } + + /** + * @magentoDataFixture Magento/Catalog/_files/category_product.php + * @magentoAppIsolation enabled + * @magentoDbIsolation disabled + */ + public function testSetCategoryWithStoreFilter() + { + $appState = Bootstrap::getObjectManager() + ->create(State::class); + $appState->setAreaCode(Area::AREA_CRONTAB); + + $category = \Magento\Framework\App\ObjectManager::getInstance()->get( + \Magento\Catalog\Model\Category::class + )->load(333); + $this->collection->addCategoryFilter($category)->addStoreFilter(1); + $this->collection->load(); + + $collectionStoreFilterAfter = Bootstrap::getObjectManager()->create( + \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory::class + )->create(); + $collectionStoreFilterAfter->addStoreFilter(1)->addCategoryFilter($category); + $collectionStoreFilterAfter->load(); + $this->assertEquals($this->collection->getItems(), $collectionStoreFilterAfter->getItems()); + $this->assertCount(1, $collectionStoreFilterAfter->getItems()); + } + + /** + * @magentoDataFixture Magento/Catalog/_files/categories.php + * @magentoAppIsolation enabled + * @magentoDbIsolation disabled + */ + public function testSetCategoryFilter() + { + $appState = Bootstrap::getObjectManager() + ->create(State::class); + $appState->setAreaCode(Area::AREA_CRONTAB); + + $category = \Magento\Framework\App\ObjectManager::getInstance()->get( + \Magento\Catalog\Model\Category::class + )->load(3); + $this->collection->addCategoryFilter($category); + $this->collection->load(); + $this->assertEquals($this->collection->getSize(), 3); + } + /** * @magentoDataFixture Magento/Catalog/_files/products.php * @magentoAppIsolation enabled @@ -98,7 +171,7 @@ public function testAddPriceDataOnSave() { $this->processor->getIndexer()->setScheduled(false); $this->assertFalse($this->processor->getIndexer()->isScheduled()); - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + $productRepository = Bootstrap::getObjectManager() ->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); /** @var \Magento\Catalog\Api\Data\ProductInterface $product */ $product = $productRepository->get('simple'); @@ -184,7 +257,7 @@ public function testJoinTable() $productTable = $this->collection->getTable('catalog_product_entity'); $urlRewriteTable = $this->collection->getTable('url_rewrite'); - // phpcs:ignore + // phpcs:ignore Magento2.SQL.RawQuery $expected = 'SELECT `e`.*, `alias`.`request_path` FROM `' . $productTable . '` AS `e`' . ' LEFT JOIN `' . $urlRewriteTable . '` AS `alias` ON (alias.entity_id =e.entity_id)' . ' AND (alias.entity_type = \'product\')'; diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/attribute_set_based_on_default_set.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/attribute_set_based_on_default_set.php new file mode 100644 index 0000000000000..929b88367dd78 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/attribute_set_based_on_default_set.php @@ -0,0 +1,26 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/** @var $product \Magento\Catalog\Model\Product */ +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +/** @var \Magento\Eav\Model\Entity\Attribute\Set $attributeSet */ +$attributeSet = $objectManager->create(\Magento\Eav\Model\Entity\Attribute\Set::class); + +$entityType = $objectManager->create(\Magento\Eav\Model\Entity\Type::class)->loadByCode('catalog_product'); +$defaultSetId = $objectManager->create(\Magento\Catalog\Model\Product::class)->getDefaultAttributeSetid(); + +$data = [ + 'attribute_set_name' => 'second_attribute_set', + 'entity_type_id' => $entityType->getId(), + 'sort_order' => 200, +]; + +$attributeSet->setData($data); +$attributeSet->validate(); +$attributeSet->save(); +$attributeSet->initFromSkeleton($defaultSetId); +$attributeSet->save(); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php new file mode 100644 index 0000000000000..27564d486c808 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php @@ -0,0 +1,38 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/../../ConfigurableProduct/_files/configurable_products.php'; + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Eav\Api\AttributeRepositoryInterface; +use Magento\TestFramework\Helper\CacheCleaner; + +$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); + +/** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ +$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); + +$eavConfig->clear(); + +$attribute->setIsSearchable(1) + ->setIsVisibleInAdvancedSearch(1) + ->setIsFilterable(true) + ->setIsFilterableInSearch(true) + ->setIsVisibleOnFront(1); + +/** @var AttributeRepositoryInterface $attributeRepository */ +$attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); +$attributeRepository->save($attribute); +CacheCleaner::cleanAll(); +/** @var \Magento\Indexer\Model\Indexer\Collection $indexerCollection */ +$indexerCollection = Bootstrap::getObjectManager()->get(\Magento\Indexer\Model\Indexer\Collection::class); +$indexerCollection->load(); +/** @var \Magento\Indexer\Model\Indexer $indexer */ +foreach ($indexerCollection->getItems() as $indexer) { + $indexer->reindexAll(); +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation_rollback.php new file mode 100644 index 0000000000000..49e2a8e88a1ac --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/../../ConfigurableProduct/_files/configurable_products_rollback.php'; diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/dropdown_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/dropdown_attribute.php index bb7e241d972e5..7077509d622d9 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/dropdown_attribute.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/dropdown_attribute.php @@ -37,7 +37,6 @@ 'used_for_sort_by' => 0, 'frontend_label' => ['Drop-Down Attribute'], 'backend_type' => 'varchar', - 'backend_model' => \Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend::class, 'option' => [ 'value' => [ 'option_1' => ['Option 1'], diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/dropdown_attribute_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/dropdown_attribute_rollback.php new file mode 100644 index 0000000000000..0ed7317762056 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/dropdown_attribute_rollback.php @@ -0,0 +1,18 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +/* Delete attribute with multiselect_attribute code */ +$registry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get('Magento\Framework\Registry'); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); +/** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ +$attribute = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + 'Magento\Catalog\Model\ResourceModel\Eav\Attribute' +); +$attribute->load('dropdown_attribute', 'attribute_code'); +$attribute->delete(); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_for_relevance_sorting.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_for_relevance_sorting.php new file mode 100644 index 0000000000000..85b3146fc7ec0 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_for_relevance_sorting.php @@ -0,0 +1,112 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +// phpcs:ignore Magento2.Security.IncludeFile +include __DIR__ . '/../../Framework/Search/_files/products.php'; +use Magento\Catalog\Api\ProductRepositoryInterface; + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +$categoryLinkRepository = $objectManager->create( + \Magento\Catalog\Api\CategoryLinkRepositoryInterface::class, + [ + 'productRepository' => $productRepository + ] +); +$categoryLinkManagement = $objectManager->create( + \Magento\Catalog\Api\CategoryLinkManagementInterface::class, + [ + 'productRepository' => $productRepository, + 'categoryLinkRepository' => $categoryLinkRepository + ] +); +$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category->isObjectNew(true); +$category->setId( + 330 +)->setCreatedAt( + '2019-08-27 11:05:07' +)->setName( + 'Colorful Category' +)->setParentId( + 2 +)->setPath( + '1/2/330' +)->setLevel( + 2 +)->setAvailableSortBy( + ['position', 'name'] +)->setDefaultSortBy( + 'name' +)->setIsActive( + true +)->setPosition( + 1 +)->save(); + +$defaultAttributeSet = $objectManager->get(Magento\Eav\Model\Config::class) + ->getEntityType('catalog_product') + ->getDefaultAttributeSetId(); + +/** @var $product \Magento\Catalog\Model\Product */ +$product = $objectManager->create(\Magento\Catalog\Model\Product::class); +$product->isObjectNew(true); +$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) + ->setAttributeSetId($defaultAttributeSet) + ->setStoreId(1) + ->setWebsiteIds([1]) + ->setName('Navy Blue Striped Shoes') + ->setSku('navy-striped-shoes') + ->setPrice(40) + ->setWeight(8) + ->setDescription('blue striped flip flops at <b>one</b>') + ->setMetaTitle('navy blue colored shoes meta title') + ->setMetaKeyword('blue, navy, striped , women, kids') + ->setMetaDescription('blue shoes women kids meta description') + ->setStockData(['use_config_manage_stock' => 0]) + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->save(); + +/** @var $product \Magento\Catalog\Model\Product */ +$product = $objectManager->create(\Magento\Catalog\Model\Product::class); +$product->isObjectNew(true); +$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) + ->setAttributeSetId($defaultAttributeSet) + ->setStoreId(1) + ->setWebsiteIds([1]) + ->setName('light green Shoes') + ->setSku('light-green-shoes') + ->setPrice(40) + ->setWeight(8) + ->setDescription('green polka dots shoes <b>one</b>') + ->setMetaTitle('light green shoes meta title') + ->setMetaKeyword('light, green , women, kids') + ->setMetaDescription('shoes women kids meta description') + ->setStockData(['use_config_manage_stock' => 0]) + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->save(); + +/** @var \Magento\Catalog\Model\Product $greyProduct */ +$greyProduct = $productRepository->get('grey_shorts'); +$greyProduct->setDescription('Description with Blue lines'); +$productRepository->save($greyProduct); + +$skus = ['green_socks', 'white_shorts','red_trousers','blue_briefs','grey_shorts', + 'navy-striped-shoes', 'light-green-shoes']; + +/** @var \Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ +$categoryLinkManagement = $objectManager->create(\Magento\Catalog\Api\CategoryLinkManagementInterface::class); +foreach ($skus as $sku) { + $categoryLinkManagement->assignProductToCategories( + $sku, + [330] + ); +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_for_relevance_sorting_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_for_relevance_sorting_rollback.php new file mode 100644 index 0000000000000..5a1dd30c6b492 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_for_relevance_sorting_rollback.php @@ -0,0 +1,35 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +/** @var \Magento\Framework\Registry $registry */ +$registry = $objectManager->get(\Magento\Framework\Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var $category \Magento\Catalog\Model\Category */ +$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category->load(330); +if ($category->getId()) { + $category->delete(); +} +// Remove products +/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); +$productsToDelete = ['green_socks', 'white_shorts','red_trousers','blue_briefs', + 'grey_shorts', 'navy-striped-shoes','light-green-shoes']; + +foreach ($productsToDelete as $sku) { + try { + $product = $productRepository->get($sku, false, null, true); + $productRepository->delete($product); + } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { + //Product already removed + } +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php index 48c47c9988d59..7bee46bc2078f 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php @@ -36,7 +36,7 @@ 'is_unique' => 0, 'is_required' => 0, 'is_searchable' => 1, - 'is_visible_in_advanced_search' => 0, + 'is_visible_in_advanced_search' => 1, 'is_comparable' => 1, 'is_filterable' => 1, 'is_filterable_in_search' => 1, diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php new file mode 100644 index 0000000000000..72336c48410d5 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php @@ -0,0 +1,152 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/../../Catalog/_files/attribute_set_based_on_default_set.php'; +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/../../Catalog/_files/categories.php'; + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Eav\Api\AttributeRepositoryInterface; + +$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); + +$eavConfig->clear(); + +$attribute1 = $eavConfig->getAttribute('catalog_product', ' second_test_configurable'); +$eavConfig->clear(); + +/** @var $installer \Magento\Catalog\Setup\CategorySetup */ +$installer = Bootstrap::getObjectManager()->create(\Magento\Catalog\Setup\CategorySetup::class); + +if (!$attribute->getId()) { + + /** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ + $attribute = Bootstrap::getObjectManager()->create( + \Magento\Catalog\Model\ResourceModel\Eav\Attribute::class + ); + + /** @var AttributeRepositoryInterface $attributeRepository */ + $attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); + + $attribute->setData( + [ + 'attribute_code' => 'test_configurable', + 'entity_type_id' => $installer->getEntityTypeId('catalog_product'), + 'is_global' => 1, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 1, + 'is_visible_in_advanced_search' => 1, + 'is_comparable' => 1, + 'is_filterable' => 1, + 'is_filterable_in_search' => 1, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 1, + 'used_in_product_listing' => 1, + 'used_for_sort_by' => 1, + 'frontend_label' => ['Test Configurable'], + 'backend_type' => 'int', + 'option' => [ + 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], + 'order' => ['option_0' => 1, 'option_1' => 2], + ], + 'default_value' => 'option_0' + ] + ); + + $attributeRepository->save($attribute); + + /* Assign attribute to attribute set */ + $installer->addAttributeToGroup('catalog_product', 'Default', 'General', $attribute->getId()); +} +// create a second attribute +if (!$attribute1->getId()) { + + /** @var $attribute1 \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ + $attribute1 = Bootstrap::getObjectManager()->create( + \Magento\Catalog\Model\ResourceModel\Eav\Attribute::class + ); + + /** @var AttributeRepositoryInterface $attributeRepository */ + $attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); + + $attribute1->setData( + [ + 'attribute_code' => 'second_test_configurable', + 'entity_type_id' => $installer->getEntityTypeId('catalog_product'), + 'is_global' => 1, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 1, + 'is_visible_in_advanced_search' => 1, + 'is_comparable' => 1, + 'is_filterable' => 1, + 'is_filterable_in_search' => 1, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 1, + 'used_in_product_listing' => 1, + 'used_for_sort_by' => 1, + 'frontend_label' => ['Second Test Configurable'], + 'backend_type' => 'int', + 'option' => [ + 'value' => ['option_0' => ['Option 3'], 'option_1' => ['Option 4']], + 'order' => ['option_0' => 1, 'option_1' => 2], + ], + 'default' => ['option_0'] + ] + ); + + $attributeRepository->save($attribute1); + + /* Assign attribute to attribute set */ + $installer->addAttributeToGroup( + 'catalog_product', + $attributeSet->getId(), + $attributeSet->getDefaultGroupId(), + $attribute1->getId() + ); +} + +$eavConfig->clear(); + +/** @var \Magento\Framework\ObjectManagerInterface $objectManager */ +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +/** @var $productRepository \Magento\Catalog\Api\ProductRepositoryInterface */ +$productRepository = $objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); +$productsWithNewAttributeSet = ['simple', '12345', 'simple-4']; + +foreach ($productsWithNewAttributeSet as $sku) { + try { + $product = $productRepository->get($sku, false, null, true); + $product->setAttributeSetId($attributeSet->getId()); + $product->setStockData( + ['use_config_manage_stock' => 1, + 'qty' => 50, + 'is_qty_decimal' => 0, + 'is_in_stock' => 1] + ); + $productRepository->save($product); + } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { + + } +} +/** @var \Magento\Indexer\Model\Indexer\Collection $indexerCollection */ +$indexerCollection = Bootstrap::getObjectManager()->get(\Magento\Indexer\Model\Indexer\Collection::class); +$indexerCollection->load(); +/** @var \Magento\Indexer\Model\Indexer $indexer */ +foreach ($indexerCollection->getItems() as $indexer) { + $indexer->reindexAll(); +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute_rollback.php new file mode 100644 index 0000000000000..5cababbc988c7 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute_rollback.php @@ -0,0 +1,46 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/../../Eav/_files/empty_attribute_set_rollback.php'; +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/../../Catalog/_files/categories_rollback.php'; + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Eav\Api\AttributeRepositoryInterface; +use Magento\TestFramework\Helper\CacheCleaner; + +$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +$attributesToDelete = ['test_configurable', 'second_test_configurable']; +/** @var AttributeRepositoryInterface $attributeRepository */ +$attributeRepository = Bootstrap::getObjectManager()->get(AttributeRepositoryInterface::class); + +foreach ($attributesToDelete as $attributeCode) { + /** @var \Magento\Eav\Api\Data\AttributeInterface $attribute */ + $attribute = $attributeRepository->get('catalog_product', $attributeCode); + $attributeRepository->delete($attribute); +} +/** @var $product \Magento\Catalog\Model\Product */ +$objectManager = Bootstrap::getObjectManager(); + +$entityType = $objectManager->create(\Magento\Eav\Model\Entity\Type::class)->loadByCode('catalog_product'); + +// remove attribute set + +/** @var \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\Collection $attributeSetCollection */ +$attributeSetCollection = $objectManager->create( + \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\Collection::class +); +$attributeSetCollection->addFilter('attribute_set_name', 'second_attribute_set'); +$attributeSetCollection->addFilter('entity_type_id', $entityType->getId()); +$attributeSetCollection->setOrder('attribute_set_id'); // descending is default value +$attributeSetCollection->setPageSize(1); +$attributeSetCollection->load(); + +/** @var \Magento\Eav\Model\Entity\Attribute\Set $attributeSet */ +$attributeSet = $attributeSetCollection->fetchItem(); +$attributeSet->delete(); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute.php new file mode 100644 index 0000000000000..7d4f22e154030 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute.php @@ -0,0 +1,98 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/** + * Create multiselect attribute + */ +require __DIR__ . '/multiselect_attribute.php'; + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Eav\Api\AttributeRepositoryInterface; + +/** Create product with options and multiselect attribute */ + +/** @var $installer \Magento\Catalog\Setup\CategorySetup */ +$installer = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + \Magento\Catalog\Setup\CategorySetup::class +); + +/** @var $options \Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection */ +$options = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + \Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection::class +); +$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); + +/** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ +$attribute = $eavConfig->getAttribute('catalog_product', 'multiselect_attribute'); + +$eavConfig->clear(); +$attribute->setIsSearchable(1) + ->setIsVisibleInAdvancedSearch(1) + ->setIsFilterable(true) + ->setIsFilterableInSearch(true) + ->setIsVisibleOnFront(1); +/** @var AttributeRepositoryInterface $attributeRepository */ +$attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); +$attributeRepository->save($attribute); + +$options->setAttributeFilter($attribute->getId()); +$optionIds = $options->getAllIds(); + +/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ +$productRepository = Bootstrap::getObjectManager()->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); + +/** @var $product \Magento\Catalog\Model\Product */ +$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); +$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) + ->setId($optionIds[0] * 10) + ->setAttributeSetId($installer->getAttributeSetId('catalog_product', 'Default')) + ->setWebsiteIds([1]) + ->setName('With Multiselect 1 and 2') + ->setSku('simple_ms_1') + ->setPrice(10) + ->setDescription('Hello " &" Bring the water bottle when you can!') + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setMultiselectAttribute([$optionIds[1],$optionIds[2]]) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); +$productRepository->save($product); + +$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); +$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) + ->setId($optionIds[1] * 10) + ->setAttributeSetId($installer->getAttributeSetId('catalog_product', 'Default')) + ->setWebsiteIds([1]) + ->setName('With Multiselect 2 and 3') + ->setSku('simple_ms_2') + ->setPrice(10) + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setMultiselectAttribute([$optionIds[2], $optionIds[3]]) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); +$productRepository->save($product); + +$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); +$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) + ->setId($optionIds[2] * 10) + ->setAttributeSetId($installer->getAttributeSetId('catalog_product', 'Default')) + ->setWebsiteIds([1]) + ->setName('With Multiselect 1 and 3') + ->setSku('simple_ms_2') + ->setPrice(10) + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setMultiselectAttribute([$optionIds[2], $optionIds[3]]) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); + +$productRepository->save($product); + +/** @var \Magento\Indexer\Model\Indexer\Collection $indexerCollection */ +$indexerCollection = Bootstrap::getObjectManager()->get(\Magento\Indexer\Model\Indexer\Collection::class); +$indexerCollection->load(); +/** @var \Magento\Indexer\Model\Indexer $indexer */ +foreach ($indexerCollection->getItems() as $indexer) { + $indexer->reindexAll(); +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute_rollback.php new file mode 100644 index 0000000000000..eb8201f04e6cc --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute_rollback.php @@ -0,0 +1,32 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +require __DIR__ . '/multiselect_attribute_rollback.php'; + +use Magento\Framework\Indexer\IndexerRegistry; + +/** + * Remove all products as strategy of isolation process + */ +$registry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get('Magento\Framework\Registry'); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var $productCollection \Magento\Catalog\Model\ResourceModel\Product */ +$productCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->create('Magento\Catalog\Model\Product') + ->getCollection(); + +foreach ($productCollection as $product) { + $product->delete(); +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); + +\Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(IndexerRegistry::class) + ->get(Magento\CatalogInventory\Model\Indexer\Stock\Processor::INDEXER_ID) + ->reindexAll(); diff --git a/dev/tests/integration/testsuite/Magento/CatalogInventory/Model/ResourceModel/Stock/ItemTest.php b/dev/tests/integration/testsuite/Magento/CatalogInventory/Model/ResourceModel/Stock/ItemTest.php new file mode 100644 index 0000000000000..460f43d816e35 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogInventory/Model/ResourceModel/Stock/ItemTest.php @@ -0,0 +1,376 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogInventory\Model\ResourceModel\Stock; + +use PHPUnit\Framework\TestCase; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\CatalogInventory\Model\Stock; +use Magento\TestFramework\App\Config; +use Magento\Store\Model\ScopeInterface; +use Magento\CatalogInventory\Model\Configuration; + +/** + * Item test. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class ItemTest extends TestCase +{ + /** + * @var \Magento\Framework\ObjectManagerInterface + */ + private $objectManager; + + /** + * @var Item + */ + private $stockItemResourceModel; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var StockItemRepositoryInterface + */ + private $stockItemRepository; + + /** + * @var StockItemCriteriaInterfaceFactory + */ + private $stockItemCriteriaFactory; + + /** + * @var StockConfigurationInterface + */ + private $stockConfiguration; + + /** + * @var Config + */ + private $config; + + /** + * Saved Stock Status Item data. + * + * @var array + */ + private $stockStatusData; + + /** + * Saved system config data. + * + * @var array + */ + private $configData; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = Bootstrap::getObjectManager(); + + $this->stockItemResourceModel = $this->objectManager->get(Item::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->stockItemRepository = $this->objectManager->get(StockItemRepositoryInterface::class); + $this->stockItemCriteriaFactory = $this->objectManager->get(StockItemCriteriaInterfaceFactory::class); + $this->stockConfiguration = $this->objectManager->get(StockConfigurationInterface::class); + $this->config = $this->objectManager->get(Config::class); + + $this->storeSystemConfig(); + } + + /** + * @inheritdoc + */ + protected function tearDown() + { + $this->restoreSystemConfig(); + } + + /** + * Tests updateSetOutOfStock method. + * + * @return void + * + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + */ + public function testUpdateSetOutOfStock(): void + { + $stockItem = $this->getStockItem(1); + $this->saveStockItemData($stockItem); + $this->storeSystemConfig(); + + foreach ($this->stockStatusVariations() as $variation) { + /** + * Check when Stock Item use it's own configuration of backorders. + */ + $this->configureStockItem($stockItem, $variation); + $this->stockItemResourceModel->updateSetOutOfStock($this->stockConfiguration->getDefaultScopeId()); + $stockItem = $this->getStockItem(1); + + self::assertEquals($variation['is_in_stock'], $stockItem->getIsInStock(), $variation['message']); + $stockItem = $this->resetStockItem($stockItem); + + /** + * Check when Stock Item use system configuration of backorders. + */ + $this->configureStockItemWithSystemConfig($stockItem, $variation); + $this->stockItemResourceModel->updateSetOutOfStock($this->stockConfiguration->getDefaultScopeId()); + $stockItem = $this->getStockItem(1); + + self::assertEquals($variation['is_in_stock'], $stockItem->getIsInStock(), $variation['message']); + $stockItem = $this->resetStockItem($stockItem); + $this->restoreSystemConfig(); + } + } + + /** + * Tests updateSetInOfStock method. + * + * @return void + * + * @magentoDataFixture Magento/Catalog/_files/product_simple_out_of_stock.php + */ + public function testUpdateSetInStock(): void + { + $product = $this->productRepository->get('simple-out-of-stock'); + $stockItem = $this->getStockItem((int)$product->getId()); + $this->saveStockItemData($stockItem); + $this->storeSystemConfig(); + + foreach ($this->stockStatusVariations() as $variation) { + /** + * Check when Stock Item use it's own configuration of backorders. + */ + $stockItem->setStockStatusChangedAutomaticallyFlag(true); + $this->configureStockItem($stockItem, $variation); + $this->stockItemResourceModel->updateSetInStock($this->stockConfiguration->getDefaultScopeId()); + $stockItem = $this->getStockItem((int)$product->getId()); + + self::assertEquals($variation['is_in_stock'], $stockItem->getIsInStock(), $variation['message']); + $stockItem = $this->resetStockItem($stockItem); + + /** + * Check when Stock Item use the system configuration of backorders. + */ + $stockItem->setStockStatusChangedAuto(1); + $this->configureStockItemWithSystemConfig($stockItem, $variation); + $this->stockItemResourceModel->updateSetInStock($this->stockConfiguration->getDefaultScopeId()); + $stockItem = $this->getStockItem((int)$product->getId()); + + self::assertEquals($variation['is_in_stock'], $stockItem->getIsInStock(), $variation['message']); + $stockItem = $this->resetStockItem($stockItem); + $this->restoreSystemConfig(); + } + } + + /** + * Configure backorders feature for Stock Item. + * + * @param StockItemInterface $stockItem + * @param array $config + * @return void + */ + private function configureStockItem(StockItemInterface $stockItem, array $config): void + { + /** + * Configuring Stock Item to use it's own configuration. + */ + $stockItem->setUseConfigBackorders(0); + $stockItem->setUseConfigMinQty(0); + $stockItem->setQty($config['qty']); + $stockItem->setMinQty($config['min_qty']); + $stockItem->setBackorders($config['backorders']); + + $this->stockItemRepository->save($stockItem); + } + + /** + * Configure backorders feature using the system configuration for Stock Item. + * + * @param StockItemInterface $stockItem + * @param array $config + * @return void + */ + private function configureStockItemWithSystemConfig(StockItemInterface $stockItem, array $config): void + { + /** + * Configuring Stock Item to use the system configuration. + */ + $stockItem->setUseConfigBackorders(1); + $stockItem->setUseConfigMinQty(1); + + $this->config->setValue( + Configuration::XML_PATH_BACKORDERS, + $config['backorders'], + ScopeInterface::SCOPE_STORE + ); + $this->config->setValue( + Configuration::XML_PATH_MIN_QTY, + $config['min_qty'], + ScopeInterface::SCOPE_STORE + ); + + $stockItem->setQty($config['qty']); + + $this->stockItemRepository->save($stockItem); + } + + /** + * Stock status variations. + * + * @return array + */ + private function stockStatusVariations(): array + { + return [ + // Quantity has not reached Threshold + [ + 'qty' => 3, + 'min_qty' => 2, + 'backorders' => Stock::BACKORDERS_NO, + 'is_in_stock' => true, + 'message' => "Stock status should be In Stock - v.1", + ], + // Quantity has reached Threshold + [ + 'qty' => 3, + 'min_qty' => 3, + 'backorders' => Stock::BACKORDERS_NO, + 'is_in_stock' => false, + 'message' => "Stock status should be Out of Stock - v.2", + ], + // Infinite backorders + [ + 'qty' => -100, + 'min_qty' => 0, + 'backorders' => Stock::BACKORDERS_YES_NOTIFY, + 'is_in_stock' => true, + 'message' => "Stock status should be In Stock for infinite backorders - v.3", + ], + // Quantity has not reached Threshold's negative value + [ + 'qty' => -99, + 'min_qty' => -100, + 'backorders' => Stock::BACKORDERS_YES_NOTIFY, + 'is_in_stock' => true, + 'message' => "Stock status should be In Stock - v.4", + ], + // Quantity has reached Threshold's negative value + [ + 'qty' => -100, + 'min_qty' => -99, + 'backorders' => Stock::BACKORDERS_YES_NOTIFY, + 'is_in_stock' => false, + 'message' => "Stock status should be Out of Stock - v.5", + ], + ]; + } + + /** + * Stores Stock Item values. + * + * @param StockItemInterface $stockItem + * @return void + */ + private function saveStockItemData(StockItemInterface $stockItem): void + { + $this->stockStatusData = $stockItem->getData(); + } + + /** + * Resets Stock Item to previous saved values and prepare for new test variation. + * + * @param StockItemInterface $stockItem + * @return StockItemInterface + */ + private function resetStockItem(StockItemInterface $stockItem): StockItemInterface + { + $stockItem->setData($this->stockStatusData); + + return $this->stockItemRepository->save($stockItem); + } + + /** + * Get Stock Item by product id. + * + * @param int $productId + * @param int|null $scope + * @return StockItemInterface + * @throws NoSuchEntityException + */ + private function getStockItem(int $productId, ?int $scope = null): StockItemInterface + { + $scope = $scope ?? $this->stockConfiguration->getDefaultScopeId(); + $stockItemCriteria = $this->stockItemCriteriaFactory->create(); + $stockItemCriteria->setScopeFilter($scope); + $stockItemCriteria->setProductsFilter([$productId]); + $stockItems = $this->stockItemRepository->getList($stockItemCriteria); + $stockItems = $stockItems->getItems(); + + if (empty($stockItems)) { + throw new NoSuchEntityException(); + } + + $stockItem = reset($stockItems); + + return $stockItem; + } + + /** + * Stores system configuration. + * + * @return void + */ + private function storeSystemConfig(): void + { + /** + * Save system configuration data. + */ + $backorders = $this->config->getValue( + Configuration::XML_PATH_BACKORDERS, + ScopeInterface::SCOPE_STORE + ); + $minQty = $this->config->getValue(Configuration::XML_PATH_MIN_QTY, ScopeInterface::SCOPE_STORE); + $this->configData = [ + 'backorders' => $backorders, + 'min_qty' => $minQty, + ]; + } + + /** + * Restores system configuration. + * + * @return void + */ + private function restoreSystemConfig(): void + { + /** + * Turn back system configuration. + */ + $this->config->setValue( + Configuration::XML_PATH_BACKORDERS, + $this->configData['backorders'], + ScopeInterface::SCOPE_STORE + ); + $this->config->setValue( + Configuration::XML_PATH_MIN_QTY, + $this->configData['min_qty'], + ScopeInterface::SCOPE_STORE + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/CatalogInventory/Model/System/Config/Backend/MinqtyTest.php b/dev/tests/integration/testsuite/Magento/CatalogInventory/Model/System/Config/Backend/MinqtyTest.php new file mode 100644 index 0000000000000..c053d38fea1ff --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogInventory/Model/System/Config/Backend/MinqtyTest.php @@ -0,0 +1,67 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogInventory\Model\System\Config\Backend; + +use PHPUnit\Framework\TestCase; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\CatalogInventory\Model\Stock; + +/** + * Minqty test. + */ +class MinqtyTest extends TestCase +{ + /** + * @var Minqty + */ + private $minQtyConfig; + + /** + * @inheritdoc + */ + protected function setUp() + { + $objectManager = Bootstrap::getObjectManager(); + $this->minQtyConfig = $objectManager->create(Minqty::class); + $this->minQtyConfig->setPath('cataloginventory/item_options/min_qty'); + } + + /** + * Tests beforeSave method. + * + * @param string $value + * @param array $fieldSetData + * @param string $expected + * @return void + * + * @dataProvider minQtyConfigDataProvider + */ + public function testBeforeSave(string $value, array $fieldSetData, string $expected): void + { + $this->minQtyConfig->setData('fieldset_data', $fieldSetData); + $this->minQtyConfig->setValue($value); + $this->minQtyConfig->beforeSave(); + $this->assertEquals($expected, $this->minQtyConfig->getValue()); + } + + /** + * Minqty config data provider. + * + * @return array + */ + public function minQtyConfigDataProvider(): array + { + return [ + 'straight' => ['3', ['backorders' => Stock::BACKORDERS_NO], '3'], + 'straight2' => ['3.5', ['backorders' => Stock::BACKORDERS_NO], '3.5'], + 'negative_value_disabled_backorders' => ['-3', ['backorders' => Stock::BACKORDERS_NO], '0'], + 'negative_value_enabled_backorders' => ['-3', ['backorders' => Stock::BACKORDERS_YES_NOTIFY], '-3'], + 'negative_value_enabled_backorders2' => ['-3.05', ['backorders' => Stock::BACKORDERS_YES_NOTIFY], '-3.05'], + ]; + } +} diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/ResultTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/ResultTest.php index 4f8a279a59165..24ad6af1fea51 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/ResultTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/ResultTest.php @@ -8,6 +8,7 @@ /** * @magentoDbIsolation enabled * @magentoAppIsolation enabled + * @magentoDataFixture Magento/CatalogSearch/_files/full_reindex.php */ class ResultTest extends \Magento\TestFramework\TestCase\AbstractController { @@ -31,6 +32,9 @@ public function testIndexActionTranslation() $this->assertContains('Den gesamten Shop durchsuchen...', $responseBody); } + /** + * @magentoDbIsolation disabled + */ public function testIndexActionXSSQueryVerification() { $escaper = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProviderTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProviderTest.php index 4d2a90f8f44f9..c2c316ecf45c4 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProviderTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProviderTest.php @@ -28,7 +28,8 @@ public function testSearchProductByAttribute() $requestBuilder->setRequestName('quick_search_container'); $queryRequest = $requestBuilder->create(); /** @var \Magento\Framework\Search\Adapter\Mysql\Adapter $adapter */ - $adapter = $objectManager->create(\Magento\Framework\Search\Adapter\Mysql\Adapter::class); + $adapterFactory = $objectManager->create(\Magento\Search\Model\AdapterFactory::class); + $adapter = $adapterFactory->create(); $queryResponse = $adapter->query($queryRequest); $actualIds = []; foreach ($queryResponse as $document) { diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php index 137a3845b1efa..a5c18f0fcee6c 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php @@ -16,38 +16,36 @@ /** * Class for testing fulltext index rebuild + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class FullTest extends \PHPUnit\Framework\TestCase { - /** - * @var \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\Full - */ - protected $actionFull; - - /** - * @inheritdoc - */ - protected function setUp() - { - $this->actionFull = Bootstrap::getObjectManager()->create( - \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\Full::class - ); - } - /** * Testing fulltext index rebuild * * @magentoDataFixture Magento/CatalogSearch/_files/products_for_index.php * @magentoDataFixture Magento/CatalogSearch/_files/product_configurable_not_available.php * @magentoDataFixture Magento/Framework/Search/_files/product_configurable.php + * @magentoConfigFixture default/catalog/search/engine mysql */ public function testGetIndexData() { + $engineProvider = Bootstrap::getObjectManager()->create( + \Magento\CatalogSearch\Model\ResourceModel\EngineProvider::class + ); + $dataProvider = Bootstrap::getObjectManager()->create( + \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\DataProvider::class, + ['engineProvider' => $engineProvider] + ); + $actionFull = Bootstrap::getObjectManager()->create( + \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\Full::class, + ['dataProvider' => $dataProvider] + ); /** @var ProductRepositoryInterface $productRepository */ $productRepository = Bootstrap::getObjectManager()->get(ProductRepositoryInterface::class); $allowedStatuses = Bootstrap::getObjectManager()->get(Status::class)->getVisibleStatusIds(); $allowedVisibility = Bootstrap::getObjectManager()->get(Engine::class)->getAllowedVisibility(); - $result = iterator_to_array($this->actionFull->rebuildStoreIndex(Store::DISTRO_STORE_ID)); + $result = iterator_to_array($actionFull->rebuildStoreIndex(Store::DISTRO_STORE_ID)); $this->assertNotEmpty($result); $productsIds = array_keys($result); @@ -82,37 +80,45 @@ private function getExpectedIndexData() $taxClassId = $attributeRepository ->get(\Magento\Customer\Api\Data\GroupInterface::TAX_CLASS_ID) ->getAttributeId(); + $urlKeyId = $attributeRepository + ->get(\Magento\Catalog\Api\Data\ProductAttributeInterface::CODE_SEO_FIELD_URL_KEY) + ->getAttributeId(); return [ 'configurable' => [ $skuId => 'configurable', $configurableId => 'Option 2', $nameId => 'Configurable Product | Configurable OptionOption 2', $taxClassId => 'Taxable Goods | Taxable Goods', - $statusId => 'Enabled | Enabled' + $statusId => 'Enabled | Enabled', + $urlKeyId => 'configurable-product | configurable-optionoption-2' ], 'index_enabled' => [ $skuId => 'index_enabled', $nameId => 'index enabled', $taxClassId => 'Taxable Goods', - $statusId => 'Enabled' + $statusId => 'Enabled', + $urlKeyId => 'index-enabled' ], 'index_visible_search' => [ $skuId => 'index_visible_search', $nameId => 'index visible search', $taxClassId => 'Taxable Goods', - $statusId => 'Enabled' + $statusId => 'Enabled', + $urlKeyId => 'index-visible-search' ], 'index_visible_category' => [ $skuId => 'index_visible_category', $nameId => 'index visible category', $taxClassId => 'Taxable Goods', - $statusId => 'Enabled' + $statusId => 'Enabled', + $urlKeyId => 'index-visible-category' ], 'index_visible_both' => [ $skuId => 'index_visible_both', $nameId => 'index visible both', $taxClassId => 'Taxable Goods', - $statusId => 'Enabled' + $statusId => 'Enabled', + $urlKeyId => 'index-visible-both' ] ]; } @@ -124,6 +130,9 @@ private function getExpectedIndexData() */ public function testRebuildStoreIndexConfigurable() { + $actionFull = Bootstrap::getObjectManager()->create( + \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\Full::class + ); $storeId = 1; $simpleProductId = $this->getIdBySku('simple_10'); @@ -133,8 +142,8 @@ public function testRebuildStoreIndexConfigurable() $simpleProductId, $configProductId ]; - $storeIndexDataSimple = $this->actionFull->rebuildStoreIndex($storeId, [$simpleProductId]); - $storeIndexDataExpected = $this->actionFull->rebuildStoreIndex($storeId, $expected); + $storeIndexDataSimple = $actionFull->rebuildStoreIndex($storeId, [$simpleProductId]); + $storeIndexDataExpected = $actionFull->rebuildStoreIndex($storeId, $expected); $this->assertEquals($storeIndexDataSimple, $storeIndexDataExpected); } diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/FulltextTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/FulltextTest.php index 7c72d18b97118..b0ae104cae393 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/FulltextTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/FulltextTest.php @@ -9,7 +9,6 @@ use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Visibility; -use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection; use Magento\TestFramework\Helper\Bootstrap; /** @@ -76,10 +75,6 @@ protected function setUp() ); $this->indexer->load('catalogsearch_fulltext'); - $this->engine = Bootstrap::getObjectManager()->get( - \Magento\CatalogSearch\Model\ResourceModel\Engine::class - ); - $this->queryFactory = Bootstrap::getObjectManager()->get( \Magento\Search\Model\QueryFactory::class ); @@ -223,12 +218,8 @@ protected function search(string $text, $visibilityFilter = null): array $query->setQueryText($text); $query->saveIncrementalPopularity(); $products = []; - $collection = Bootstrap::getObjectManager()->create( - Collection::class, - [ - 'searchRequestName' => 'quick_search_container' - ] - ); + $searchLayer = Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Layer\Search::class); + $collection = $searchLayer->getProductCollection(); $collection->addSearchFilter($text); if (null !== $visibilityFilter) { diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/DecimalTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/DecimalTest.php index f0c8402c51879..b75a984178f24 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/DecimalTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/DecimalTest.php @@ -48,6 +48,46 @@ protected function setUp() ->create(\Magento\CatalogSearch\Model\Layer\Filter\Decimal::class, ['layer' => $layer]); $this->_model->setAttributeModel($attribute); } + + /** + * Test the filter label is correct + */ + public function testApplyFilterLabel() + { + /** @var $objectManager \Magento\TestFramework\ObjectManager */ + $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + /** @var $request \Magento\TestFramework\Request */ + $request = $objectManager->get(\Magento\TestFramework\Request::class); + $request->setParam('weight', '10-20'); + $this->_model->apply($request); + + $filters = $this->_model->getLayer()->getState()->getFilters(); + $this->assertArrayHasKey(0, $filters); + $this->assertEquals( + '<span class="price">$10.00</span> - <span class="price">$19.99</span>', + (string)$filters[0]->getLabel() + ); + } + + /** + * Test the filter label is correct when there is empty To value + */ + public function testApplyFilterLabelWithEmptyToValue() + { + /** @var $objectManager \Magento\TestFramework\ObjectManager */ + $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + /** @var $request \Magento\TestFramework\Request */ + $request = $objectManager->get(\Magento\TestFramework\Request::class); + $request->setParam('weight', '10-'); + $this->_model->apply($request); + + $filters = $this->_model->getLayer()->getState()->getFilters(); + $this->assertArrayHasKey(0, $filters); + $this->assertEquals( + '<span class="price">$10.00</span> and above', + (string)$filters[0]->getLabel() + ); + } public function testApplyNothing() { diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/PriceTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/PriceTest.php index 451553113af2c..a7944566eb8e0 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/PriceTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Layer/Filter/PriceTest.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\CatalogSearch\Model\Layer\Filter; use Magento\TestFramework\Helper\Bootstrap; @@ -35,10 +37,16 @@ protected function setUp() $category->load(4); $layer = $this->objectManager->get(\Magento\Catalog\Model\Layer\Category::class); $layer->setCurrentCategory($category); + /** @var $attribute \Magento\Catalog\Model\Entity\Attribute */ + $attribute = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + \Magento\Catalog\Model\Entity\Attribute::class + ); + $attribute->loadByCode('catalog_product', 'price'); $this->_model = $this->objectManager->create( \Magento\CatalogSearch\Model\Layer\Filter\Price::class, ['layer' => $layer] ); + $this->_model->setAttributeModel($attribute); } public function testApplyNothing() diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Advanced/CollectionTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Advanced/CollectionTest.php index 5dcff3f92a9f9..87fda534be6d9 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Advanced/CollectionTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Advanced/CollectionTest.php @@ -18,8 +18,9 @@ class CollectionTest extends \PHPUnit\Framework\TestCase protected function setUp() { - $this->advancedCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\CatalogSearch\Model\ResourceModel\Advanced\Collection::class); + $advanced = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->create(\Magento\CatalogSearch\Model\Search\ItemCollectionProvider::class); + $this->advancedCollection = $advanced->getCollection(); } /** diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Fulltext/CollectionTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Fulltext/CollectionTest.php index 93df194080b69..ae4fbc8d0d98e 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Fulltext/CollectionTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/ResourceModel/Fulltext/CollectionTest.php @@ -14,6 +14,9 @@ class CollectionTest extends \PHPUnit\Framework\TestCase /** * @dataProvider filtersDataProviderSearch * @magentoDataFixture Magento/Framework/Search/_files/products.php + * @magentoDataFixture Magento/CatalogSearch/_files/full_reindex.php + * @magentoConfigFixture default/catalog/search/engine mysql + * @magentoAppIsolation enabled */ public function testLoadWithFilterSearch($request, $filters, $expectedCount) { @@ -31,6 +34,42 @@ public function testLoadWithFilterSearch($request, $filters, $expectedCount) $this->assertCount($expectedCount, $items); } + /** + * @dataProvider filtersDataProviderQuickSearch + * @magentoDataFixture Magento/Framework/Search/_files/products.php + */ + public function testLoadWithFilterQuickSearch($filters, $expectedCount) + { + $objManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $searchLayer = $objManager->create(\Magento\Catalog\Model\Layer\Search::class); + /** @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection $fulltextCollection */ + $fulltextCollection = $searchLayer->getProductCollection(); + foreach ($filters as $field => $value) { + $fulltextCollection->addFieldToFilter($field, $value); + } + $fulltextCollection->loadWithFilter(); + $items = $fulltextCollection->getItems(); + $this->assertCount($expectedCount, $items); + } + + /** + * @dataProvider filtersDataProviderCatalogView + * @magentoDataFixture Magento/Framework/Search/_files/products.php + */ + public function testLoadWithFilterCatalogView($filters, $expectedCount) + { + $objManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $searchLayer = $objManager->create(\Magento\Catalog\Model\Layer\Category::class); + /** @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection $fulltextCollection */ + $fulltextCollection = $searchLayer->getProductCollection(); + foreach ($filters as $field => $value) { + $fulltextCollection->addFieldToFilter($field, $value); + } + $fulltextCollection->loadWithFilter(); + $items = $fulltextCollection->getItems(); + $this->assertCount($expectedCount, $items); + } + /** * @magentoDataFixture Magento/Framework/Search/_files/products_with_the_same_search_score.php */ @@ -42,11 +81,9 @@ public function testSearchResultsAreTheSameForSameRequests() $objManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); foreach (range(1, $howManySearchRequests) as $i) { + $searchLayer = $objManager->create(\Magento\Catalog\Model\Layer\Search::class); /** @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection $fulltextCollection */ - $fulltextCollection = $objManager->create( - \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection::class, - ['searchRequestName' => 'quick_search_container'] - ); + $fulltextCollection = $searchLayer->getProductCollection(); $fulltextCollection->addFieldToFilter('search_term', 'shorts'); $fulltextCollection->setOrder('relevance'); @@ -81,4 +118,22 @@ public function filtersDataProviderSearch() ['catalog_view_container', [], 0], ]; } + + public function filtersDataProviderQuickSearch() + { + return [ + [['search_term' => ' shorts'], 2], + [['search_term' => 'nonexistent'], 0], + ]; + } + + public function filtersDataProviderCatalogView() + { + return [ + [['category_ids' => 2], 5], + [['category_ids' => 100001], 0], + [['category_ids' => []], 5], + [[], 5], + ]; + } } diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/indexer_fulltext.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/indexer_fulltext.php index 2ed7c1a45360d..0e5987f8326a5 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/indexer_fulltext.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/indexer_fulltext.php @@ -14,6 +14,7 @@ ->setWebsiteIds([1]) ->setName('Simple Product Apple') ->setSku('fulltext-1') + ->setUrlKey('fulltext-1') ->setPrice(10) ->setMetaTitle('first meta title') ->setMetaKeyword('first meta keyword') @@ -30,6 +31,7 @@ ->setWebsiteIds([1]) ->setName('Simple Product Banana') ->setSku('fulltext-2') + ->setUrlKey('fulltext-2') ->setPrice(20) ->setMetaTitle('second meta title') ->setMetaKeyword('second meta keyword') @@ -46,6 +48,7 @@ ->setWebsiteIds([1]) ->setName('Simple Product Orange') ->setSku('fulltext-3') + ->setUrlKey('fulltext-3') ->setPrice(20) ->setMetaTitle('third meta title') ->setMetaKeyword('third meta keyword') @@ -62,6 +65,7 @@ ->setWebsiteIds([1]) ->setName('Simple Product Papaya') ->setSku('fulltext-4') + ->setUrlKey('fulltext-4') ->setPrice(20) ->setMetaTitle('fourth meta title') ->setMetaKeyword('fourth meta keyword') @@ -78,6 +82,7 @@ ->setWebsiteIds([1]) ->setName('Simple Product Cherry') ->setSku('fulltext-5') + ->setUrlKey('fulltext-5') ->setPrice(20) ->setMetaTitle('fifth meta title') ->setMetaKeyword('fifth meta keyword') diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute_2.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute_2.php new file mode 100644 index 0000000000000..4de079ba3b6ee --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute_2.php @@ -0,0 +1,62 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Eav\Api\AttributeRepositoryInterface; + +$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +$attribute2 = $eavConfig->getAttribute('catalog_product', 'test_configurable_2'); + +$eavConfig->clear(); + +/** @var $installer \Magento\Catalog\Setup\CategorySetup */ +$installer = Bootstrap::getObjectManager()->create(\Magento\Catalog\Setup\CategorySetup::class); + +if (!$attribute2->getId()) { + + /** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ + $attribute2 = Bootstrap::getObjectManager()->create( + \Magento\Catalog\Model\ResourceModel\Eav\Attribute::class + ); + + /** @var AttributeRepositoryInterface $attributeRepository */ + $attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); + + $attribute2->setData( + [ + 'attribute_code' => 'test_configurable_2', + 'entity_type_id' => $installer->getEntityTypeId('catalog_product'), + 'is_global' => 1, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 0, + 'is_visible_in_advanced_search' => 0, + 'is_comparable' => 0, + 'is_filterable' => 0, + 'is_filterable_in_search' => 0, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 0, + 'used_in_product_listing' => 0, + 'used_for_sort_by' => 0, + 'frontend_label' => ['Test Configurable 2'], + 'backend_type' => 'int', + 'option' => [ + 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], + 'order' => ['option_0' => 1, 'option_1' => 2], + ], + ] + ); + + $attributeRepository->save($attribute2); + + /* Assign attribute to attribute set */ + $installer->addAttributeToGroup('catalog_product', 'Default', 'General', $attribute2->getId()); +} + +$eavConfig->clear(); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute_2_rollback.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute_2_rollback.php new file mode 100644 index 0000000000000..84f6ec58d3e4f --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute_2_rollback.php @@ -0,0 +1,28 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/** @var \Magento\Framework\Registry $registry */ +$registry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); +$productCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->get(\Magento\Catalog\Model\ResourceModel\Product\Collection::class); +foreach ($productCollection as $product) { + $product->delete(); +} + +$eavConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable_2'); +if ($attribute instanceof \Magento\Eav\Model\Entity\Attribute\AbstractAttribute + && $attribute->getId() +) { + $attribute->delete(); +} +$eavConfig->clear(); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_different_super_attribute.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_different_super_attribute.php new file mode 100644 index 0000000000000..1bf816425a9c9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_different_super_attribute.php @@ -0,0 +1,162 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Setup\CategorySetup; +use Magento\ConfigurableProduct\Helper\Product\Options\Factory; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Eav\Api\Data\AttributeOptionInterface; +use Magento\TestFramework\Helper\Bootstrap; + +require __DIR__ . '/configurable_attribute.php'; +require __DIR__ . '/configurable_attribute_2.php'; + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = Bootstrap::getObjectManager() + ->get(ProductRepositoryInterface::class); + +/** @var $installer CategorySetup */ +$installer = Bootstrap::getObjectManager()->create(CategorySetup::class); + +/* Create simple products per each option value*/ +/** @var AttributeOptionInterface[] $options */ +$options = $attribute->getOptions(); + +$attributeValues = []; +$attributeSetId = $installer->getAttributeSetId('catalog_product', 'Default'); +$associatedProductIds = []; +$productIds = [10, 20]; +array_shift($options); //remove the first option which is empty + +foreach ($options as $option) { + /** @var $product Product */ + $product = Bootstrap::getObjectManager()->create(Product::class); + $productId = array_shift($productIds); + $product->setTypeId(Type::TYPE_SIMPLE) + ->setId($productId) + ->setAttributeSetId($attributeSetId) + ->setWebsiteIds([1]) + ->setName('Configurable Option' . $option->getLabel()) + ->setSku('simple_' . $productId) + ->setPrice($productId) + ->setTestConfigurable($option->getValue()) + ->setVisibility(Visibility::VISIBILITY_NOT_VISIBLE) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); + $product = $productRepository->save($product); + + $attributeValues[] = [ + 'label' => 'test', + 'attribute_id' => $attribute->getId(), + 'value_index' => $option->getValue(), + ]; + $associatedProductIds[] = $product->getId(); +} + +/** @var $product Product */ +$product = Bootstrap::getObjectManager()->create(Product::class); +/** @var Factory $optionsFactory */ +$optionsFactory = Bootstrap::getObjectManager()->create(Factory::class); +$configurableAttributesData = [ + [ + 'attribute_id' => $attribute->getId(), + 'code' => $attribute->getAttributeCode(), + 'label' => $attribute->getStoreLabel(), + 'position' => '0', + 'values' => $attributeValues, + ], +]; +$configurableOptions = $optionsFactory->create($configurableAttributesData); +$extensionConfigurableAttributes = $product->getExtensionAttributes(); +$extensionConfigurableAttributes->setConfigurableProductOptions($configurableOptions); +$extensionConfigurableAttributes->setConfigurableProductLinks($associatedProductIds); +$product->setExtensionAttributes($extensionConfigurableAttributes); + +$product->setTypeId(Configurable::TYPE_CODE) + ->setId(1) + ->setAttributeSetId($attributeSetId) + ->setWebsiteIds([1]) + ->setName('Configurable Product') + ->setSku('configurable') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'is_in_stock' => 1]); +$productRepository->cleanCache(); +$productRepository->save($product); + +/* Create simple products per each option value*/ +/** @var AttributeOptionInterface[] $options */ +$options = $attribute2->getOptions(); + +$attributeValues = []; +$attributeSetId = $installer->getAttributeSetId('catalog_product', 'Default'); +$associatedProductIds = []; +$productIds = [30, 40]; +array_shift($options); //remove the first option which is empty + +foreach ($options as $option) { + /** @var $product Product */ + $product = Bootstrap::getObjectManager()->create(Product::class); + $productId = array_shift($productIds); + $product->setTypeId(Type::TYPE_SIMPLE) + ->setId($productId) + ->setAttributeSetId($attributeSetId) + ->setWebsiteIds([1]) + ->setName('Configurable Option' . $option->getLabel()) + ->setSku('simple_' . $productId) + ->setPrice($productId) + ->setTestConfigurable2($option->getValue()) + ->setVisibility(Visibility::VISIBILITY_NOT_VISIBLE) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); + $product = $productRepository->save($product); + + $attributeValues[] = [ + 'label' => 'test', + 'attribute_id' => $attribute2->getId(), + 'value_index' => $option->getValue(), + ]; + $associatedProductIds[] = $product->getId(); +} + +/** @var $product Product */ +$product = Bootstrap::getObjectManager()->create(Product::class); + +/** @var Factory $optionsFactory */ +$optionsFactory = Bootstrap::getObjectManager()->create(Factory::class); + +$configurableAttributesData = [ + [ + 'attribute_id' => $attribute2->getId(), + 'code' => $attribute2->getAttributeCode(), + 'label' => $attribute2->getStoreLabel(), + 'position' => '1', + 'values' => $attributeValues, + ], +]; + +$configurableOptions = $optionsFactory->create($configurableAttributesData); + +$extensionConfigurableAttributes = $product->getExtensionAttributes(); +$extensionConfigurableAttributes->setConfigurableProductOptions($configurableOptions); +$extensionConfigurableAttributes->setConfigurableProductLinks($associatedProductIds); + +$product->setExtensionAttributes($extensionConfigurableAttributes); + +$product->setTypeId(Configurable::TYPE_CODE) + ->setId(11) + ->setAttributeSetId($attributeSetId) + ->setWebsiteIds([1]) + ->setName('Configurable Product 12345') + ->setSku('configurable_12345') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'is_in_stock' => 1]); +$productRepository->cleanCache(); +$productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_different_super_attribute_rollback.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_different_super_attribute_rollback.php new file mode 100644 index 0000000000000..d4fa2a97c4934 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_different_super_attribute_rollback.php @@ -0,0 +1,14 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +require __DIR__ . '/configurable_products_rollback.php'; + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +require __DIR__ . '/configurable_attribute_2_rollback.php'; + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/import_export/customer.php b/dev/tests/integration/testsuite/Magento/Customer/_files/import_export/customer.php index 215dd2a709418..3a39e62af0ccb 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/_files/import_export/customer.php +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/import_export/customer.php @@ -30,7 +30,7 @@ )->setLastname( 'Alston' )->setGender( - 2 + '2' ); $customer->isObjectNew(true); diff --git a/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/CustomerTest.php b/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/CustomerTest.php index 05d9c5d3acb1e..77ceae27e0774 100644 --- a/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/CustomerTest.php +++ b/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/CustomerTest.php @@ -78,7 +78,7 @@ public function testImportData() $expectAddedCustomers = 5; $source = new \Magento\ImportExport\Model\Import\Source\Csv( - __DIR__ . '/_files/customers_to_import.csv', + __DIR__ . '/_files/customers_with_gender_to_import.csv', $this->directoryWrite ); @@ -133,6 +133,11 @@ public function testImportData() $updatedCustomer->getCreatedAt(), 'Creation date must be changed' ); + $this->assertEquals( + $existingCustomer->getGender(), + $updatedCustomer->getGender(), + 'Gender must be changed' + ); } /** diff --git a/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/_files/customers_with_gender_to_import.csv b/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/_files/customers_with_gender_to_import.csv new file mode 100644 index 0000000000000..96c14c67607aa --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/_files/customers_with_gender_to_import.csv @@ -0,0 +1,7 @@ +email,_website,_store,confirmation,created_at,created_in,default_billing,default_shipping,disable_auto_group_change,dob,firstname,gender,group_id,lastname,middlename,password_hash,prefix,rp_token,rp_token_created_at,store_id,suffix,taxvat,website_id,password +AnthonyANealy@magento.com,base,admin,,5/6/2012 15:53,Admin,1,1,0,5/6/2010,Anthony,Female,1,Nealy,A.,6a9c9bfb2ba88a6ad2a64e7402df44a763e0c48cd21d7af9e7e796cd4677ee28:RF,,,,0,,,1, +LoriBBanks@magento.com,admin,admin,,5/6/2012 15:59,Admin,3,3,0,5/6/2010,Lori,Female,1,Banks,B.,7ad6dbdc83d3e9f598825dc58b84678c7351e4281f6bc2b277a32dcd88b9756b:pz,,,,0,,,0, +CharlesTAlston@teleworm.us,base,admin,,5/6/2012 16:13,Admin,4,4,0,,Jhon,Female,1,Doe,T.,145d12bfff8a6a279eb61e277e3d727c0ba95acc1131237f1594ddbb7687a564:l1,,,,0,,,2, +customer@example.com,base,admin,,5/6/2012 16:15,Admin,4,4,0,,Firstname,Female,1,Lastname,T.,145d12bfff8a6a279eb61e277e3d727c0ba95acc1131237f1594ddbb7687a564:l1,,,,0,,,2, +julie.worrell@example.com,base,admin,,5/6/2012 16:19,Admin,4,4,0,,Julie,Female,1,Worrell,T.,145d12bfff8a6a279eb61e277e3d727c0ba95acc1131237f1594ddbb7687a564:l1,,,,0,,,2, +david.lamar@example.com,base,admin,,5/6/2012 16:25,Admin,4,4,0,,David,,1,Lamar,T.,145d12bfff8a6a279eb61e277e3d727c0ba95acc1131237f1594ddbb7687a564:l1,,,,0,,,2, diff --git a/dev/tests/integration/testsuite/Magento/Elasticsearch/Controller/Adminhtml/Category/SaveTest.php b/dev/tests/integration/testsuite/Magento/Elasticsearch/Controller/Adminhtml/Category/SaveTest.php index fcd8226aec50c..787e554a947a1 100644 --- a/dev/tests/integration/testsuite/Magento/Elasticsearch/Controller/Adminhtml/Category/SaveTest.php +++ b/dev/tests/integration/testsuite/Magento/Elasticsearch/Controller/Adminhtml/Category/SaveTest.php @@ -13,7 +13,6 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Indexer\Category\Product as CategoryIndexer; use Magento\CatalogSearch\Model\Indexer\Fulltext as FulltextIndexer; -use Magento\Elasticsearch\Model\Config; use Magento\Framework\Api\SearchCriteriaBuilder; use Magento\Framework\Indexer\IndexerInterface; use Magento\Framework\Indexer\IndexerRegistry; @@ -35,13 +34,6 @@ protected function setUp() { parent::setUp(); - $config = $this->getMockBuilder(Config::class) - ->disableOriginalConstructor() - ->getMock(); - $config->method('isElasticsearchEnabled') - ->willReturn(true); - $this->_objectManager->addSharedInstance($config, Config::class); - $this->changeIndexerSchedule(FulltextIndexer::INDEXER_ID, true); $this->changeIndexerSchedule(CategoryIndexer::INDEXER_ID, true); } @@ -51,7 +43,6 @@ protected function setUp() */ protected function tearDown() { - $this->_objectManager->removeSharedInstance(Config::class); $this->changeIndexerSchedule(FulltextIndexer::INDEXER_ID, $this->indexerSchedule[FulltextIndexer::INDEXER_ID]); $this->changeIndexerSchedule(CategoryIndexer::INDEXER_ID, $this->indexerSchedule[CategoryIndexer::INDEXER_ID]); @@ -161,9 +152,12 @@ private function getProductIdList(array $skuList): array $items = $repository->getList($searchCriteria) ->getItems(); - $idList = array_map(function (ProductInterface $item) { - return $item->getId(); - }, $items); + $idList = array_map( + function (ProductInterface $item) { + return $item->getId(); + }, + $items + ); return $idList; } diff --git a/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php b/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php index 515e1a898dfac..0ed7bd4440be7 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php @@ -5,52 +5,136 @@ */ namespace Magento\Framework\Error; +use Magento\TestFramework\Helper\Bootstrap; + require_once __DIR__ . '/../../../../../../../pub/errors/processor.php'; class ProcessorTest extends \PHPUnit\Framework\TestCase { - /** @var \Magento\Framework\Error\Processor */ + /** + * @var Processor + */ private $processor; - public function setUp() + /** + * @inheritdoc + */ + protected function setUp() { $this->processor = $this->createProcessor(); } - public function tearDown() + /** + * {@inheritdoc} + * @throws \Exception + */ + protected function tearDown() { - if ($this->processor->reportId) { - unlink($this->processor->_reportDir . '/' . $this->processor->reportId); - } + $reportDir = $this->processor->_reportDir; + $this->removeDirRecursively($reportDir); } - public function testSaveAndLoadReport() - { + /** + * @param int $logReportDirNestingLevel + * @param int $logReportDirNestingLevelChanged + * @param string $exceptionMessage + * @dataProvider dataProviderSaveAndLoadReport + */ + public function testSaveAndLoadReport( + int $logReportDirNestingLevel, + int $logReportDirNestingLevelChanged, + string $exceptionMessage + ) { + $_ENV['MAGE_ERROR_REPORT_DIR_NESTING_LEVEL'] = $logReportDirNestingLevel; $reportData = [ - 0 => 'exceptionMessage', + 0 => $exceptionMessage, 1 => 'exceptionTrace', 'script_name' => 'processor.php' ]; + $reportData['report_id'] = hash('sha256', implode('', $reportData)); $expectedReportData = array_merge($reportData, ['url' => '']); - $this->processor = $this->createProcessor(); - $this->processor->saveReport($reportData); - if (!$this->processor->reportId) { + $processor = $this->createProcessor(); + $processor->saveReport($reportData); + $reportId = $processor->reportId; + if (!$reportId) { $this->fail("Failed to generate report id"); } - $this->assertFileExists($this->processor->_reportDir . '/' . $this->processor->reportId); - $this->assertEquals($expectedReportData, $this->processor->reportData); + $this->assertEquals($expectedReportData, $processor->reportData); + $_ENV['MAGE_ERROR_REPORT_DIR_NESTING_LEVEL'] = $logReportDirNestingLevelChanged; + $processor = $this->createProcessor(); + $processor->loadReport($reportId); + $this->assertEquals($expectedReportData, $processor->reportData, "File contents of report don't match"); + } + + /** + * Data Provider for testSaveAndLoadReport + * + * @return array + */ + public function dataProviderSaveAndLoadReport(): array + { + return [ + [ + 'logReportDirNestingLevel' => 0, + 'logReportDirNestingLevelChanged' => 0, + 'exceptionMessage' => '$exceptionMessage 0', + ], + [ + 'logReportDirNestingLevel' => 1, + 'logReportDirNestingLevelChanged' => 1, + 'exceptionMessage' => '$exceptionMessage 1', + ], + [ + 'logReportDirNestingLevel' => 2, + 'logReportDirNestingLevelChanged' => 2, + 'exceptionMessage' => '$exceptionMessage 2', + ], + [ + 'logReportDirNestingLevel' => 3, + 'logReportDirNestingLevelChanged' => 23, + 'exceptionMessage' => '$exceptionMessage 2', + ], + [ + 'logReportDirNestingLevel' => 32, + 'logReportDirNestingLevelChanged' => 32, + 'exceptionMessage' => '$exceptionMessage 3', + ], + [ + 'logReportDirNestingLevel' => 100, + 'logReportDirNestingLevelChanged' => 100, + 'exceptionMessage' => '$exceptionMessage 100', + ], + ]; + } - $loadProcessor = $this->createProcessor(); - $loadProcessor->loadReport($this->processor->reportId); - $this->assertEquals($expectedReportData, $loadProcessor->reportData, "File contents of report don't match"); + /** + * @return Processor + */ + private function createProcessor(): Processor + { + return Bootstrap::getObjectManager()->create(Processor::class); } /** - * @return \Magento\Framework\Error\Processor + * Remove dir recursively + * + * @param string $dir + * @param int $i + * @return bool + * @throws \Exception */ - private function createProcessor() + private function removeDirRecursively(string $dir, int $i = 0): bool { - return \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Error\Processor::class); + if ($i >= 100) { + throw new \Exception('Emergency exit from recursion'); + } + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $i++; + (is_dir("$dir/$file")) + ? $this->removeDirRecursively("$dir/$file", $i) + : unlink("$dir/$file"); + } + return rmdir($dir); } } diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Controller/GraphQlControllerTest.php b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/GraphQlControllerTest.php index 91e1df21fafc8..2fd388d9db3f5 100644 --- a/dev/tests/integration/testsuite/Magento/GraphQl/Controller/GraphQlControllerTest.php +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/GraphQlControllerTest.php @@ -167,7 +167,7 @@ public function testDispatchGetWithParameterizedVariables() : void /** @var ProductInterface $product */ $product = $productRepository->get('simple1'); $query = <<<QUERY -query GetProducts(\$filterInput:ProductFilterInput){ +query GetProducts(\$filterInput:ProductAttributeFilterInput){ products( filter:\$filterInput ){ diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Tax/_files/tax_calculation_price_and_cart_display_settings.php b/dev/tests/integration/testsuite/Magento/GraphQl/Tax/_files/tax_calculation_price_and_cart_display_settings.php new file mode 100644 index 0000000000000..aca41b236c7bf --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Tax/_files/tax_calculation_price_and_cart_display_settings.php @@ -0,0 +1,28 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\App\Config\Storage\Writer; +use Magento\Framework\App\Config\Storage\WriterInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\App\Config\ScopeConfigInterface; + +$objectManager = Bootstrap::getObjectManager(); +/** @var Writer $configWriter */ +$configWriter = $objectManager->get(WriterInterface::class); + +//Apply discount on prices to include tax +$configWriter->save('tax/calculation/discount_tax', '1'); +$configWriter->save('tax/display/type', '3'); +$configWriter->save('tax/display/shipping', '3'); + +$configWriter->save('tax/cart_display/price', '3'); +$configWriter->save('tax/cart_display/subtotal', '3'); +$configWriter->save('tax/cart_display/shipping', '3'); +$configWriter->save('tax/cart_display/grandtotal', '1'); + +$scopeConfig = $objectManager->get(ScopeConfigInterface::class); +$scopeConfig->clean(); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Tax/_files/tax_calculation_price_and_cart_display_settings_rollback.php b/dev/tests/integration/testsuite/Magento/GraphQl/Tax/_files/tax_calculation_price_and_cart_display_settings_rollback.php new file mode 100644 index 0000000000000..7448a71dcbf18 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Tax/_files/tax_calculation_price_and_cart_display_settings_rollback.php @@ -0,0 +1,28 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\App\Config\Storage\Writer; +use Magento\Framework\App\Config\Storage\WriterInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\App\Config\ScopeConfigInterface; + +$objectManager = Bootstrap::getObjectManager(); +/** @var Writer $configWriter */ +$configWriter = $objectManager->get(WriterInterface::class); + +//Apply discount on prices to include tax +$configWriter->save('tax/calculation/discount_tax', '0'); +$configWriter->save('tax/display/type', '1'); +$configWriter->save('tax/display/shipping', '1'); + +$configWriter->save('tax/cart_display/price', '1'); +$configWriter->save('tax/cart_display/subtotal', '1'); +$configWriter->save('tax/cart_display/shipping', '1'); +$configWriter->save('tax/cart_display/grandtotal', '0'); + +$scopeConfig = $objectManager->get(ScopeConfigInterface::class); +$scopeConfig->clean(); diff --git a/dev/tests/integration/testsuite/Magento/ProductAlert/Controller/Add/StockTest.php b/dev/tests/integration/testsuite/Magento/ProductAlert/Controller/Add/StockTest.php new file mode 100644 index 0000000000000..2e4da9992642f --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ProductAlert/Controller/Add/StockTest.php @@ -0,0 +1,111 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ProductAlert\Controller\Add; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\App\Action\Action; +use Magento\Framework\Url\Helper\Data; +use Magento\Customer\Model\Session; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\AbstractController; + +/** + * Test for Magento\ProductAlert\Controller\Add\Stock class. + * + * @magentoAppIsolation enabled + * @magentoDbIsolation enabled + */ +class StockTest extends AbstractController +{ + /** + * @var Session + */ + private $customerSession; + + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var Data + */ + private $dataUrlHelper; + + /** + * @var ResourceConnection + */ + protected $resource; + + /** + * Connection adapter + * + * @var \Magento\Framework\DB\Adapter\AdapterInterface + */ + protected $connectionMock; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + protected function setUp() + { + parent::setUp(); + $this->objectManager = Bootstrap::getObjectManager(); + + $this->customerSession = $this->objectManager->get(Session::class); + $this->dataUrlHelper = $this->objectManager->get(Data::class); + + $this->resource = $this->objectManager->get(ResourceConnection::class); + $this->connectionMock = $this->resource->getConnection(); + $this->productRepository = $this->objectManager->create(ProductRepositoryInterface::class); + } + + /** + * @magentoAppArea frontend + * @magentoDataFixture Magento/Catalog/_files/product_simple_out_of_stock.php + * @magentoDataFixture Magento/Customer/_files/customer.php + */ + public function testSubscribeStockNotification() + { + $productId = $this->productRepository->get('simple-out-of-stock')->getId(); + $customerId = 1; + + $this->customerSession->setCustomerId($customerId); + + $encodedParameterValue = $this->getUrlEncodedParameter($productId); + $this->getRequest()->setMethod('GET'); + $this->getRequest()->setQueryValue('product_id', $productId); + $this->getRequest()->setQueryValue(Action::PARAM_NAME_URL_ENCODED, $encodedParameterValue); + $this->dispatch('productalert/add/stock'); + + $select = $this->connectionMock->select()->from($this->resource->getTableName('product_alert_stock')) + ->where('`product_id` LIKE ?', $productId); + $result = $this->connectionMock->fetchAll($select); + $this->assertCount(1, $result); + } + + /** + * @param $productId + * + * @return string + */ + private function getUrlEncodedParameter($productId):string + { + $baseUrl = $this->objectManager->get(StoreManagerInterface::class)->getStore()->getBaseUrl(); + $encodedParameterValue = urlencode( + $this->dataUrlHelper->getEncodedUrl($baseUrl . 'productalert/add/stock/product_id/' . $productId) + ); + + return $encodedParameterValue; + } +} diff --git a/dev/tests/integration/testsuite/Magento/ProductAlert/Controller/Unsubscribe/StockTest.php b/dev/tests/integration/testsuite/Magento/ProductAlert/Controller/Unsubscribe/StockTest.php new file mode 100644 index 0000000000000..1d56edab9a8a5 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ProductAlert/Controller/Unsubscribe/StockTest.php @@ -0,0 +1,84 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ProductAlert\Controller\Unsubscribe; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\ObjectManagerInterface; +use Magento\Customer\Model\Session; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\AbstractController; + +/** + * Test for Magento\ProductAlert\Controller\Unsubscribe\Stock class. + * + * @magentoAppIsolation enabled + * @magentoDbIsolation enabled + */ +class StockTest extends AbstractController +{ + /** + * @var Session + */ + private $customerSession; + + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var ResourceConnection + */ + protected $resource; + + /** + * Connection adapter + * + * @var AdapterInterface + */ + protected $connectionMock; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + protected function setUp() + { + parent::setUp(); + $this->objectManager = Bootstrap::getObjectManager(); + $this->customerSession = $this->objectManager->get(Session::class); + $this->resource = $this->objectManager->get(ResourceConnection::class); + $this->connectionMock = $this->resource->getConnection(); + $this->productRepository = $this->objectManager->create(ProductRepositoryInterface::class); + } + + /** + * @magentoAppArea frontend + * @magentoDataFixture Magento/Catalog/_files/product_simple_out_of_stock.php + * @magentoDataFixture Magento/Customer/_files/customer.php + * @magentoDataFixture Magento/ProductAlert/_files/customer_unsubscribe_stock.php + */ + public function testUnsubscribeStockNotification() + { + $customerId = 1; + $productId = $this->productRepository->get('simple-out-of-stock')->getId(); + + $this->customerSession->setCustomerId($customerId); + + $this->getRequest()->setPostValue('product', $productId)->setMethod('POST'); + $this->dispatch('productalert/unsubscribe/stock'); + + $select = $this->connectionMock->select()->from($this->resource->getTableName('product_alert_stock')) + ->where('`product_id` LIKE ?', $productId); + $result = $this->connectionMock->fetchAll($select); + $this->assertCount(0, $result); + } +} diff --git a/dev/tests/integration/testsuite/Magento/ProductAlert/_files/customer_unsubscribe_stock.php b/dev/tests/integration/testsuite/Magento/ProductAlert/_files/customer_unsubscribe_stock.php new file mode 100644 index 0000000000000..3308b2b1829db --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ProductAlert/_files/customer_unsubscribe_stock.php @@ -0,0 +1,33 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Stdlib\DateTime\DateTimeFactory; +use Magento\ProductAlert\Model\ResourceModel\Stock; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); +$resource = $objectManager->get(Stock::class); + +/** @var \Magento\Framework\Stdlib\DateTime\DateTime $dateTime */ +$dateTime = $objectManager->get(DateTimeFactory::class)->create(); +$date = $dateTime->gmtDate(null, ($dateTime->gmtTimestamp() - 3600)); +$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$productId = $productRepository->get('simple-out-of-stock')->getId(); + +$resource->getConnection()->insert( + $resource->getMainTable(), + [ + 'customer_id' => 1, + 'product_id' => $productId, + 'website_id' => 1, + 'store_id' => 1, + 'add_date' => $date, + 'send_date' => null, + 'send_count' => 0, + 'status' => 0 + ] +); diff --git a/dev/tests/integration/testsuite/Magento/ProductAlert/_files/customer_unsubscribe_stock_rollback.php b/dev/tests/integration/testsuite/Magento/ProductAlert/_files/customer_unsubscribe_stock_rollback.php new file mode 100644 index 0000000000000..c01c09df26d10 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ProductAlert/_files/customer_unsubscribe_stock_rollback.php @@ -0,0 +1,20 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\ProductAlert\Model\ResourceModel\Stock; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); +$resource = $objectManager->get(Stock::class); + +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +$productId = $productRepository->get('simple-out-of-stock')->getId(); + +$resource->getConnection()->delete( + $resource->getMainTable(), + ['product_id = ?' => $productId] +); diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/Controller/Adminhtml/Promo/Quote/NewActionHtmlTest.php b/dev/tests/integration/testsuite/Magento/SalesRule/Controller/Adminhtml/Promo/Quote/NewActionHtmlTest.php new file mode 100644 index 0000000000000..82f1c53d8f161 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/Controller/Adminhtml/Promo/Quote/NewActionHtmlTest.php @@ -0,0 +1,81 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesRule\Controller\Adminhtml\Promo\Quote; + +use Magento\TestFramework\TestCase\AbstractBackendController; + +/** + * New action html test + * + * @magentoAppArea adminhtml + */ +class NewActionHtmlTest extends AbstractBackendController +{ + /** + * @var string + */ + protected $resource = 'Magento_SalesRule::quote'; + + /** + * @var string + */ + protected $uri = 'backend/sales_rule/promo_quote/newActionHtml'; + + /** + * @var string + */ + private $formName = 'test_form'; + + /** + * Test verifies that execute method has the proper data-form-part value in html response + * + * @return void + */ + public function testExecute(): void + { + $this->prepareRequest(); + $this->dispatch($this->uri); + $html = $this->getResponse() + ->getBody(); + $this->assertContains($this->formName, $html); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(); + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(); + parent::testAclNoAccess(); + } + + /** + * Prepare request + * + * @return void + */ + private function prepareRequest(): void + { + $this->getRequest()->setParams( + [ + 'id' => 1, + 'form_namespace' => $this->formName, + 'type' => 'Magento\SalesRule\Model\Rule\Condition\Product|quote_item_price', + ] + )->setMethod('POST'); + } +} diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/_files/buy_3_get_1_free.php b/dev/tests/integration/testsuite/Magento/SalesRule/_files/buy_3_get_1_free.php new file mode 100644 index 0000000000000..2d5035523e161 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/_files/buy_3_get_1_free.php @@ -0,0 +1,34 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Customer\Model\GroupManagement; +use Magento\SalesRule\Model\Rule; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); + +/** @var Rule $salesRule */ +$salesRule = $objectManager->create(Rule::class); +$salesRule->setData( + [ + 'name' => 'Buy 3 And Get 1 Free', + 'is_active' => 1, + 'customer_group_ids' => [GroupManagement::NOT_LOGGED_IN_ID], + 'coupon_type' => Rule::COUPON_TYPE_NO_COUPON, + 'conditions' => [], + 'simple_action' => Rule::BUY_X_GET_Y_ACTION, + 'discount_amount' => 1, + 'discount_step' => 3, + 'stop_rules_processing' => 0, + 'store_labels' => [0 => ' Get 1 item free for every 3 you buy'], + 'website_ids' => [ + $objectManager->get(StoreManagerInterface::class)->getWebsite()->getId(), + ], + ] +); +$objectManager->get(\Magento\SalesRule\Model\ResourceModel\Rule::class)->save($salesRule); diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/_files/buy_3_get_1_free_rollback.php b/dev/tests/integration/testsuite/Magento/SalesRule/_files/buy_3_get_1_free_rollback.php new file mode 100644 index 0000000000000..f6866a8066ee3 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/_files/buy_3_get_1_free_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/rules_rollback.php'; diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/_files/cart_rule_10_percent_off_qty_more_than_2_items.php b/dev/tests/integration/testsuite/Magento/SalesRule/_files/cart_rule_10_percent_off_qty_more_than_2_items.php new file mode 100644 index 0000000000000..f8a0d65b00a10 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/_files/cart_rule_10_percent_off_qty_more_than_2_items.php @@ -0,0 +1,73 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Customer\Model\GroupManagement; +use Magento\SalesRule\Model\Rule; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); +$websiteId = Bootstrap::getObjectManager()->get(StoreManagerInterface::class) + ->getWebsite() + ->getId(); + +/** @var Rule $salesRule */ +$salesRule = $objectManager->create(Rule::class); +$salesRule->setData( + [ + 'name' => '10% Off on orders with two items', + 'is_active' => 1, + 'customer_group_ids' => [GroupManagement::NOT_LOGGED_IN_ID], + 'coupon_type' => Rule::COUPON_TYPE_NO_COUPON, + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'discount_step' => 0, + 'stop_rules_processing' => 1, + 'is_advanced' => 1, + 'website_ids' => [$websiteId], + 'store_labels' => [ + + 'store_id' => 0, + 'store_label' => '10% off with two items_Label', + + ] + ] +); + +$salesRule->getConditions()->loadArray( + [ + 'type' => \Magento\SalesRule\Model\Rule\Condition\Combine::class, + 'attribute' => null, + 'operator' => null, + 'value' => '1', + 'is_value_processed' => null, + 'aggregator' => 'all', + 'conditions' => + [ + [ + 'type' => \Magento\SalesRule\Model\Rule\Condition\Product\Found::class, + 'attribute' => null, + 'operator' => null, + 'value' => '1', + 'is_value_processed' => null, + 'aggregator' => 'all', + 'conditions' => + [ + [ + 'type' => \Magento\SalesRule\Model\Rule\Condition\Product::class, + 'attribute' => 'quote_item_qty', + 'operator' => '>=', + 'value' => '2', + 'is_value_processed' => false, + ], + ], + ], + ], + ] +); + +$salesRule->save(); diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/_files/cart_rule_10_percent_off_qty_more_than_2_items_rollback.php b/dev/tests/integration/testsuite/Magento/SalesRule/_files/cart_rule_10_percent_off_qty_more_than_2_items_rollback.php new file mode 100644 index 0000000000000..f6866a8066ee3 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/_files/cart_rule_10_percent_off_qty_more_than_2_items_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +// phpcs:ignore Magento2.Security.IncludeFile +require __DIR__ . '/rules_rollback.php'; diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_categories_rollback.php b/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_categories_rollback.php index 968193b26fe17..0f7de0efe41f6 100644 --- a/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_categories_rollback.php +++ b/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_categories_rollback.php @@ -3,11 +3,35 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Catalog\Api\CategoryListInterface; +use Magento\Catalog\Api\CategoryRepositoryInterface; + +$objectManager = Bootstrap::getObjectManager(); /** @var Magento\Framework\Registry $registry */ -$registry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); +$registry = $objectManager->get(\Magento\Framework\Registry::class); /** @var Magento\SalesRule\Model\Rule $rule */ $rule = $registry->registry('_fixture/Magento_SalesRule_Multiple_Categories'); $rule->delete(); + +// logic to delete the category that was created as part of the rules_category fixture +/** @var SearchCriteriaBuilder $searchCriteriaBuilder */ +$searchCriteriaBuilder = $objectManager->get(SearchCriteriaBuilder::class); +$searchCriteria = $searchCriteriaBuilder->addFilter('name', 'Category 1') + ->create(); + +/** @var CategoryListInterface $categoryList */ +$categoryList = $objectManager->get(CategoryListInterface::class); +$categories = $categoryList->getList($searchCriteria) + ->getItems(); + +/** @var CategoryRepositoryInterface $categoryRepository */ +$categoryRepository = $objectManager->get(CategoryRepositoryInterface::class); + +foreach ($categories as $category) { + $categoryRepository->delete($category); +} diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category.php b/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category.php index 939d6d9e28200..0a83a1f65d875 100644 --- a/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category.php +++ b/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category.php @@ -15,16 +15,23 @@ 'simple_action' => 'by_percent', 'discount_amount' => 50, 'discount_step' => 0, - 'stop_rules_processing' => 1, + 'stop_rules_processing' => 0, 'website_ids' => [ \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( \Magento\Store\Model\StoreManagerInterface::class )->getWebsite()->getId() + ], + 'store_labels' => [ + + 'store_id' => 0, + 'store_label' => 'TestRule_Label', + ] ] ); -$salesRule->getConditions()->loadArray([ +$salesRule->getConditions()->loadArray( + [ 'type' => \Magento\SalesRule\Model\Rule\Condition\Combine::class, 'attribute' => null, 'operator' => null, @@ -52,7 +59,8 @@ ], ], ], -]); + ] +); $salesRule->save(); @@ -67,7 +75,7 @@ )->setParentId( 2 )->setPath( - '1/2/333' + '1/2/66' )->setLevel( 2 )->setAvailableSortBy( diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category_rollback.php b/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category_rollback.php index 7ad18fc73c687..1ecd20a1f518f 100644 --- a/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category_rollback.php +++ b/dev/tests/integration/testsuite/Magento/SalesRule/_files/rules_category_rollback.php @@ -11,3 +11,16 @@ $rule = $registry->registry('_fixture/Magento_SalesRule_Category'); $rule->delete(); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var $category \Magento\Catalog\Model\Category */ +$category = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Category::class); +$category->load(66); +if ($category->getId()) { + $category->delete(); +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Setup/Model/ObjectManagerProviderTest.php b/dev/tests/integration/testsuite/Magento/Setup/Model/ObjectManagerProviderTest.php index c4fc0fa7c5854..a80da16be67eb 100644 --- a/dev/tests/integration/testsuite/Magento/Setup/Model/ObjectManagerProviderTest.php +++ b/dev/tests/integration/testsuite/Magento/Setup/Model/ObjectManagerProviderTest.php @@ -6,9 +6,17 @@ namespace Magento\Setup\Model; +use Magento\Framework\ObjectManagerInterface; use Magento\Setup\Mvc\Bootstrap\InitParamListener; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject; +use Symfony\Component\Console\Application; +use Zend\ServiceManager\ServiceLocatorInterface; -class ObjectManagerProviderTest extends \PHPUnit\Framework\TestCase +/** + * Tests ObjectManagerProvider + */ +class ObjectManagerProviderTest extends TestCase { /** * @var ObjectManagerProvider @@ -16,21 +24,34 @@ class ObjectManagerProviderTest extends \PHPUnit\Framework\TestCase private $object; /** - * @var \Zend\ServiceManager\ServiceLocatorInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ServiceLocatorInterface|PHPUnit_Framework_MockObject_MockObject */ private $locator; + /** + * @inheritDoc + */ protected function setUp() { - $this->locator = $this->getMockForAbstractClass(\Zend\ServiceManager\ServiceLocatorInterface::class); + $this->locator = $this->getMockForAbstractClass(ServiceLocatorInterface::class); $this->object = new ObjectManagerProvider($this->locator, new Bootstrap()); + $this->locator->expects($this->any()) + ->method('get') + ->willReturnMap( + [ + [InitParamListener::BOOTSTRAP_PARAM, []], + [Application::class, $this->getMockForAbstractClass(Application::class)], + ] + ); } + /** + * Tests the same instance of ObjectManagerInterface should be provided by the ObjectManagerProvider + */ public function testGet() { - $this->locator->expects($this->once())->method('get')->with(InitParamListener::BOOTSTRAP_PARAM)->willReturn([]); $objectManager = $this->object->get(); - $this->assertInstanceOf(\Magento\Framework\ObjectManagerInterface::class, $objectManager); + $this->assertInstanceOf(ObjectManagerInterface::class, $objectManager); $this->assertSame($objectManager, $this->object->get()); } } diff --git a/dev/tests/integration/testsuite/Magento/UrlRewrite/Controller/UrlRewriteTest.php b/dev/tests/integration/testsuite/Magento/UrlRewrite/Controller/UrlRewriteTest.php index 74d6edaef847b..795f29332876a 100644 --- a/dev/tests/integration/testsuite/Magento/UrlRewrite/Controller/UrlRewriteTest.php +++ b/dev/tests/integration/testsuite/Magento/UrlRewrite/Controller/UrlRewriteTest.php @@ -15,6 +15,7 @@ class UrlRewriteTest extends AbstractController { /** * @magentoDataFixture Magento/UrlRewrite/_files/url_rewrite.php + * @magentoDbIsolation disabled * * @covers \Magento\UrlRewrite\Controller\Router::match * @covers \Magento\UrlRewrite\Model\Storage\DbStorage::doFindOneByData diff --git a/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php b/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php index 8ea9fdcd744f1..d7da1389ac847 100644 --- a/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php +++ b/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php @@ -62,6 +62,8 @@ protected function setUp() * * @magentoDataFixture Magento/UrlRewrite/_files/url_rewrite.php * @magentoDataFixture Magento/Catalog/_files/category_product.php + * @magentoDbIsolation disabled + * @magentoAppIsolation enabled * @return void * @throws StoreSwitcher\CannotSwitchStoreException * @throws \Magento\Framework\Exception\NoSuchEntityException @@ -71,7 +73,7 @@ public function testSwitchToNonExistingPage(): void $fromStore = $this->getStoreByCode('default'); $toStore = $this->getStoreByCode('fixture_second_store'); - $this->setBaseUrl($toStore); + $this->setBaseUrl($toStore, 'http://domain.com/'); $product = $this->productRepository->get('simple333'); @@ -79,12 +81,14 @@ public function testSwitchToNonExistingPage(): void $expectedUrl = $toStore->getBaseUrl(); $this->assertEquals($expectedUrl, $this->storeSwitcher->switch($fromStore, $toStore, $redirectUrl)); + $this->setBaseUrl($toStore, 'http://localhost/'); } /** * Testing store switching with existing cms pages * * @magentoDataFixture Magento/UrlRewrite/_files/url_rewrite.php + * @magentoDbIsolation disabled * @return void * @throws StoreSwitcher\CannotSwitchStoreException * @throws \Magento\Framework\Exception\NoSuchEntityException @@ -120,13 +124,13 @@ public function testSwitchCmsPageToAnotherStore(): void * Set base url to store. * * @param StoreInterface $targetStore + * @param string $baseUrl * @return void */ - private function setBaseUrl(StoreInterface $targetStore): void + private function setBaseUrl(StoreInterface $targetStore, string $baseUrl): void { $configValue = $this->objectManager->create(Value::class); $configValue->load('web/unsecure/base_url', 'path'); - $baseUrl = 'http://domain.com/'; if (!$configValue->getPath()) { $configValue->setPath('web/unsecure/base_url'); } diff --git a/dev/tests/integration/testsuite/Magento/UrlRewrite/_files/url_rewrite_rollback.php b/dev/tests/integration/testsuite/Magento/UrlRewrite/_files/url_rewrite_rollback.php index 8fec06284a78c..22d95751fbf26 100644 --- a/dev/tests/integration/testsuite/Magento/UrlRewrite/_files/url_rewrite_rollback.php +++ b/dev/tests/integration/testsuite/Magento/UrlRewrite/_files/url_rewrite_rollback.php @@ -11,6 +11,15 @@ $registry->unregister('isSecureArea'); $registry->register('isSecureArea', true); +/** @var Magento\Cms\Api\PageRepositoryInterface $pageRepository */ +$pageRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + Magento\Cms\Api\PageRepositoryInterface::class +); + +$pageRepository->deleteById('page-a'); +$pageRepository->deleteById('page-b'); +$pageRepository->deleteById('page-c'); + /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() ->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); @@ -20,7 +29,7 @@ ->create(\Magento\UrlRewrite\Model\ResourceModel\UrlRewriteCollection::class); $collection = $urlRewriteCollection ->addFieldToFilter('entity_type', 'custom') - ->addFieldToFilter('request_path', ['page-a', 'page-b', 'page-c']) + ->addFieldToFilter('target_path', ['page-a/', 'page-a', 'page-b', 'page-c']) ->load() ->walk('delete'); diff --git a/dev/tests/integration/testsuite/Magento/Wishlist/Model/ResourceModel/Item/Collection/GridTest.php b/dev/tests/integration/testsuite/Magento/Wishlist/Model/ResourceModel/Item/Collection/GridTest.php new file mode 100644 index 0000000000000..f7d7199134013 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Wishlist/Model/ResourceModel/Item/Collection/GridTest.php @@ -0,0 +1,76 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Wishlist\Model\ResourceModel\Item\Collection; + +use Magento\Customer\Controller\RegistryConstants; +use Magento\Customer\Model\Customer; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Registry; +use Magento\Store\Model\Website; +use PHPUnit\Framework\TestCase; + +/** + * Class to test wishlist collection by customer functionality + * + * @magentoAppArea adminhtml + */ +class GridTest extends TestCase +{ + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @var Registry + */ + private $registryManager; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = ObjectManager::getInstance(); + $this->registryManager = $this->objectManager->get(Registry::class); + } + + /** + * Test to load wishlist collection by customer on second website + * + * @magentoDbIsolation disabled + * @magentoDataFixture Magento/Wishlist/_files/wishlist_on_second_website.php + */ + public function testLoadOnSecondWebsite() + { + $customer = $this->loadCustomer(); + $this->registryManager->register(RegistryConstants::CURRENT_CUSTOMER_ID, $customer->getId()); + + $gridCollection = $this->objectManager->get(Grid::class); + $this->assertNotEmpty($gridCollection->getItems()); + } + + /** + * Load customer in second website + * + * @return Customer + */ + private function loadCustomer(): Customer + { + /** @var $website Website */ + $website = $this->objectManager->get(Website::class); + $website->load('newwebsite', 'code'); + + /** @var Customer $customer */ + $customer = $this->objectManager->get(Customer::class); + $customer->setWebsiteId($website->getId()); + $customer->loadByEmail('customer2@example.com'); + + return $customer; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Wishlist/_files/wishlist_on_second_website.php b/dev/tests/integration/testsuite/Magento/Wishlist/_files/wishlist_on_second_website.php new file mode 100644 index 0000000000000..6d8051cf060f6 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Wishlist/_files/wishlist_on_second_website.php @@ -0,0 +1,27 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require __DIR__ . '/../../../Magento/Catalog/_files/products_with_websites_and_stores.php'; +require __DIR__ . '/../../../Magento/Customer/_files/customer_non_default_website_id.php'; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Wishlist\Model\Wishlist; + +$objectManager = Bootstrap::getObjectManager(); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +$simpleProduct = $productRepository->get('simple-2'); + +/* @var $wishlist Wishlist */ +$wishlist = Bootstrap::getObjectManager()->create(Wishlist::class); +$wishlist->loadByCustomerId($customer->getId(), true); +$wishlist->addNewItem($simpleProduct); +$wishlist->setSharingCode('fixture_unique_code') + ->setShared(1) + ->save(); diff --git a/dev/tests/integration/testsuite/Magento/Wishlist/_files/wishlist_on_second_website_rollback.php b/dev/tests/integration/testsuite/Magento/Wishlist/_files/wishlist_on_second_website_rollback.php new file mode 100644 index 0000000000000..49b3e120f7354 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Wishlist/_files/wishlist_on_second_website_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require __DIR__ . '/../../../Magento/Catalog/_files/products_with_websites_and_stores_rollback.php'; +require __DIR__ . '/../../../Magento/Customer/_files/customer_non_default_website_id_rollback.php'; diff --git a/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/AllPurposeAction.php b/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/AllPurposeAction.php index ca257c1f6eb39..00a6e49d4e31d 100644 --- a/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/AllPurposeAction.php +++ b/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/AllPurposeAction.php @@ -38,7 +38,7 @@ public function apply(AbstractNode $node) return; } - if (in_array(ActionInterface::class, $impl, true)) { + if (is_array($impl) && in_array(ActionInterface::class, $impl, true)) { $methodsDefined = false; foreach ($impl as $i) { if (preg_match('/\\\Http[a-z]+ActionInterface$/i', $i)) { diff --git a/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/SerializationAware.php b/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/SerializationAware.php new file mode 100644 index 0000000000000..e38fba8558bad --- /dev/null +++ b/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/SerializationAware.php @@ -0,0 +1,34 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\CodeMessDetector\Rule\Design; + +use PHPMD\AbstractNode; +use PHPMD\AbstractRule; +use PHPMD\Node\ClassNode; +use PHPMD\Node\MethodNode; +use PDepend\Source\AST\ASTMethod; +use PHPMD\Rule\MethodAware; + +/** + * Detect PHP serialization aware methods. + */ +class SerializationAware extends AbstractRule implements MethodAware +{ + /** + * @inheritDoc + * + * @param ASTMethod|MethodNode $method + */ + public function apply(AbstractNode $method) + { + if ($method->getName() === '__wakeup' || $method->getName() === '__sleep') { + $this->addViolation($method, [$method->getName(), $method->getParent()->getFullQualifiedName()]); + } + } +} diff --git a/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml b/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml index 53f2fe4a0084e..5f2461812bab7 100644 --- a/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml +++ b/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml @@ -60,6 +60,31 @@ class OrderProcessor $currentOrder = $this->session->get('current_order'); ... } +} + ]]> + </example> + </rule> + <rule name="SerializationAware" + class="Magento\CodeMessDetector\Rule\Design\SerializationAware" + message="{1} has {0} method and is PHP serialization aware - PHP serialization must be avoided."> + <description> + <![CDATA[ +Using PHP serialization must be avoided in Magento for security reasons and for prevention of unexpected behaviour. + ]]> + </description> + <priority>2</priority> + <properties /> + <example> + <![CDATA[ +class MyModel extends AbstractModel +{ + + ....... + + public function __sleep() + { + ..... + } } ]]> </example> diff --git a/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php b/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php index 4958795412681..913cc9448b978 100644 --- a/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php +++ b/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php @@ -285,11 +285,12 @@ private function isPluginDependency($dependent, $dependency) * @return array * @throws LocalizedException * @throws \Exception + * @SuppressWarnings(PMD.CyclomaticComplexity) */ protected function _caseGetUrl(string $currentModule, string &$contents): array { - $pattern = '#(\->|:)(?<source>getUrl\(([\'"])(?<route_id>[a-z0-9\-_]{3,})' - .'(/(?<controller_name>[a-z0-9\-_]+))?(/(?<action_name>[a-z0-9\-_]+))?\3)#i'; + $pattern = '#(\->|:)(?<source>getUrl\(([\'"])(?<route_id>[a-z0-9\-_]{3,}|\*)' + .'(/(?<controller_name>[a-z0-9\-_]+|\*))?(/(?<action_name>[a-z0-9\-_]+|\*))?\3)#i'; $dependencies = []; if (!preg_match_all($pattern, $contents, $matches, PREG_SET_ORDER)) { @@ -298,10 +299,22 @@ protected function _caseGetUrl(string $currentModule, string &$contents): array try { foreach ($matches as $item) { + $routeId = $item['route_id']; + $controllerName = $item['controller_name'] ?? UrlInterface::DEFAULT_CONTROLLER_NAME; + $actionName = $item['action_name'] ?? UrlInterface::DEFAULT_ACTION_NAME; + + // skip rest + if ($routeId === "rest") { //MC-19890 + continue; + } + // skip wildcards + if ($routeId === "*" || $controllerName === "*" || $actionName === "*") { //MC-19890 + continue; + } $modules = $this->routeMapper->getDependencyByRoutePath( - $item['route_id'], - $item['controller_name'] ?? UrlInterface::DEFAULT_CONTROLLER_NAME, - $item['action_name'] ?? UrlInterface::DEFAULT_ACTION_NAME + $routeId, + $controllerName, + $actionName ); if (!in_array($currentModule, $modules)) { if (count($modules) === 1) { diff --git a/dev/tests/static/framework/Magento/TestFramework/Dependency/Route/RouteMapper.php b/dev/tests/static/framework/Magento/TestFramework/Dependency/Route/RouteMapper.php index 87cc0985a053b..315bb2ae26b02 100644 --- a/dev/tests/static/framework/Magento/TestFramework/Dependency/Route/RouteMapper.php +++ b/dev/tests/static/framework/Magento/TestFramework/Dependency/Route/RouteMapper.php @@ -174,6 +174,7 @@ public function getDependencyByRoutePath( $dependencies = []; foreach ($this->getRouterTypes() as $routerId) { if (isset($this->getActionsMap()[$routerId][$routeId][$controllerName][$actionName])) { + //phpcs:ignore Magento2.Performance.ForeachArrayMerge $dependencies = array_merge( $dependencies, $this->getActionsMap()[$routerId][$routeId][$controllerName][$actionName] diff --git a/dev/tests/static/testsuite/Magento/Test/Integrity/DependencyTest.php b/dev/tests/static/testsuite/Magento/Test/Integrity/DependencyTest.php index 16ba295588b58..fa0d365061858 100644 --- a/dev/tests/static/testsuite/Magento/Test/Integrity/DependencyTest.php +++ b/dev/tests/static/testsuite/Magento/Test/Integrity/DependencyTest.php @@ -234,8 +234,8 @@ protected static function _initRules() . '/_files/dependency_test/tables_*.php'; $dbRuleTables = []; foreach (glob($replaceFilePattern) as $fileName) { - //phpcs:ignore Generic.PHP.NoSilencedErrors - $dbRuleTables = array_merge($dbRuleTables, @include $fileName); + //phpcs:ignore Magento2.Performance.ForeachArrayMerge + $dbRuleTables = array_merge($dbRuleTables, include $fileName); } self::$_rulesInstances = [ new PhpRule( @@ -267,11 +267,11 @@ private static function getRoutesWhitelist(): array $routesWhitelistFilePattern = realpath(__DIR__) . '/_files/dependency_test/whitelist/routes_*.php'; $routesWhitelist = []; foreach (glob($routesWhitelistFilePattern) as $fileName) { + //phpcs:ignore Magento2.Performance.ForeachArrayMerge $routesWhitelist = array_merge($routesWhitelist, include $fileName); } self::$routesWhitelist = $routesWhitelist; } - return self::$routesWhitelist; } @@ -284,24 +284,26 @@ private static function getRoutesWhitelist(): array */ protected function _getCleanedFileContents($fileType, $file) { - $contents = (string)file_get_contents($file); + $contents = null; switch ($fileType) { case 'php': - //Removing php comments - $contents = preg_replace('~/\*.*?\*/~m', '', $contents); - $contents = preg_replace('~^\s*/\*.*?\*/~sm', '', $contents); - $contents = preg_replace('~^\s*//.*$~m', '', $contents); + $contents = php_strip_whitespace($file); break; case 'layout': case 'config': //Removing xml comments - $contents = preg_replace('~\<!\-\-/.*?\-\-\>~s', '', $contents); + $contents = preg_replace( + '~\<!\-\-/.*?\-\-\>~s', + '', + file_get_contents($file) + ); break; case 'template': + $contents = php_strip_whitespace($file); //Removing html $contentsWithoutHtml = ''; preg_replace_callback( - '~(<\?php\s+.*\?>)~sU', + '~(<\?(php|=)\s+.*\?>)~sU', function ($matches) use ($contents, &$contentsWithoutHtml) { $contentsWithoutHtml .= $matches[1]; return $contents; @@ -309,10 +311,9 @@ function ($matches) use ($contents, &$contentsWithoutHtml) { $contents ); $contents = $contentsWithoutHtml; - //Removing php comments - $contents = preg_replace('~/\*.*?\*/~s', '', $contents); - $contents = preg_replace('~^\s*//.*$~s', '', $contents); break; + default: + $contents = file_get_contents($file); } return $contents; } @@ -393,9 +394,9 @@ protected function getDependenciesFromFiles($module, $fileType, $file, $contents foreach (self::$_rulesInstances as $rule) { /** @var \Magento\TestFramework\Dependency\RuleInterface $rule */ $newDependencies = $rule->getDependencyInfo($module, $fileType, $file, $contents); + //phpcs:ignore Magento2.Performance.ForeachArrayMerge $dependencies = array_merge($dependencies, $newDependencies); } - foreach ($dependencies as $key => $dependency) { foreach (self::$whiteList as $namespace) { if (strpos($dependency['source'], $namespace) !== false) { @@ -509,12 +510,12 @@ public function collectRedundant() foreach (array_keys(self::$mapDependencies) as $module) { $declared = $this->_getDependencies($module, self::TYPE_HARD, self::MAP_TYPE_DECLARED); + //phpcs:ignore Magento2.Performance.ForeachArrayMerge $found = array_merge( $this->_getDependencies($module, self::TYPE_HARD, self::MAP_TYPE_FOUND), $this->_getDependencies($module, self::TYPE_SOFT, self::MAP_TYPE_FOUND), $schemaDependencyProvider->getDeclaredExistingModuleDependencies($module) ); - $found['Magento\Framework'] = 'Magento\Framework'; $this->_setDependencies($module, self::TYPE_HARD, self::MAP_TYPE_REDUNDANT, array_diff($declared, $found)); } @@ -578,37 +579,16 @@ protected function _prepareFiles($fileType, $files, $skip = null) */ public function getAllFiles() { - $files = []; - - // Get all php files - $files = array_merge( - $files, + return array_merge( $this->_prepareFiles( 'php', Files::init()->getPhpFiles(Files::INCLUDE_APP_CODE | Files::AS_DATA_SET | Files::INCLUDE_NON_CLASSES), true - ) - ); - - // Get all configuration files - $files = array_merge( - $files, - $this->_prepareFiles('config', Files::init()->getConfigFiles()) - ); - - //Get all layout updates files - $files = array_merge( - $files, - $this->_prepareFiles('layout', Files::init()->getLayoutFiles()) - ); - - // Get all template files - $files = array_merge( - $files, + ), + $this->_prepareFiles('config', Files::init()->getConfigFiles()), + $this->_prepareFiles('layout', Files::init()->getLayoutFiles()), $this->_prepareFiles('template', Files::init()->getPhtmlFiles()) ); - - return $files; } /** diff --git a/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml b/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml index 0e3b5fa3d341c..e65a9a089da9e 100644 --- a/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml +++ b/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml @@ -45,5 +45,6 @@ <!-- Magento Specific Rules --> <rule ref="Magento/CodeMessDetector/resources/rulesets/design.xml/AllPurposeAction" /> <rule ref="Magento/CodeMessDetector/resources/rulesets/design.xml/CookieAndSessionMisuse" /> + <rule ref="Magento/CodeMessDetector/resources/rulesets/design.xml/SerializationAware" /> </ruleset> diff --git a/lib/internal/Magento/Framework/App/AreaList/Proxy.php b/lib/internal/Magento/Framework/App/AreaList/Proxy.php index d080e4cabbd87..105ddd3727906 100644 --- a/lib/internal/Magento/Framework/App/AreaList/Proxy.php +++ b/lib/internal/Magento/Framework/App/AreaList/Proxy.php @@ -3,10 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Framework\App\AreaList; /** - * Application area list + * Proxy for area list. */ class Proxy extends \Magento\Framework\App\AreaList implements \Magento\Framework\ObjectManager\NoninterceptableInterface @@ -57,9 +58,12 @@ public function __construct( } /** - * Sleep magic method. + * Remove links to other objects. * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -70,6 +74,9 @@ public function __sleep() * Retrieve ObjectManager from global scope * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/App/ExceptionHandler.php b/lib/internal/Magento/Framework/App/ExceptionHandler.php new file mode 100644 index 0000000000000..2bec055808aca --- /dev/null +++ b/lib/internal/Magento/Framework/App/ExceptionHandler.php @@ -0,0 +1,284 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Framework\App; + +use Magento\Framework\App\Response\Http as ResponseHttp; +use Magento\Framework\App\Request\Http as RequestHttp; +use Magento\Framework\Encryption\EncryptorInterface; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Debug; +use Magento\Framework\Filesystem; +use Psr\Log\LoggerInterface; +use Magento\Framework\Exception\SessionException; +use Magento\Framework\Exception\State\InitException; + +/** + * Handler of HTTP web application exception + */ +class ExceptionHandler implements ExceptionHandlerInterface +{ + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var EncryptorInterface + */ + private $encryptor; + + /** + * @param EncryptorInterface $encryptor + * @param Filesystem $filesystem + * @param LoggerInterface $logger + */ + public function __construct( + EncryptorInterface $encryptor, + Filesystem $filesystem, + LoggerInterface $logger + ) { + $this->encryptor = $encryptor; + $this->filesystem = $filesystem; + $this->logger = $logger; + } + + /** + * Handles exception of HTTP web application + * + * @param Bootstrap $bootstrap + * @param \Exception $exception + * @param ResponseHttp $response + * @param RequestHttp $request + * @return bool + */ + public function handle( + Bootstrap $bootstrap, + \Exception $exception, + ResponseHttp $response, + RequestHttp $request + ): bool { + $result = $this->handleDeveloperMode($bootstrap, $exception, $response) + || $this->handleBootstrapErrors($bootstrap, $exception, $response) + || $this->handleSessionException($exception, $response, $request) + || $this->handleInitException($exception) + || $this->handleGenericReport($bootstrap, $exception); + return $result; + } + + /** + * Error handler for developer mode + * + * @param Bootstrap $bootstrap + * @param \Exception $exception + * @param ResponseHttp $response + * @return bool + */ + private function handleDeveloperMode( + Bootstrap $bootstrap, + \Exception $exception, + ResponseHttp $response + ): bool { + if ($bootstrap->isDeveloperMode()) { + if (Bootstrap::ERR_IS_INSTALLED == $bootstrap->getErrorCode()) { + try { + $this->redirectToSetup($bootstrap, $exception, $response); + return true; + } catch (\Exception $e) { + $exception = $e; + } + } + $response->setHttpResponseCode(500); + $response->setHeader('Content-Type', 'text/plain'); + $response->setBody($this->buildContentFromException($exception)); + $response->sendResponse(); + return true; + } + return false; + } + + /** + * Build content based on an exception + * + * @param \Exception $exception + * @return string + */ + private function buildContentFromException(\Exception $exception): string + { + /** @var \Exception[] $exceptions */ + $exceptions = []; + + do { + $exceptions[] = $exception; + } while ($exception = $exception->getPrevious()); + + $buffer = sprintf("%d exception(s):\n", count($exceptions)); + + foreach ($exceptions as $index => $exception) { + $buffer .= sprintf( + "Exception #%d (%s): %s\n", + $index, + get_class($exception), + $exception->getMessage() + ); + } + + foreach ($exceptions as $index => $exception) { + $buffer .= sprintf( + "\nException #%d (%s): %s\n%s\n", + $index, + get_class($exception), + $exception->getMessage(), + Debug::trace( + $exception->getTrace(), + true, + true, + (bool)getenv('MAGE_DEBUG_SHOW_ARGS') + ) + ); + } + + return $buffer; + } + + /** + * Handler for bootstrap errors + * + * @param Bootstrap $bootstrap + * @param \Exception $exception + * @param ResponseHttp $response + * @return bool + */ + private function handleBootstrapErrors( + Bootstrap $bootstrap, + \Exception &$exception, + ResponseHttp $response + ): bool { + $bootstrapCode = $bootstrap->getErrorCode(); + if (Bootstrap::ERR_MAINTENANCE == $bootstrapCode) { + // phpcs:ignore Magento2.Security.IncludeFile + require $this->filesystem + ->getDirectoryRead(DirectoryList::PUB) + ->getAbsolutePath('errors/503.php'); + return true; + } + if (Bootstrap::ERR_IS_INSTALLED == $bootstrapCode) { + try { + $this->redirectToSetup($bootstrap, $exception, $response); + return true; + } catch (\Exception $e) { + $exception = $e; + } + } + return false; + } + + /** + * Handler for session errors + * + * @param \Exception $exception + * @param ResponseHttp $response + * @param RequestHttp $request + * @return bool + */ + private function handleSessionException( + \Exception $exception, + ResponseHttp $response, + RequestHttp $request + ): bool { + if ($exception instanceof SessionException) { + $response->setRedirect($request->getDistroBaseUrl()); + $response->sendHeaders(); + return true; + } + return false; + } + + /** + * Handler for application initialization errors + * + * @param \Exception $exception + * @return bool + */ + private function handleInitException(\Exception $exception): bool + { + if ($exception instanceof InitException) { + $this->logger->critical($exception); + // phpcs:ignore Magento2.Security.IncludeFile + require $this->filesystem + ->getDirectoryRead(DirectoryList::PUB) + ->getAbsolutePath('errors/404.php'); + return true; + } + return false; + } + + /** + * Handle for any other errors + * + * @param Bootstrap $bootstrap + * @param \Exception $exception + * @return bool + */ + private function handleGenericReport(Bootstrap $bootstrap, \Exception $exception): bool + { + $reportData = [ + $exception->getMessage(), + Debug::trace( + $exception->getTrace(), + true, + false, + (bool)getenv('MAGE_DEBUG_SHOW_ARGS') + ) + ]; + $params = $bootstrap->getParams(); + if (isset($params['REQUEST_URI'])) { + $reportData['url'] = $params['REQUEST_URI']; + } + if (isset($params['SCRIPT_NAME'])) { + $reportData['script_name'] = $params['SCRIPT_NAME']; + } + $reportData['report_id'] = $this->encryptor->getHash(implode('', $reportData)); + $this->logger->critical($exception, ['report_id' => $reportData['report_id']]); + // phpcs:ignore Magento2.Security.IncludeFile + require $this->filesystem + ->getDirectoryRead(DirectoryList::PUB) + ->getAbsolutePath('errors/report.php'); + return true; + } + + /** + * If not installed, try to redirect to installation wizard + * + * @param Bootstrap $bootstrap + * @param \Exception $exception + * @param ResponseHttp $response + * @return void + * @throws \Exception + */ + private function redirectToSetup(Bootstrap $bootstrap, \Exception $exception, ResponseHttp $response) + { + $setupInfo = new SetupInfo($bootstrap->getParams()); + $projectRoot = $this->filesystem->getDirectoryRead(DirectoryList::ROOT)->getAbsolutePath(); + if ($setupInfo->isAvailable()) { + $response->setRedirect($setupInfo->getUrl()); + $response->sendHeaders(); + } else { + $newMessage = $exception->getMessage() . "\nNOTE: You cannot install Magento using the Setup Wizard " + . "because the Magento setup directory cannot be accessed. \n" + . 'You can install Magento using either the command line or you must restore access ' + . 'to the following directory: ' . $setupInfo->getDir($projectRoot) . "\n"; + // phpcs:ignore Magento2.Exceptions.DirectThrow + throw new \Exception($newMessage, 0, $exception); + } + } +} diff --git a/lib/internal/Magento/Framework/App/ExceptionHandlerInterface.php b/lib/internal/Magento/Framework/App/ExceptionHandlerInterface.php new file mode 100644 index 0000000000000..b4bb5d555017d --- /dev/null +++ b/lib/internal/Magento/Framework/App/ExceptionHandlerInterface.php @@ -0,0 +1,31 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Framework\App; + +use Magento\Framework\App\Response\Http as ResponseHttp; +use Magento\Framework\App\Request\Http as RequestHttp; + +/** + * Interface ExceptionHandler + */ +interface ExceptionHandlerInterface +{ + /** + * Handles exception of HTTP web application + * + * @param Bootstrap $bootstrap + * @param \Exception $exception + * @param ResponseHttp $response + * @param RequestHttp $request + * @return bool + */ + public function handle( + Bootstrap $bootstrap, + \Exception $exception, + ResponseHttp $response, + RequestHttp $request + ): bool; +} diff --git a/lib/internal/Magento/Framework/App/Http.php b/lib/internal/Magento/Framework/App/Http.php index ca3976da1df52..d54dda9e9f166 100644 --- a/lib/internal/Magento/Framework/App/Http.php +++ b/lib/internal/Magento/Framework/App/Http.php @@ -3,18 +3,17 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - namespace Magento\Framework\App; -use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\Request\Http as RequestHttp; use Magento\Framework\App\Response\Http as ResponseHttp; use Magento\Framework\App\Response\HttpInterface; use Magento\Framework\Controller\ResultInterface; -use Magento\Framework\Debug; -use Magento\Framework\Event; -use Magento\Framework\Filesystem; use Magento\Framework\ObjectManager\ConfigLoaderInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\Event\Manager; +use Magento\Framework\Registry; /** * HTTP web application. Called from webroot index.php to serve web requests. @@ -24,12 +23,12 @@ class Http implements \Magento\Framework\AppInterface { /** - * @var \Magento\Framework\ObjectManagerInterface + * @var ObjectManagerInterface */ protected $_objectManager; /** - * @var \Magento\Framework\Event\Manager + * @var Manager */ protected $_eventManager; @@ -53,47 +52,42 @@ class Http implements \Magento\Framework\AppInterface */ protected $_state; - /** - * @var Filesystem - */ - protected $_filesystem; - /** * @var ResponseHttp */ protected $_response; /** - * @var \Magento\Framework\Registry + * @var Registry */ protected $registry; /** - * @var \Psr\Log\LoggerInterface + * @var ExceptionHandlerInterface */ - private $logger; + private $exceptionHandler; /** - * @param \Magento\Framework\ObjectManagerInterface $objectManager - * @param Event\Manager $eventManager + * @param ObjectManagerInterface $objectManager + * @param Manager $eventManager * @param AreaList $areaList * @param RequestHttp $request * @param ResponseHttp $response * @param ConfigLoaderInterface $configLoader * @param State $state - * @param Filesystem $filesystem - * @param \Magento\Framework\Registry $registry + * @param Registry $registry + * @param ExceptionHandlerInterface $exceptionHandler */ public function __construct( - \Magento\Framework\ObjectManagerInterface $objectManager, - Event\Manager $eventManager, + ObjectManagerInterface $objectManager, + Manager $eventManager, AreaList $areaList, RequestHttp $request, ResponseHttp $response, ConfigLoaderInterface $configLoader, State $state, - Filesystem $filesystem, - \Magento\Framework\Registry $registry + Registry $registry, + ExceptionHandlerInterface $exceptionHandler = null ) { $this->_objectManager = $objectManager; $this->_eventManager = $eventManager; @@ -102,30 +96,15 @@ public function __construct( $this->_response = $response; $this->_configLoader = $configLoader; $this->_state = $state; - $this->_filesystem = $filesystem; $this->registry = $registry; - } - - /** - * Add new dependency - * - * @return \Psr\Log\LoggerInterface - * - * @deprecated 100.1.0 - */ - private function getLogger() - { - if (!$this->logger instanceof \Psr\Log\LoggerInterface) { - $this->logger = \Magento\Framework\App\ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class); - } - return $this->logger; + $this->exceptionHandler = $exceptionHandler ?: $this->_objectManager->get(ExceptionHandlerInterface::class); } /** * Run application * - * @throws \InvalidArgumentException * @return ResponseInterface + * @throws LocalizedException|\InvalidArgumentException */ public function launch() { @@ -172,193 +151,8 @@ private function handleHeadRequest() /** * @inheritdoc */ - public function catchException(Bootstrap $bootstrap, \Exception $exception) - { - $result = $this->handleDeveloperMode($bootstrap, $exception) - || $this->handleBootstrapErrors($bootstrap, $exception) - || $this->handleSessionException($exception) - || $this->handleInitException($exception) - || $this->handleGenericReport($bootstrap, $exception); - return $result; - } - - /** - * Error handler for developer mode - * - * @param Bootstrap $bootstrap - * @param \Exception $exception - * @return bool - */ - private function handleDeveloperMode(Bootstrap $bootstrap, \Exception $exception) + public function catchException(Bootstrap $bootstrap, \Exception $exception): bool { - if ($bootstrap->isDeveloperMode()) { - if (Bootstrap::ERR_IS_INSTALLED == $bootstrap->getErrorCode()) { - try { - $this->redirectToSetup($bootstrap, $exception); - return true; - } catch (\Exception $e) { - $exception = $e; - } - } - $this->_response->setHttpResponseCode(500); - $this->_response->setHeader('Content-Type', 'text/plain'); - $this->_response->setBody($this->buildContentFromException($exception)); - $this->_response->sendResponse(); - return true; - } - return false; - } - - /** - * Build content based on an exception - * - * @param \Exception $exception - * @return string - */ - private function buildContentFromException(\Exception $exception) - { - /** @var \Exception[] $exceptions */ - $exceptions = []; - - do { - $exceptions[] = $exception; - } while ($exception = $exception->getPrevious()); - - $buffer = sprintf("%d exception(s):\n", count($exceptions)); - - foreach ($exceptions as $index => $exception) { - $buffer .= sprintf("Exception #%d (%s): %s\n", $index, get_class($exception), $exception->getMessage()); - } - - foreach ($exceptions as $index => $exception) { - $buffer .= sprintf( - "\nException #%d (%s): %s\n%s\n", - $index, - get_class($exception), - $exception->getMessage(), - Debug::trace( - $exception->getTrace(), - true, - true, - (bool)getenv('MAGE_DEBUG_SHOW_ARGS') - ) - ); - } - - return $buffer; - } - - /** - * If not installed, try to redirect to installation wizard - * - * @param Bootstrap $bootstrap - * @param \Exception $exception - * @return void - * @throws \Exception - */ - private function redirectToSetup(Bootstrap $bootstrap, \Exception $exception) - { - $setupInfo = new SetupInfo($bootstrap->getParams()); - $projectRoot = $this->_filesystem->getDirectoryRead(DirectoryList::ROOT)->getAbsolutePath(); - if ($setupInfo->isAvailable()) { - $this->_response->setRedirect($setupInfo->getUrl()); - $this->_response->sendHeaders(); - } else { - $newMessage = $exception->getMessage() . "\nNOTE: You cannot install Magento using the Setup Wizard " - . "because the Magento setup directory cannot be accessed. \n" - . 'You can install Magento using either the command line or you must restore access ' - . 'to the following directory: ' . $setupInfo->getDir($projectRoot) . "\n"; - // phpcs:ignore Magento2.Exceptions.DirectThrow - throw new \Exception($newMessage, 0, $exception); - } - } - - /** - * Handler for bootstrap errors - * - * @param Bootstrap $bootstrap - * @param \Exception $exception - * @return bool - */ - private function handleBootstrapErrors(Bootstrap $bootstrap, \Exception &$exception) - { - $bootstrapCode = $bootstrap->getErrorCode(); - if (Bootstrap::ERR_MAINTENANCE == $bootstrapCode) { - // phpcs:ignore Magento2.Security.IncludeFile - require $this->_filesystem->getDirectoryRead(DirectoryList::PUB)->getAbsolutePath('errors/503.php'); - return true; - } - if (Bootstrap::ERR_IS_INSTALLED == $bootstrapCode) { - try { - $this->redirectToSetup($bootstrap, $exception); - return true; - } catch (\Exception $e) { - $exception = $e; - } - } - return false; - } - - /** - * Handler for session errors - * - * @param \Exception $exception - * @return bool - */ - private function handleSessionException(\Exception $exception) - { - if ($exception instanceof \Magento\Framework\Exception\SessionException) { - $this->_response->setRedirect($this->_request->getDistroBaseUrl()); - $this->_response->sendHeaders(); - return true; - } - return false; - } - - /** - * Handler for application initialization errors - * - * @param \Exception $exception - * @return bool - */ - private function handleInitException(\Exception $exception) - { - if ($exception instanceof \Magento\Framework\Exception\State\InitException) { - $this->getLogger()->critical($exception); - // phpcs:ignore Magento2.Security.IncludeFile - require $this->_filesystem->getDirectoryRead(DirectoryList::PUB)->getAbsolutePath('errors/404.php'); - return true; - } - return false; - } - - /** - * Handle for any other errors - * - * @param Bootstrap $bootstrap - * @param \Exception $exception - * @return bool - */ - private function handleGenericReport(Bootstrap $bootstrap, \Exception $exception) - { - $reportData = [ - $exception->getMessage(), - Debug::trace( - $exception->getTrace(), - true, - true, - (bool)getenv('MAGE_DEBUG_SHOW_ARGS') - ) - ]; - $params = $bootstrap->getParams(); - if (isset($params['REQUEST_URI'])) { - $reportData['url'] = $params['REQUEST_URI']; - } - if (isset($params['SCRIPT_NAME'])) { - $reportData['script_name'] = $params['SCRIPT_NAME']; - } - // phpcs:ignore Magento2.Security.IncludeFile - require $this->_filesystem->getDirectoryRead(DirectoryList::PUB)->getAbsolutePath('errors/report.php'); - return true; + return $this->exceptionHandler->handle($bootstrap, $exception, $this->_response, $this->_request); } } diff --git a/lib/internal/Magento/Framework/App/ProductMetadata.php b/lib/internal/Magento/Framework/App/ProductMetadata.php index c9fde94352a71..631dba8273bcd 100644 --- a/lib/internal/Magento/Framework/App/ProductMetadata.php +++ b/lib/internal/Magento/Framework/App/ProductMetadata.php @@ -8,12 +8,13 @@ namespace Magento\Framework\App; use Magento\Framework\Composer\ComposerFactory; -use \Magento\Framework\Composer\ComposerJsonFinder; -use \Magento\Framework\App\Filesystem\DirectoryList; -use \Magento\Framework\Composer\ComposerInformation; +use Magento\Framework\Composer\ComposerJsonFinder; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Composer\ComposerInformation; /** * Class ProductMetadata + * * @package Magento\Framework\App */ class ProductMetadata implements ProductMetadataInterface @@ -28,6 +29,11 @@ class ProductMetadata implements ProductMetadataInterface */ const PRODUCT_NAME = 'Magento'; + /** + * Cache key for Magento product version + */ + private const MAGENTO_PRODUCT_VERSION_CACHE_KEY = 'magento-product-version'; + /** * Product version * @@ -46,12 +52,19 @@ class ProductMetadata implements ProductMetadataInterface */ private $composerInformation; + /** + * @var CacheInterface + */ + private $cache; + /** * @param ComposerJsonFinder $composerJsonFinder + * @param CacheInterface|null $cache */ - public function __construct(ComposerJsonFinder $composerJsonFinder) + public function __construct(ComposerJsonFinder $composerJsonFinder, CacheInterface $cache = null) { $this->composerJsonFinder = $composerJsonFinder; + $this->cache = $cache ?: ObjectManager::getInstance()->get(CacheInterface::class); } /** @@ -61,6 +74,9 @@ public function __construct(ComposerJsonFinder $composerJsonFinder) */ public function getVersion() { + if ($cachedVersion = $this->cache->load(self::MAGENTO_PRODUCT_VERSION_CACHE_KEY)) { + $this->version = $cachedVersion; + } if (!$this->version) { if (!($this->version = $this->getSystemPackageVersion())) { if ($this->getComposerInformation()->isMagentoRoot()) { @@ -69,6 +85,7 @@ public function getVersion() $this->version = 'UNKNOWN'; } } + $this->cache->save($this->version, self::MAGENTO_PRODUCT_VERSION_CACHE_KEY); } return $this->version; } diff --git a/lib/internal/Magento/Framework/App/Response/Http.php b/lib/internal/Magento/Framework/App/Response/Http.php index e6fff90837d9d..279ae9d9649f6 100644 --- a/lib/internal/Magento/Framework/App/Response/Http.php +++ b/lib/internal/Magento/Framework/App/Response/Http.php @@ -1,10 +1,9 @@ <?php /** - * HTTP response - * * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Framework\App\Response; use Magento\Framework\App\Http\Context; @@ -17,7 +16,7 @@ use Magento\Framework\Session\Config\ConfigInterface; /** - * HTTP response + * HTTP Response. * * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ @@ -182,10 +181,13 @@ public function representJson($content) } /** - * Sleep magic method. + * Remove links to other objects. * * @return string[] * @codeCoverageIgnore + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -197,6 +199,9 @@ public function __sleep() * * @return void * @codeCoverageIgnore + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/App/Route/ConfigInterface/Proxy.php b/lib/internal/Magento/Framework/App/Route/ConfigInterface/Proxy.php index 09dda9727b937..863a6d7d836d4 100644 --- a/lib/internal/Magento/Framework/App/Route/ConfigInterface/Proxy.php +++ b/lib/internal/Magento/Framework/App/Route/ConfigInterface/Proxy.php @@ -60,9 +60,12 @@ public function __construct( } /** - * Sleep magic method. + * Remove links to other objects. * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -73,6 +76,9 @@ public function __sleep() * Retrieve ObjectManager from global scope * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/App/Test/Unit/ExceptionHandlerTest.php b/lib/internal/Magento/Framework/App/Test/Unit/ExceptionHandlerTest.php new file mode 100644 index 0000000000000..bce2fd8113149 --- /dev/null +++ b/lib/internal/Magento/Framework/App/Test/Unit/ExceptionHandlerTest.php @@ -0,0 +1,277 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Framework\App\Test\Unit; + +use Magento\Framework\App\ExceptionHandler; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\SetupInfo; +use Magento\Framework\App\Bootstrap; +use Magento\Framework\Debug; +use Magento\Framework\Encryption\EncryptorInterface; +use Magento\Framework\Filesystem; +use PHPUnit\Framework\MockObject\MockObject; +use Magento\Framework\App\Response\Http as ResponseHttp; +use Magento\Framework\App\Request\Http as RequestHttp; +use Psr\Log\LoggerInterface; +use Magento\Framework\Filesystem\Directory\ReadInterface; +use Magento\Framework\Exception\SessionException; +use Magento\Framework\Phrase; +use PHPUnit\Framework\Constraint\StringStartsWith; +use Magento\Framework\Exception\State\InitException; + +/** + * Test for \Magento\Framework\App\ExceptionHandler class + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class ExceptionHandlerTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ExceptionHandler + */ + private $exceptionHandler; + + /** + * @var EncryptorInterface|MockObject + */ + private $encryptorInterfaceMock; + + /** + * @var FileSystem|MockObject + */ + private $filesystemMock; + + /** + * @var LoggerInterface|MockObject + */ + private $loggerMock; + + /** + * @var ResponseHttp|MockObject + */ + private $responseMock; + + /** + * @var RequestHttp|MockObject + */ + private $requestMock; + + protected function setUp() + { + $this->encryptorInterfaceMock = $this->createMock(EncryptorInterface::class); + $this->filesystemMock = $this->createMock(Filesystem::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->responseMock = $this->createMock(ResponseHttp::class); + $this->requestMock = $this->getMockBuilder(RequestHttp::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->exceptionHandler = new ExceptionHandler( + $this->encryptorInterfaceMock, + $this->filesystemMock, + $this->loggerMock + ); + } + + public function testHandleDeveloperModeNotInstalled() + { + $dir = $this->getMockForAbstractClass(ReadInterface::class); + $dir->expects($this->once()) + ->method('getAbsolutePath') + ->willReturn(__DIR__); + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryRead') + ->with(DirectoryList::ROOT) + ->willReturn($dir); + $this->responseMock->expects($this->once()) + ->method('setRedirect') + ->with('/_files/'); + $this->responseMock->expects($this->once()) + ->method('sendHeaders'); + $bootstrap = $this->getBootstrapNotInstalled(); + $bootstrap->expects($this->once()) + ->method('getParams') + ->willReturn( + [ + 'SCRIPT_NAME' => '/index.php', + 'DOCUMENT_ROOT' => __DIR__, + 'SCRIPT_FILENAME' => __DIR__ . '/index.php', + SetupInfo::PARAM_NOT_INSTALLED_URL_PATH => '_files', + ] + ); + $this->assertTrue( + $this->exceptionHandler->handle( + $bootstrap, + new \Exception('Test Message'), + $this->responseMock, + $this->requestMock + ) + ); + } + + public function testHandleDeveloperMode() + { + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryRead') + ->will($this->throwException(new \Exception('strange error'))); + $this->responseMock->expects($this->once()) + ->method('setHttpResponseCode') + ->with(500); + $this->responseMock->expects($this->once()) + ->method('setHeader') + ->with('Content-Type', 'text/plain'); + $constraint = new StringStartsWith('1 exception(s):'); + $this->responseMock->expects($this->once()) + ->method('setBody') + ->with($constraint); + $this->responseMock->expects($this->once()) + ->method('sendResponse'); + $bootstrap = $this->getBootstrapNotInstalled(); + $bootstrap->expects($this->once()) + ->method('getParams') + ->willReturn( + ['DOCUMENT_ROOT' => 'something', 'SCRIPT_FILENAME' => 'something/else'] + ); + $this->assertTrue( + $this->exceptionHandler->handle( + $bootstrap, + new \Exception('Test'), + $this->responseMock, + $this->requestMock + ) + ); + } + + public function testCatchExceptionSessionException() + { + $this->responseMock->expects($this->once()) + ->method('setRedirect'); + $this->responseMock->expects($this->once()) + ->method('sendHeaders'); + /** @var Bootstrap|MockObject $bootstrap */ + $bootstrap = $this->createMock(Bootstrap::class); + $bootstrap->expects($this->once()) + ->method('isDeveloperMode') + ->willReturn(false); + $this->assertTrue( + $this->exceptionHandler->handle( + $bootstrap, + new SessionException(new Phrase('Test')), + $this->responseMock, + $this->requestMock + ) + ); + } + + public function testHandleInitException() + { + $bootstrap = $this->getBootstrapInstalled(); + $exception = new InitException(new Phrase('Test')); + $dir = $this->getMockForAbstractClass(ReadInterface::class); + $dir->expects($this->once()) + ->method('getAbsolutePath') + ->with('errors/404.php') + ->willReturn(__DIR__ . '/_files/pub/errors/404.php'); + $this->loggerMock->expects($this->once()) + ->method('critical') + ->with($exception); + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryRead') + ->with(DirectoryList::PUB) + ->willReturn($dir); + + $this->assertTrue( + $this->exceptionHandler->handle( + $bootstrap, + $exception, + $this->responseMock, + $this->requestMock + ) + ); + } + + public function testHandleGenericReport() + { + $bootstrap = $this->getBootstrapInstalled(); + $exception = new \Exception('Test'); + $dir = $this->getMockForAbstractClass(ReadInterface::class); + $dir->expects($this->once()) + ->method('getAbsolutePath') + ->with('errors/report.php') + ->willReturn(__DIR__ . '/_files/pub/errors/report.php'); + $bootstrap->expects($this->once()) + ->method('getParams') + ->willReturn(['REQUEST_URI' => 'some-request-uri', 'SCRIPT_NAME' => 'some-script-name']); + $reportData = [ + $exception->getMessage(), + Debug::trace( + $exception->getTrace(), + true, + false, + (bool)getenv('MAGE_DEBUG_SHOW_ARGS') + ), + 'url' => 'some-request-uri', + 'script_name' => 'some-script-name' + ]; + $this->encryptorInterfaceMock->expects($this->once()) + ->method('getHash') + ->with(implode('', $reportData)) + ->willReturn('some-sha256-hash'); + $this->loggerMock->expects($this->once()) + ->method('critical') + ->with($exception, ['report_id' => 'some-sha256-hash']); + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryRead') + ->with(DirectoryList::PUB) + ->willReturn($dir); + + $this->assertTrue( + $this->exceptionHandler->handle( + $bootstrap, + $exception, + $this->responseMock, + $this->requestMock + ) + ); + } + + /** + * Prepares a mock of bootstrap in "not installed" state + * + * @return Bootstrap|MockObject + */ + private function getBootstrapNotInstalled(): Bootstrap + { + $bootstrap = $this->createMock(Bootstrap::class); + $bootstrap->expects($this->once()) + ->method('isDeveloperMode') + ->willReturn(true); + $bootstrap->expects($this->once()) + ->method('getErrorCode') + ->willReturn(Bootstrap::ERR_IS_INSTALLED); + return $bootstrap; + } + + /** + * Prepares a mock of bootstrap in "installed" state + * + * @return Bootstrap|MockObject + */ + private function getBootstrapInstalled(): Bootstrap + { + /** @var Bootstrap|MockObject $bootstrap */ + $bootstrap = $this->createMock(Bootstrap::class); + $bootstrap->expects($this->once()) + ->method('isDeveloperMode') + ->willReturn(false); + $bootstrap->expects($this->once()) + ->method('getErrorCode') + ->willReturn(0); + return $bootstrap; + } +} diff --git a/lib/internal/Magento/Framework/App/Test/Unit/HttpTest.php b/lib/internal/Magento/Framework/App/Test/Unit/HttpTest.php index dbb315e88a526..5d8e5960e5798 100644 --- a/lib/internal/Magento/Framework/App/Test/Unit/HttpTest.php +++ b/lib/internal/Magento/Framework/App/Test/Unit/HttpTest.php @@ -3,12 +3,23 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - namespace Magento\Framework\App\Test\Unit; -use Magento\Framework\App\Filesystem\DirectoryList; -use Magento\Framework\App\SetupInfo; -use Magento\Framework\App\Bootstrap; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as HelperObjectManager; +use PHPUnit_Framework_MockObject_MockObject as MockObject; +use Magento\Framework\App\Request\Http as RequestHttp; +use Magento\Framework\App\Response\Http as ResponseHttp; +use Magento\Framework\App\Http as AppHttp; +use Magento\Framework\App\FrontControllerInterface; +use Magento\Framework\Event\Manager; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\App\AreaList; +use Magento\Framework\App\ObjectManager\ConfigLoader; +use Magento\Framework\App\ExceptionHandlerInterface; +use Magento\Framework\Stdlib\Cookie\CookieReaderInterface; +use Magento\Framework\App\Route\ConfigInterface\Proxy; +use Magento\Framework\App\Request\PathInfoProcessorInterface; +use Magento\Framework\Stdlib\StringUtils; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -16,85 +27,90 @@ class HttpTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager + * @var HelperObjectManager */ - protected $objectManager; + private $objectManager; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ResponseHttp|MockObject */ - protected $responseMock; + private $responseMock; /** - * @var \Magento\Framework\App\Http + * @var AppHttp */ - protected $http; + private $http; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var FrontControllerInterface|MockObject */ - protected $frontControllerMock; + private $frontControllerMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Manager|MockObject */ - protected $eventManagerMock; + private $eventManagerMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var RequestHttp|MockObject */ - protected $requestMock; + private $requestMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ObjectManagerInterface|MockObject */ - protected $objectManagerMock; + private $objectManagerMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var AreaList|MockObject */ - protected $areaListMock; + private $areaListMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ConfigLoader|MockObject */ - protected $configLoaderMock; + private $configLoaderMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ExceptionHandlerInterface|MockObject */ - protected $filesystemMock; + private $exceptionHandlerMock; + /** + * @inheritdoc + */ protected function setUp() { - $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $cookieReaderMock = $this->getMockBuilder(\Magento\Framework\Stdlib\Cookie\CookieReaderInterface::class) + $this->objectManager = new HelperObjectManager($this); + $cookieReaderMock = $this->getMockBuilder(CookieReaderInterface::class) ->disableOriginalConstructor() ->getMock(); - $routeConfigMock = $this->getMockBuilder(\Magento\Framework\App\Route\ConfigInterface\Proxy::class) + $routeConfigMock = $this->getMockBuilder(Proxy::class) ->disableOriginalConstructor() ->getMock(); - $pathInfoProcessorMock = $this->getMockBuilder(\Magento\Framework\App\Request\PathInfoProcessorInterface::class) + $pathInfoProcessorMock = $this->getMockBuilder(PathInfoProcessorInterface::class) ->disableOriginalConstructor() ->getMock(); - $converterMock = $this->getMockBuilder(\Magento\Framework\Stdlib\StringUtils::class) + $converterMock = $this->getMockBuilder(StringUtils::class) ->disableOriginalConstructor() ->setMethods(['cleanString']) ->getMock(); - $objectManagerMock = $this->getMockBuilder(\Magento\Framework\ObjectManagerInterface::class) + $objectManagerMock = $this->getMockBuilder(ObjectManagerInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\Request\Http::class) - ->setConstructorArgs([ - 'cookieReader' => $cookieReaderMock, - 'converter' => $converterMock, - 'routeConfig' => $routeConfigMock, - 'pathInfoProcessor' => $pathInfoProcessorMock, - 'objectManager' => $objectManagerMock - ]) + $this->requestMock = $this->getMockBuilder(RequestHttp::class) + ->setConstructorArgs( + [ + 'cookieReader' => $cookieReaderMock, + 'converter' => $converterMock, + 'routeConfig' => $routeConfigMock, + 'pathInfoProcessor' => $pathInfoProcessorMock, + 'objectManager' => $objectManagerMock + ] + ) ->setMethods(['getFrontName', 'isHead']) ->getMock(); - $this->areaListMock = $this->getMockBuilder(\Magento\Framework\App\AreaList::class) + $this->areaListMock = $this->getMockBuilder(AreaList::class) ->disableOriginalConstructor() ->setMethods(['getCodeByFrontName']) ->getMock(); @@ -102,20 +118,20 @@ protected function setUp() ->disableOriginalConstructor() ->setMethods(['load']) ->getMock(); - $this->objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); - $this->responseMock = $this->createMock(\Magento\Framework\App\Response\Http::class); + $this->objectManagerMock = $this->createMock(ObjectManagerInterface::class); + $this->responseMock = $this->createMock(ResponseHttp::class); $this->frontControllerMock = $this->getMockBuilder(\Magento\Framework\App\FrontControllerInterface::class) ->disableOriginalConstructor() ->setMethods(['dispatch']) ->getMock(); - $this->eventManagerMock = $this->getMockBuilder(\Magento\Framework\Event\Manager::class) + $this->eventManagerMock = $this->getMockBuilder(Manager::class) ->disableOriginalConstructor() ->setMethods(['dispatch']) ->getMock(); - $this->filesystemMock = $this->createMock(\Magento\Framework\Filesystem::class); + $this->exceptionHandlerMock = $this->createMock(ExceptionHandlerInterface::class); $this->http = $this->objectManager->getObject( - \Magento\Framework\App\Http::class, + AppHttp::class, [ 'objectManager' => $this->objectManagerMock, 'eventManager' => $this->eventManagerMock, @@ -123,7 +139,7 @@ protected function setUp() 'request' => $this->requestMock, 'response' => $this->responseMock, 'configLoader' => $this->configLoaderMock, - 'filesystem' => $this->filesystemMock, + 'exceptionHandler' => $this->exceptionHandlerMock, ] ); } @@ -247,92 +263,4 @@ public function dataProviderForTestLaunchHeadRequest(): array ] ]; } - - public function testHandleDeveloperModeNotInstalled() - { - $dir = $this->getMockForAbstractClass(\Magento\Framework\Filesystem\Directory\ReadInterface::class); - $dir->expects($this->once()) - ->method('getAbsolutePath') - ->willReturn(__DIR__); - $this->filesystemMock->expects($this->once()) - ->method('getDirectoryRead') - ->with(DirectoryList::ROOT) - ->willReturn($dir); - $this->responseMock->expects($this->once()) - ->method('setRedirect') - ->with('/_files/'); - $this->responseMock->expects($this->once()) - ->method('sendHeaders'); - $bootstrap = $this->getBootstrapNotInstalled(); - $bootstrap->expects($this->once()) - ->method('getParams') - ->willReturn( - [ - 'SCRIPT_NAME' => '/index.php', - 'DOCUMENT_ROOT' => __DIR__, - 'SCRIPT_FILENAME' => __DIR__ . '/index.php', - SetupInfo::PARAM_NOT_INSTALLED_URL_PATH => '_files', - ] - ); - $this->assertTrue($this->http->catchException($bootstrap, new \Exception('Test Message'))); - } - - public function testHandleDeveloperMode() - { - $this->filesystemMock->expects($this->once()) - ->method('getDirectoryRead') - ->will($this->throwException(new \Exception('strange error'))); - $this->responseMock->expects($this->once()) - ->method('setHttpResponseCode') - ->with(500); - $this->responseMock->expects($this->once()) - ->method('setHeader') - ->with('Content-Type', 'text/plain'); - $constraint = new \PHPUnit\Framework\Constraint\StringStartsWith('1 exception(s):'); - $this->responseMock->expects($this->once()) - ->method('setBody') - ->with($constraint); - $this->responseMock->expects($this->once()) - ->method('sendResponse'); - $bootstrap = $this->getBootstrapNotInstalled(); - $bootstrap->expects($this->once()) - ->method('getParams') - ->willReturn( - ['DOCUMENT_ROOT' => 'something', 'SCRIPT_FILENAME' => 'something/else'] - ); - $this->assertTrue($this->http->catchException($bootstrap, new \Exception('Test'))); - } - - public function testCatchExceptionSessionException() - { - $this->responseMock->expects($this->once()) - ->method('setRedirect'); - $this->responseMock->expects($this->once()) - ->method('sendHeaders'); - $bootstrap = $this->createMock(\Magento\Framework\App\Bootstrap::class); - $bootstrap->expects($this->once()) - ->method('isDeveloperMode') - ->willReturn(false); - $this->assertTrue($this->http->catchException( - $bootstrap, - new \Magento\Framework\Exception\SessionException(new \Magento\Framework\Phrase('Test')) - )); - } - - /** - * Prepares a mock of bootstrap in "not installed" state - * - * @return \PHPUnit_Framework_MockObject_MockObject - */ - private function getBootstrapNotInstalled() - { - $bootstrap = $this->createMock(\Magento\Framework\App\Bootstrap::class); - $bootstrap->expects($this->once()) - ->method('isDeveloperMode') - ->willReturn(true); - $bootstrap->expects($this->once()) - ->method('getErrorCode') - ->willReturn(Bootstrap::ERR_IS_INSTALLED); - return $bootstrap; - } } diff --git a/lib/internal/Magento/Framework/App/Test/Unit/Response/HttpTest.php b/lib/internal/Magento/Framework/App/Test/Unit/Response/HttpTest.php index efb35b7321c3b..9be68b379900a 100644 --- a/lib/internal/Magento/Framework/App/Test/Unit/Response/HttpTest.php +++ b/lib/internal/Magento/Framework/App/Test/Unit/Response/HttpTest.php @@ -290,45 +290,6 @@ public function testRepresentJson() $this->assertEquals('json_string', $this->model->getBody('default')); } - /** - * - * @expectedException \RuntimeException - * @expectedExceptionMessage ObjectManager isn't initialized - */ - public function testWakeUpWithException() - { - /* ensure that the test preconditions are met */ - $objectManagerClass = new \ReflectionClass(\Magento\Framework\App\ObjectManager::class); - $instanceProperty = $objectManagerClass->getProperty('_instance'); - $instanceProperty->setAccessible(true); - $instanceProperty->setValue(null); - - $this->model->__wakeup(); - $this->assertNull($this->cookieMetadataFactoryMock); - $this->assertNull($this->cookieManagerMock); - } - - /** - * Test for the magic method __wakeup - * - * @covers \Magento\Framework\App\Response\Http::__wakeup - */ - public function testWakeUpWith() - { - $objectManagerMock = $this->createMock(\Magento\Framework\App\ObjectManager::class); - $objectManagerMock->expects($this->once()) - ->method('create') - ->with(\Magento\Framework\Stdlib\CookieManagerInterface::class) - ->will($this->returnValue($this->cookieManagerMock)); - $objectManagerMock->expects($this->at(1)) - ->method('get') - ->with(\Magento\Framework\Stdlib\Cookie\CookieMetadataFactory::class) - ->will($this->returnValue($this->cookieMetadataFactoryMock)); - - \Magento\Framework\App\ObjectManager::setInstance($objectManagerMock); - $this->model->__wakeup(); - } - public function testSetXFrameOptions() { $value = 'DENY'; diff --git a/lib/internal/Magento/Framework/App/Test/Unit/_files/pub/errors/404.php b/lib/internal/Magento/Framework/App/Test/Unit/_files/pub/errors/404.php new file mode 100644 index 0000000000000..37121092d0ba0 --- /dev/null +++ b/lib/internal/Magento/Framework/App/Test/Unit/_files/pub/errors/404.php @@ -0,0 +1,6 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); diff --git a/lib/internal/Magento/Framework/App/Test/Unit/_files/pub/errors/report.php b/lib/internal/Magento/Framework/App/Test/Unit/_files/pub/errors/report.php new file mode 100644 index 0000000000000..37121092d0ba0 --- /dev/null +++ b/lib/internal/Magento/Framework/App/Test/Unit/_files/pub/errors/report.php @@ -0,0 +1,6 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); diff --git a/lib/internal/Magento/Framework/Console/Cli.php b/lib/internal/Magento/Framework/Console/Cli.php index 2ef41f361027e..34fd6316ce454 100644 --- a/lib/internal/Magento/Framework/Console/Cli.php +++ b/lib/internal/Magento/Framework/Console/Cli.php @@ -93,6 +93,7 @@ public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') } parent::__construct($name, $version); + $this->serviceManager->setService(\Symfony\Component\Console\Application::class, $this); } /** diff --git a/lib/internal/Magento/Framework/DB/Select.php b/lib/internal/Magento/Framework/DB/Select.php index 7399845215bb5..c0aa06f2d11da 100644 --- a/lib/internal/Magento/Framework/DB/Select.php +++ b/lib/internal/Magento/Framework/DB/Select.php @@ -42,7 +42,7 @@ class Select extends \Zend_Db_Select const STRAIGHT_JOIN = 'straightjoin'; /** - * Sql straight join + * Straight join SQL directive. */ const SQL_STRAIGHT_JOIN = 'STRAIGHT_JOIN'; @@ -400,7 +400,7 @@ public function useStraightJoin($flag = true) /** * Render STRAIGHT_JOIN clause * - * @param string $sql SQL query + * @param string $sql SQL query * @return string */ protected function _renderStraightjoin($sql) @@ -452,7 +452,7 @@ public function orderRand($field = null) /** * Render FOR UPDATE clause * - * @param string $sql SQL query + * @param string $sql SQL query * @return string */ protected function _renderForupdate($sql) @@ -509,10 +509,13 @@ public function assemble() } /** - * Sleep magic method. + * Remove links to other objects. * * @return string[] * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -532,6 +535,9 @@ public function __sleep() * * @return void * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/DB/Select/RendererProxy.php b/lib/internal/Magento/Framework/DB/Select/RendererProxy.php index b6d0803759842..f3029a7ac2bd0 100644 --- a/lib/internal/Magento/Framework/DB/Select/RendererProxy.php +++ b/lib/internal/Magento/Framework/DB/Select/RendererProxy.php @@ -56,9 +56,12 @@ public function __construct( } /** - * Sleep magic method. + * Remove links to other objects. * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -69,6 +72,9 @@ public function __sleep() * Retrieve ObjectManager from global scope * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Data/Collection.php b/lib/internal/Magento/Framework/Data/Collection.php index c44916fb5af6f..cd5e6bd0a2f59 100644 --- a/lib/internal/Magento/Framework/Data/Collection.php +++ b/lib/internal/Magento/Framework/Data/Collection.php @@ -889,6 +889,9 @@ public function hasFlag($flag) * * @return string[] * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -907,6 +910,9 @@ public function __sleep() * * @return void * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Data/Collection/AbstractDb.php b/lib/internal/Magento/Framework/Data/Collection/AbstractDb.php index 8a22c9a1ce4fc..dc4b71caf5bee 100644 --- a/lib/internal/Magento/Framework/Data/Collection/AbstractDb.php +++ b/lib/internal/Magento/Framework/Data/Collection/AbstractDb.php @@ -896,6 +896,9 @@ private function getMainTableAlias() /** * @inheritdoc * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -908,6 +911,9 @@ public function __sleep() /** * @inheritdoc * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/DataObject/Copy/Config/Data/Proxy.php b/lib/internal/Magento/Framework/DataObject/Copy/Config/Data/Proxy.php index d8bb7a06e5b7d..42d58daec2c93 100644 --- a/lib/internal/Magento/Framework/DataObject/Copy/Config/Data/Proxy.php +++ b/lib/internal/Magento/Framework/DataObject/Copy/Config/Data/Proxy.php @@ -57,9 +57,12 @@ public function __construct( } /** - * Sleep magic method. + * Remove links to other objects. * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -70,6 +73,9 @@ public function __sleep() * Retrieve ObjectManager from global scope * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/PathValidator.php b/lib/internal/Magento/Framework/Filesystem/Directory/PathValidator.php index fe0e6b37666b7..74e6cac7d77b3 100644 --- a/lib/internal/Magento/Framework/Filesystem/Directory/PathValidator.php +++ b/lib/internal/Magento/Framework/Filesystem/Directory/PathValidator.php @@ -58,7 +58,7 @@ public function validate( } if (mb_strpos($actualPath, $realDirectoryPath) !== 0 - && $path .DIRECTORY_SEPARATOR !== $realDirectoryPath + && rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR !== $realDirectoryPath ) { throw new ValidatorException( new Phrase( diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/Write.php b/lib/internal/Magento/Framework/Filesystem/Directory/Write.php index 3c6d2b7321b82..f23ed87971a39 100644 --- a/lib/internal/Magento/Framework/Filesystem/Directory/Write.php +++ b/lib/internal/Magento/Framework/Filesystem/Directory/Write.php @@ -53,9 +53,7 @@ public function __construct( protected function assertWritable($path) { if ($this->isWritable($path) === false) { - $path = (!$this->driver->isFile($path)) - ? $this->getAbsolutePath($this->path, $path) - : $this->getAbsolutePath($path); + $path = $this->getAbsolutePath($path); throw new FileSystemException(new \Magento\Framework\Phrase('The path "%1" is not writable.', [$path])); } } diff --git a/lib/internal/Magento/Framework/Filesystem/Test/Unit/Directory/PathValidatorTest.php b/lib/internal/Magento/Framework/Filesystem/Test/Unit/Directory/PathValidatorTest.php new file mode 100644 index 0000000000000..1fe4596759ebd --- /dev/null +++ b/lib/internal/Magento/Framework/Filesystem/Test/Unit/Directory/PathValidatorTest.php @@ -0,0 +1,80 @@ +<?php +/** + * Unit Test for \Magento\Framework\Filesystem\Directory\PathValidator + * + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Framework\Filesystem\Test\Unit\Directory; + +use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\Filesystem\DriverInterface; + +class PathValidatorTest extends \PHPUnit\Framework\TestCase +{ + /** + * \Magento\Framework\Filesystem\Driver + * + * @var \PHPUnit_Framework_MockObject_MockObject + */ + protected $driver; + + /** + * @var \Magento\Framework\Filesystem\Directory\PathValidator + */ + protected $pathValidator; + + /** + * Set up + */ + protected function setUp() + { + $this->driver = $this->createMock(\Magento\Framework\Filesystem\Driver\File::class); + $this->pathValidator = new \Magento\Framework\Filesystem\Directory\PathValidator( + $this->driver + ); + } + + /** + * Tear down + */ + protected function tearDown() + { + $this->pathValidator = null; + } + + /** + * @param string $directoryPath + * @param string $path + * @param string $scheme + * @param bool $absolutePath + * @param string $prefix + * @dataProvider validateDataProvider + */ + public function testValidate($directoryPath, $path, $scheme, $absolutePath, $prefix) + { + $this->driver->expects($this->exactly(2)) + ->method('getRealPathSafety') + ->willReturnMap( + [ + [$directoryPath, $directoryPath], + [null, $prefix . $directoryPath . ltrim($path, '/')], + ] + ); + + $this->assertNull( + $this->pathValidator->validate($directoryPath, $path, $scheme, $absolutePath) + ); + } + + /** + * @return array + */ + public function validateDataProvider() + { + return [ + ['/directory/path/', '/directory/path/', '/', false, '/://'], + ['/directory/path/', '/var/.regenerate', null, false, ''], + ]; + } +} diff --git a/lib/internal/Magento/Framework/GraphQl/Query/Resolver/Argument/AstConverter.php b/lib/internal/Magento/Framework/GraphQl/Query/Resolver/Argument/AstConverter.php index 0b03fc509c786..baf165b0298c3 100644 --- a/lib/internal/Magento/Framework/GraphQl/Query/Resolver/Argument/AstConverter.php +++ b/lib/internal/Magento/Framework/GraphQl/Query/Resolver/Argument/AstConverter.php @@ -54,13 +54,15 @@ public function __construct( * @param string $fieldName * @param array $arguments * @return array + * @throws \LogicException */ public function getClausesFromAst(string $fieldName, array $arguments) : array { $attributes = $this->fieldEntityAttributesPool->getEntityAttributesForEntityFromField($fieldName); $conditions = []; foreach ($arguments as $argumentName => $argument) { - if (in_array($argumentName, $attributes)) { + if (key_exists($argumentName, $attributes)) { + $argumentName = $attributes[$argumentName]['fieldName'] ?? $argumentName; foreach ($argument as $clauseType => $clause) { if (is_array($clause)) { $value = []; @@ -76,12 +78,14 @@ public function getClausesFromAst(string $fieldName, array $arguments) : array $value ); } - } else { + } elseif (is_array($argument)) { $conditions[] = $this->connectiveFactory->create( $this->getClausesFromAst($fieldName, $argument), $argumentName ); + } else { + throw new \LogicException('Attribute not found in the visible attributes list'); } } return $conditions; diff --git a/lib/internal/Magento/Framework/Interception/Interceptor.php b/lib/internal/Magento/Framework/Interception/Interceptor.php index 07600c5168181..ccc311c5b3426 100644 --- a/lib/internal/Magento/Framework/Interception/Interceptor.php +++ b/lib/internal/Magento/Framework/Interception/Interceptor.php @@ -62,6 +62,9 @@ public function ___callParent($method, array $arguments) * Calls parent class sleep if defined, otherwise provides own implementation * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -78,6 +81,9 @@ public function __sleep() * Causes Interceptor to be initialized * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php b/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php index 949e002a14208..69410b7757e44 100644 --- a/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php +++ b/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php @@ -362,6 +362,9 @@ private function populateExtensionAttributes(array $extensionAttributesData = [] /** * @inheritdoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -370,6 +373,9 @@ public function __sleep() /** * @inheritdoc + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Model/AbstractModel.php b/lib/internal/Magento/Framework/Model/AbstractModel.php index 8018c6176390f..534c25fce8d42 100644 --- a/lib/internal/Magento/Framework/Model/AbstractModel.php +++ b/lib/internal/Magento/Framework/Model/AbstractModel.php @@ -220,6 +220,9 @@ protected function _init($resourceModel) * Remove unneeded properties from serialization * * @return string[] + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -244,6 +247,9 @@ public function __sleep() * Init not serializable fields * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Model/ResourceModel/Db/AbstractDb.php b/lib/internal/Magento/Framework/Model/ResourceModel/Db/AbstractDb.php index fc0edf931fa9c..0b44dc60c6504 100644 --- a/lib/internal/Magento/Framework/Model/ResourceModel/Db/AbstractDb.php +++ b/lib/internal/Magento/Framework/Model/ResourceModel/Db/AbstractDb.php @@ -157,6 +157,9 @@ public function __construct( * Provide variables to serialize * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -169,6 +172,9 @@ public function __sleep() * Restore global dependencies * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { @@ -220,9 +226,10 @@ protected function _setResource($connections, $tables = null) } /** - * Set main entity table name and primary key field name + * Main table setter. * - * If field name is omitted {table_name}_id will be used + * Set main entity table name and primary key field name. + * If field name is omitted {table_name}_id will be used. * * @param string $mainTable * @param string|null $idFieldName @@ -255,7 +262,10 @@ public function getIdFieldName() } /** - * Returns main table name - extracted from "module/table" style and validated by db adapter + * Main table getter. + * + * Returns main table name - extracted from "module/table" style and + * validated by db adapter. * * @throws LocalizedException * @return string @@ -544,7 +554,7 @@ protected function _prepareDataForSave(\Magento\Framework\Model\AbstractModel $o } /** - * Check that model data fields that can be saved has really changed comparing with origData + * Check that model data fields that can be saved has really changed comparing with origData. * * @param \Magento\Framework\Model\AbstractModel $object * @return bool @@ -784,6 +794,24 @@ protected function saveNewObject(\Magento\Framework\Model\AbstractModel $object) } } + /** + * Check if column data type is numeric + * + * Based on column description + * + * @param array $columnDescription + * @return bool + */ + private function isNumericValue(array $columnDescription): bool + { + $result = true; + if (!empty($columnDescription['DATA_TYPE']) + && in_array($columnDescription['DATA_TYPE'], ['tinyint', 'smallint', 'mediumint', 'int', 'bigint'])) { + $result = false; + } + return $result; + } + /** * Update existing object * @@ -793,29 +821,35 @@ protected function saveNewObject(\Magento\Framework\Model\AbstractModel $object) */ protected function updateObject(\Magento\Framework\Model\AbstractModel $object) { - $condition = $this->getConnection()->quoteInto($this->getIdFieldName() . '=?', $object->getId()); + $connection = $this->getConnection(); + $tableDescription = $connection->describeTable($this->getMainTable()); + $preparedValue = $connection->prepareColumnValue($tableDescription[$this->getIdFieldName()], $object->getId()); + $condition = (!$this->isNumericValue($tableDescription[$this->getIdFieldName()])) + ? sprintf('%s=%d', $this->getIdFieldName(), $preparedValue) + : $connection->quoteInto($this->getIdFieldName() . '=?', $preparedValue); + /** * Not auto increment primary key support */ if ($this->_isPkAutoIncrement) { $data = $this->prepareDataForUpdate($object); if (!empty($data)) { - $this->getConnection()->update($this->getMainTable(), $data, $condition); + $connection->update($this->getMainTable(), $data, $condition); } } else { - $select = $this->getConnection()->select()->from( + $select = $connection->select()->from( $this->getMainTable(), [$this->getIdFieldName()] )->where( $condition ); - if ($this->getConnection()->fetchOne($select) !== false) { + if ($connection->fetchOne($select) !== false) { $data = $this->prepareDataForUpdate($object); if (!empty($data)) { - $this->getConnection()->update($this->getMainTable(), $data, $condition); + $connection->update($this->getMainTable(), $data, $condition); } } else { - $this->getConnection()->insert( + $connection->insert( $this->getMainTable(), $this->_prepareDataForSave($object) ); diff --git a/lib/internal/Magento/Framework/Model/ResourceModel/Db/Collection/AbstractCollection.php b/lib/internal/Magento/Framework/Model/ResourceModel/Db/Collection/AbstractCollection.php index cba5f133f53c8..1186326ab6525 100644 --- a/lib/internal/Magento/Framework/Model/ResourceModel/Db/Collection/AbstractCollection.php +++ b/lib/internal/Magento/Framework/Model/ResourceModel/Db/Collection/AbstractCollection.php @@ -607,6 +607,9 @@ public function save() /** * @inheritdoc * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -619,6 +622,9 @@ public function __sleep() /** * @inheritdoc * @since 100.0.11 + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Model/Test/Unit/ResourceModel/Db/AbstractDbTest.php b/lib/internal/Magento/Framework/Model/Test/Unit/ResourceModel/Db/AbstractDbTest.php index b69f50cf4f341..2a87cd774332a 100644 --- a/lib/internal/Magento/Framework/Model/Test/Unit/ResourceModel/Db/AbstractDbTest.php +++ b/lib/internal/Magento/Framework/Model/Test/Unit/ResourceModel/Db/AbstractDbTest.php @@ -425,16 +425,15 @@ public function testPrepareDataForUpdate() $connectionMock = $this->getMockBuilder(AdapterInterface::class) ->setMethods(['save']) ->getMockForAbstractClass(); + $context = (new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this))->getObject( \Magento\Framework\Model\Context::class ); $registryMock = $this->createMock(\Magento\Framework\Registry::class); - $resourceMock = $this->createPartialMock(AbstractDb::class, [ - '_construct', - 'getConnection', - '__wakeup', - 'getIdFieldName' - ]); + $resourceMock = $this->createPartialMock( + AbstractDb::class, + ['_construct', 'getConnection', '__wakeup', 'getIdFieldName'] + ); $connectionInterfaceMock = $this->createMock(AdapterInterface::class); $resourceMock->expects($this->any()) ->method('getConnection') @@ -453,6 +452,7 @@ public function testPrepareDataForUpdate() $this->_resourcesMock->expects($this->any())->method('getTableName')->with($data)->will( $this->returnValue('tableName') ); + $mainTableReflection = new \ReflectionProperty( AbstractDb::class, '_mainTable' @@ -467,6 +467,13 @@ public function testPrepareDataForUpdate() $idFieldNameReflection->setValue($this->_model, 'idFieldName'); $connectionMock->expects($this->any())->method('save')->with('tableName', 'idFieldName'); $connectionMock->expects($this->any())->method('quoteInto')->will($this->returnValue('idFieldName')); + $connectionMock->expects($this->any()) + ->method('describeTable') + ->with('tableName') + ->willReturn(['idFieldName' => []]); + $connectionMock->expects($this->any()) + ->method('prepareColumnValue') + ->willReturn(0); $abstractModelMock->setIdFieldName('id'); $abstractModelMock->setData( [ diff --git a/lib/internal/Magento/Framework/Mview/Config/Data/Proxy.php b/lib/internal/Magento/Framework/Mview/Config/Data/Proxy.php index 470ba16bdd40c..cfa79f3e7ee60 100644 --- a/lib/internal/Magento/Framework/Mview/Config/Data/Proxy.php +++ b/lib/internal/Magento/Framework/Mview/Config/Data/Proxy.php @@ -55,9 +55,12 @@ public function __construct( } /** - * Sleep magic method. + * Remove links to objects. * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -68,6 +71,9 @@ public function __sleep() * Retrieve ObjectManager from global scope * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/Search/Adapter/Mysql/TemporaryStorage.php b/lib/internal/Magento/Framework/Search/Adapter/Mysql/TemporaryStorage.php index 60ee2d5706067..7f8ef8c422b92 100644 --- a/lib/internal/Magento/Framework/Search/Adapter/Mysql/TemporaryStorage.php +++ b/lib/internal/Magento/Framework/Search/Adapter/Mysql/TemporaryStorage.php @@ -152,7 +152,7 @@ private function createTemporaryTable() self::FIELD_SCORE, Table::TYPE_DECIMAL, [32, 16], - ['unsigned' => true, 'nullable' => false], + ['unsigned' => true, 'nullable' => true], 'Score' ); $table->setOption('type', 'memory'); diff --git a/lib/internal/Magento/Framework/Search/Request/Builder.php b/lib/internal/Magento/Framework/Search/Request/Builder.php index 74bc65010a934..0cf959b657c76 100644 --- a/lib/internal/Magento/Framework/Search/Request/Builder.php +++ b/lib/internal/Magento/Framework/Search/Request/Builder.php @@ -6,6 +6,7 @@ namespace Magento\Framework\Search\Request; +use Magento\Framework\Api\SortOrder; use Magento\Framework\ObjectManagerInterface; use Magento\Framework\Phrase; use Magento\Framework\Search\RequestInterface; @@ -173,7 +174,13 @@ public function create() private function prepareSorts(array $sorts) { $sortData = []; - foreach ($sorts as $sortField => $direction) { + foreach ($sorts as $sortField => $sort) { + if ($sort instanceof SortOrder) { + $sortField = $sort->getField(); + $direction = $sort->getDirection(); + } else { + $direction = $sort; + } $sortData[] = [ 'field' => $sortField, 'direction' => $direction, diff --git a/lib/internal/Magento/Framework/Search/Test/Unit/Adapter/Mysql/TemporaryStorageTest.php b/lib/internal/Magento/Framework/Search/Test/Unit/Adapter/Mysql/TemporaryStorageTest.php index d71bd447cc578..cd7c0debc76c7 100644 --- a/lib/internal/Magento/Framework/Search/Test/Unit/Adapter/Mysql/TemporaryStorageTest.php +++ b/lib/internal/Magento/Framework/Search/Test/Unit/Adapter/Mysql/TemporaryStorageTest.php @@ -176,7 +176,7 @@ private function createTemporaryTable($persistentConnection = true) if ($persistentConnection) { $this->adapter->expects($this->once()) ->method('dropTemporaryTable'); - $tableInteractionCount += 1; + $tableInteractionCount++; } $table->expects($this->at($tableInteractionCount)) ->method('addColumn') @@ -187,14 +187,14 @@ private function createTemporaryTable($persistentConnection = true) ['unsigned' => true, 'nullable' => false, 'primary' => true], 'Entity ID' ); - $tableInteractionCount += 1; + $tableInteractionCount++; $table->expects($this->at($tableInteractionCount)) ->method('addColumn') ->with( 'score', Table::TYPE_DECIMAL, [32, 16], - ['unsigned' => true, 'nullable' => false], + ['unsigned' => true, 'nullable' => true], 'Score' ); $table->expects($this->once()) diff --git a/lib/internal/Magento/Framework/Stdlib/DateTime/Timezone.php b/lib/internal/Magento/Framework/Stdlib/DateTime/Timezone.php index 014854ddd584f..118a3e053bd79 100644 --- a/lib/internal/Magento/Framework/Stdlib/DateTime/Timezone.php +++ b/lib/internal/Magento/Framework/Stdlib/DateTime/Timezone.php @@ -195,16 +195,16 @@ public function date($date = null, $locale = null, $useTimezone = true, $include */ public function scopeDate($scope = null, $date = null, $includeTime = false) { - $timezone = $this->_scopeConfig->getValue($this->getDefaultTimezonePath(), $this->_scopeType, $scope); + $timezone = new \DateTimeZone( + $this->_scopeConfig->getValue($this->getDefaultTimezonePath(), $this->_scopeType, $scope) + ); switch (true) { case (empty($date)): - $date = new \DateTime('now', new \DateTimeZone($timezone)); + $date = new \DateTime('now', $timezone); break; case ($date instanceof \DateTime): - $date = $date->setTimezone(new \DateTimeZone($timezone)); - break; case ($date instanceof \DateTimeImmutable): - $date = new \DateTime($date->format('Y-m-d H:i:s'), $date->getTimezone()); + $date = $date->setTimezone($timezone); break; case (!is_numeric($date)): $timeType = $includeTime ? \IntlDateFormatter::SHORT : \IntlDateFormatter::NONE; @@ -212,14 +212,20 @@ public function scopeDate($scope = null, $date = null, $includeTime = false) $this->_localeResolver->getLocale(), \IntlDateFormatter::SHORT, $timeType, - new \DateTimeZone($timezone) + $timezone ); - $date = $formatter->parse($date) ?: (new \DateTime($date))->getTimestamp(); - $date = (new \DateTime(null, new \DateTimeZone($timezone)))->setTimestamp($date); + $timestamp = $formatter->parse($date); + $date = $timestamp + ? (new \DateTime('@' . $timestamp))->setTimezone($timezone) + : new \DateTime($date, $timezone); + break; + case (is_numeric($date)): + $date = new \DateTime('@' . $date); + $date = $date->setTimezone($timezone); break; default: - $date = new \DateTime(is_numeric($date) ? '@' . $date : $date); - $date->setTimezone(new \DateTimeZone($timezone)); + $date = new \DateTime($date, $timezone); + break; } if (!$includeTime) { diff --git a/lib/internal/Magento/Framework/Stdlib/Test/Unit/DateTime/TimezoneTest.php b/lib/internal/Magento/Framework/Stdlib/Test/Unit/DateTime/TimezoneTest.php index 3d7d14a394629..53980e574c267 100644 --- a/lib/internal/Magento/Framework/Stdlib/Test/Unit/DateTime/TimezoneTest.php +++ b/lib/internal/Magento/Framework/Stdlib/Test/Unit/DateTime/TimezoneTest.php @@ -22,6 +22,16 @@ class TimezoneTest extends \PHPUnit\Framework\TestCase */ private $defaultTimeZone; + /** + * @var string + */ + private $scopeType; + + /** + * @var string + */ + private $defaultTimezonePath; + /** * @var ObjectManager */ @@ -49,6 +59,8 @@ protected function setUp() { $this->defaultTimeZone = date_default_timezone_get(); date_default_timezone_set('UTC'); + $this->scopeType = 'store'; + $this->defaultTimezonePath = 'default/timezone/path'; $this->objectManager = new ObjectManager($this); $this->scopeResolver = $this->getMockBuilder(ScopeResolverInterface::class)->getMock(); @@ -86,9 +98,10 @@ public function testDateIncludeTime($date, $locale, $includeTime, $expectedTimes /** * DataProvider for testDateIncludeTime + * * @return array */ - public function dateIncludeTimeDataProvider() + public function dateIncludeTimeDataProvider(): array { return [ 'Parse d/m/y date without time' => [ @@ -133,9 +146,10 @@ public function testConvertConfigTimeToUtc($date, $configuredTimezone, $expected /** * Data provider for testConvertConfigTimeToUtc + * * @return array */ - public function getConvertConfigTimeToUtcFixtures() + public function getConvertConfigTimeToUtcFixtures(): array { return [ 'string' => [ @@ -181,9 +195,10 @@ public function testDate() /** * DataProvider for testDate + * * @return array */ - private function getDateFixtures() + private function getDateFixtures(): array { return [ 'now_datetime_utc' => [ @@ -239,29 +254,71 @@ private function getTimezone() return new Timezone( $this->scopeResolver, $this->localeResolver, - $this->getMockBuilder(DateTime::class)->getMock(), + $this->createMock(DateTime::class), $this->scopeConfig, - '', - '' + $this->scopeType, + $this->defaultTimezonePath ); } /** * @param string $configuredTimezone + * @param string|null $scope */ - private function scopeConfigWillReturnConfiguredTimezone($configuredTimezone) + private function scopeConfigWillReturnConfiguredTimezone(string $configuredTimezone, string $scope = null) { - $this->scopeConfig->method('getValue')->with('', '', null)->willReturn($configuredTimezone); + $this->scopeConfig->expects($this->atLeastOnce()) + ->method('getValue') + ->with($this->defaultTimezonePath, $this->scopeType, $scope) + ->willReturn($configuredTimezone); } - public function testCheckIfScopeDateSetsTimeZone() + /** + * @dataProvider scopeDateDataProvider + * @param \DateTimeInterface|string|int $date + * @param string $timezone + * @param string $locale + * @param string $expectedDate + */ + public function testScopeDate($date, string $timezone, string $locale, string $expectedDate) { - $scopeDate = new \DateTime('now', new \DateTimeZone('America/Vancouver')); - $this->scopeConfig->method('getValue')->willReturn('America/Vancouver'); + $scopeCode = 'test'; - $this->assertEquals( - $scopeDate->getTimezone(), - $this->getTimezone()->scopeDate(0, $scopeDate->getTimestamp())->getTimezone() - ); + $this->scopeConfigWillReturnConfiguredTimezone($timezone, $scopeCode); + $this->localeResolver->method('getLocale') + ->willReturn($locale); + + $scopeDate = $this->getTimezone()->scopeDate($scopeCode, $date, true); + $this->assertEquals($expectedDate, $scopeDate->format('Y-m-d H:i:s')); + $this->assertEquals($timezone, $scopeDate->getTimezone()->getName()); + } + + /** + * @return array + */ + public function scopeDateDataProvider(): array + { + $utcTz = new \DateTimeZone('UTC'); + + return [ + ['2018-10-20 00:00:00', 'UTC', 'en_US', '2018-10-20 00:00:00'], + ['2018-10-20 00:00:00', 'America/Los_Angeles', 'en_US', '2018-10-20 00:00:00'], + ['2018-10-20 00:00:00', 'Asia/Qatar', 'en_US', '2018-10-20 00:00:00'], + ['10/20/18 00:00', 'UTC', 'en_US', '2018-10-20 00:00:00'], + ['10/20/18 00:00', 'America/Los_Angeles', 'en_US', '2018-10-20 00:00:00'], + ['10/20/18 00:00', 'Asia/Qatar', 'en_US', '2018-10-20 00:00:00'], + ['20/10/18 00:00', 'UTC', 'fr_FR', '2018-10-20 00:00:00'], + ['20/10/18 00:00', 'America/Los_Angeles', 'fr_FR', '2018-10-20 00:00:00'], + ['20/10/18 00:00', 'Asia/Qatar', 'fr_FR', '2018-10-20 00:00:00'], + [1539993600, 'UTC', 'en_US', '2018-10-20 00:00:00'], + [1539993600, 'America/Los_Angeles', 'en_US', '2018-10-19 17:00:00'], + [1539993600, 'Asia/Qatar', 'en_US', '2018-10-20 03:00:00'], + [new \DateTime('2018-10-20', $utcTz), 'UTC', 'en_US', '2018-10-20 00:00:00'], + [new \DateTime('2018-10-20', $utcTz), 'America/Los_Angeles', 'en_US', '2018-10-19 17:00:00'], + [new \DateTime('2018-10-20', $utcTz), 'Asia/Qatar', 'en_US', '2018-10-20 03:00:00'], + [new \DateTimeImmutable('2018-10-20', $utcTz), 'UTC', 'en_US', '2018-10-20 00:00:00'], + [new \DateTimeImmutable('2018-10-20', $utcTz), 'America/Los_Angeles', 'en_US', '2018-10-19 17:00:00'], + [new \DateTimeImmutable('2018-10-20', $utcTz), 'Asia/Qatar', 'en_US', '2018-10-20 03:00:00'], + ]; } } diff --git a/lib/internal/Magento/Framework/Translate/Inline/Proxy.php b/lib/internal/Magento/Framework/Translate/Inline/Proxy.php index d2b0468bebde9..62b3352e11b1a 100644 --- a/lib/internal/Magento/Framework/Translate/Inline/Proxy.php +++ b/lib/internal/Magento/Framework/Translate/Inline/Proxy.php @@ -55,9 +55,12 @@ public function __construct( } /** - * Sleep magic method. + * Remove links to other objects. * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -68,6 +71,9 @@ public function __sleep() * Retrieve ObjectManager from global scope * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/internal/Magento/Framework/View/Layout/Proxy.php b/lib/internal/Magento/Framework/View/Layout/Proxy.php index ec5ce761154ed..2ee50f8d14bc3 100644 --- a/lib/internal/Magento/Framework/View/Layout/Proxy.php +++ b/lib/internal/Magento/Framework/View/Layout/Proxy.php @@ -57,9 +57,12 @@ public function __construct( } /** - * Sleep magic method. + * Remove links to objects. * * @return array + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __sleep() { @@ -70,6 +73,9 @@ public function __sleep() * Retrieve ObjectManager from global scope * * @return void + * + * @SuppressWarnings(PHPMD.SerializationAware) + * @deprecated Do not use PHP serialization. */ public function __wakeup() { diff --git a/lib/web/mage/adminhtml/browser.js b/lib/web/mage/adminhtml/browser.js index 9019e941bc586..137ad5541a87f 100644 --- a/lib/web/mage/adminhtml/browser.js +++ b/lib/web/mage/adminhtml/browser.js @@ -52,8 +52,12 @@ define([ content = '<div class="popup-window" id="' + windowId + '"></div>', self = this; - if (this.modalLoaded === true) { - if (options && typeof options.closed !== 'undefined') { + if (this.modalLoaded === true && + options && + self.targetElementId && + self.targetElementId === options.targetElementId + ) { + if (typeof options.closed !== 'undefined') { this.modal.modal('option', 'closed', options.closed); } this.modal.modal('openModal'); @@ -85,6 +89,7 @@ define([ }).done(function (data) { self.modal.html(data).trigger('contentUpdated'); self.modalLoaded = true; + self.targetElementId = options.targetElementId; }); }, diff --git a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js index 227ee10a73e64..4dafc845309cb 100644 --- a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js +++ b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js @@ -377,6 +377,7 @@ define([ var typeTitle = this.translate('Select Images'), storeId = this.config['store_id'] !== null ? this.config['store_id'] : 0, frameDialog = jQuery('div.mce-container[role="dialog"]'), + self = this, wUrl = this.config['files_browser_window_url'] + 'target_element_id/' + this.getId() + '/' + 'store/' + storeId + '/'; @@ -393,14 +394,17 @@ define([ require(['mage/adminhtml/browser'], function () { MediabrowserUtility.openDialog(wUrl, false, false, typeTitle, { - /** - * Closed. - */ - closed: function () { - frameDialog.show(); - jQuery('#mce-modal-block').show(); + /** + * Closed. + */ + closed: function () { + frameDialog.show(); + jQuery('#mce-modal-block').show(); + }, + + targetElementId: self.activeEditor() ? self.activeEditor().id : null } - }); + ); }); }, diff --git a/lib/web/mage/adminhtml/wysiwyg/widget.js b/lib/web/mage/adminhtml/wysiwyg/widget.js index 5c1a77b6382a2..f39fc22034104 100644 --- a/lib/web/mage/adminhtml/wysiwyg/widget.js +++ b/lib/web/mage/adminhtml/wysiwyg/widget.js @@ -223,7 +223,9 @@ define([ * @param {*} containerId */ enableOptionsContainer: function (containerId) { - $$('#' + containerId + ' .widget-option').each(function (e) { + var container = $(containerId); + + container.select('.widget-option').each(function (e) { e.removeClassName('skip-submit'); if (e.hasClassName('obligatory')) { @@ -231,18 +233,19 @@ define([ e.addClassName('required-entry'); } }); - $(containerId).removeClassName('no-display'); + container.removeClassName('no-display'); }, /** * @param {*} containerId */ disableOptionsContainer: function (containerId) { + var container = $(containerId); - if ($(containerId).hasClassName('no-display')) { + if (container.hasClassName('no-display')) { return; } - $$('#' + containerId + ' .widget-option').each(function (e) { + container.select('.widget-option').each(function (e) { // Avoid submitting fields of unactive container if (!e.hasClassName('skip-submit')) { e.addClassName('skip-submit'); @@ -253,7 +256,7 @@ define([ e.addClassName('obligatory'); } }); - $(containerId).addClassName('no-display'); + container.addClassName('no-display'); }, /** @@ -439,7 +442,7 @@ define([ i = 0; Form.getElements($(this.formEl)).each(function (e) { - if (!e.hasClassName('skip-submit')) { + if (jQuery(e).closest('.skip-submit, .no-display').length === 0) { formElements[i] = e; i++; } diff --git a/lib/web/mage/validation.js b/lib/web/mage/validation.js index b284f0002bc66..2ec39efcc283e 100644 --- a/lib/web/mage/validation.js +++ b/lib/web/mage/validation.js @@ -559,7 +559,7 @@ /* eslint-enable max-len */ 'pattern': [ function (value, element, param) { - return this.optional(element) || param.test(value); + return this.optional(element) || new RegExp(param).test(value); }, $.mage.__('Invalid format.') ], @@ -1925,7 +1925,6 @@ * @protected */ _create: function () { - this._prepareArrayInputs(); this.validate = this.element.validate(this.options); // ARIA (adding aria-required attribute) @@ -1938,50 +1937,6 @@ this._listenFormValidate(); }, - /** - * Validation creation. - * - * @protected - */ - _prepareArrayInputs: function () { - /* Store original names for array inputs */ - var originalElements = [], - originalSubmitHandler = this.options.submitHandler; - - /* For all array inputs, assign index so that validation is proper */ - this.element.find('[name$="[]"]').each(function (key, input) { - var originalName, name; - - input = $(input); - originalName = input.attr('name'); - name = originalName.replace('[]', '[' + key + ']'); - $(input).attr('name', name); - $(input).attr('orig-name', originalName); - originalElements.push({ - element: $(input), - name: originalName - }); - }); - - if (originalElements.length) { - /** - * Before submitting the actual form, remove the previously assigned indices - * @param {Object} form - */ - this.options.submitHandler = function (form) { - originalElements.forEach(function (element) { - element.element.attr('name', element.name); - element.element.removeAttr('orig-name'); - }); - - console.error(this.submit); - - /* Call the originalSubmitHandler if it's a function */ - typeof originalSubmitHandler === 'function' ? originalSubmitHandler(form) : form.submit(); - }; - } - }, - /** * Validation listening. * diff --git a/lib/web/mage/validation/validation.js b/lib/web/mage/validation/validation.js index 69cb984b0d82d..0f2c4c06b119b 100644 --- a/lib/web/mage/validation/validation.js +++ b/lib/web/mage/validation/validation.js @@ -49,23 +49,18 @@ 'validate-one-checkbox-required-by-name': [ function (value, element, params) { var checkedCount = 0, - selector, - container, - origNameSelector, - nameSelector; + container; if (element.type === 'checkbox') { - /* If orig-name attribute is present, use it for validation. Else use name */ - origNameSelector = '[orig-name="' + element.getAttribute('orig-name') + '"]'; - nameSelector = '[name="' + element.name + '"]'; - selector = element.getAttribute('orig-name') ? origNameSelector : nameSelector; - $(selector).each(function () { - if ($(this).is(':checked')) { - checkedCount += 1; - - return false; + $('[name="' + element.name + '"]').each( + function () { + if ($(this).is(':checked')) { + checkedCount += 1; + + return false; + } } - }); + ); } container = '#' + params; diff --git a/pub/errors/local.xml.sample b/pub/errors/local.xml.sample index b89dbb3fee8f5..c5c35559bd6d0 100644 --- a/pub/errors/local.xml.sample +++ b/pub/errors/local.xml.sample @@ -27,5 +27,22 @@ value "delete" is for cleaning --> <trash>leave</trash> + <!-- + The number of subdirectories that will be created to save the report. + Valid Values: Integers from 0 to 32 + + Example: + If we have the report name as hash sha256('') = 44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7 + + dir_nesting_level=0 -> <magento_root>/var/report/44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7 + dir_nesting_level=1 -> <magento_root>/var/report/44/44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7 + dir_nesting_level=2 -> <magento_root>/var/report/44/ff/44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7 + ... + dir_nesting_level=32 -> <magento_root>/var/report/44/ff/b1/08/7a/44/e6/1b/01/8b/3c/de/e7/22/84/d0/17/f2/2e/52/75/5c/24/e5/c8/5c/ba/c1/64/7a/e7/a7/44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7 + + If you use an environment variable MAGE_ERROR_REPORT_DIR_NESTING_LEVEL, this property will be ignored. + Environment variable has a higher priority. + --> + <dir_nesting_level>0</dir_nesting_level> </report> </config> diff --git a/pub/errors/processor.php b/pub/errors/processor.php index ab21f791bc021..7cab4add51a92 100644 --- a/pub/errors/processor.php +++ b/pub/errors/processor.php @@ -8,11 +8,15 @@ namespace Magento\Framework\Error; use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Escaper; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\Response\Http; /** * Error processor * * @SuppressWarnings(PHPMD.TooManyFields) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * phpcs:ignoreFile */ class Processor @@ -21,6 +25,7 @@ class Processor const MAGE_ERRORS_DESIGN_XML = 'design.xml'; const DEFAULT_SKIN = 'default'; const ERROR_DIR = 'pub/errors'; + const NUMBER_SYMBOLS_IN_SUBDIR_NAME = 2; /** * Page title @@ -67,7 +72,7 @@ class Processor /** * Report ID * - * @var int + * @var string */ public $reportId; @@ -128,7 +133,7 @@ class Processor /** * Http response * - * @var \Magento\Framework\App\Response\Http + * @var Http */ protected $_response; @@ -140,15 +145,25 @@ class Processor private $serializer; /** - * @param \Magento\Framework\App\Response\Http $response + * @var Escaper + */ + private $escaper; + + /** + * @param Http $response * @param Json $serializer + * @param Escaper $escaper */ - public function __construct(\Magento\Framework\App\Response\Http $response, Json $serializer = null) - { + public function __construct( + Http $response, + Json $serializer = null, + Escaper $escaper = null + ) { $this->_response = $response; $this->_errorDir = __DIR__ . '/'; $this->_reportDir = dirname(dirname($this->_errorDir)) . '/var/report/'; - $this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance()->get(Json::class); + $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class); + $this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class); if (!empty($_SERVER['SCRIPT_NAME'])) { if (in_array(basename($_SERVER['SCRIPT_NAME'], '.php'), ['404', '503', 'report'])) { @@ -158,11 +173,6 @@ public function __construct(\Magento\Framework\App\Response\Http $response, Json } } - $reportId = (isset($_GET['id'])) ? (int)$_GET['id'] : null; - if ($reportId) { - $this->loadReport($reportId); - } - $this->_indexDir = $this->_getIndexDir(); $this->_root = is_dir($this->_indexDir . 'app'); @@ -170,6 +180,9 @@ public function __construct(\Magento\Framework\App\Response\Http $response, Json if (isset($_GET['skin'])) { $this->_setSkin($_GET['skin']); } + if (isset($_GET['id'])) { + $this->loadReport($_GET['id']); + } } /** @@ -371,6 +384,9 @@ protected function _prepareConfig() if ((string)$local->report->trash) { $config->trash = $local->report->trash; } + if ($local->report->dir_nesting_level) { + $config->dir_nesting_level = (int)$local->report->dir_nesting_level; + } if ((string)$local->skin) { $this->_setSkin((string)$local->skin, $config); } @@ -467,7 +483,7 @@ protected function _setReportData($reportData) $this->reportData['url'] = $this->getHostUrl() . $reportData['url']; } - if ($this->reportData['script_name']) { + if (isset($this->reportData['script_name'])) { $this->_scriptName = $this->reportData['script_name']; } } @@ -478,18 +494,20 @@ protected function _setReportData($reportData) * @param array $reportData * @return string */ - public function saveReport($reportData) + public function saveReport(array $reportData): string { - $this->reportData = $reportData; - $this->reportId = abs((int)(microtime(true) * random_int(100, 1000))); - $this->_reportFile = $this->_reportDir . '/' . $this->reportId; - $this->_setReportData($reportData); - - if (!file_exists($this->_reportDir)) { - @mkdir($this->_reportDir, 0777, true); + $this->reportId = $reportData['report_id']; + $this->_reportFile = $this->getReportPath( + $this->getReportDirNestingLevel($this->reportId), + $this->reportId + ); + $reportDirName = dirname($this->_reportFile); + if (!file_exists($reportDirName)) { + @mkdir($reportDirName, 0777, true); } + $this->_setReportData($reportData); - @file_put_contents($this->_reportFile, $this->serializer->serialize($reportData)); + @file_put_contents($this->_reportFile, $this->serializer->serialize($reportData). PHP_EOL); if (isset($reportData['skin']) && self::DEFAULT_SKIN != $reportData['skin']) { $this->_setSkin($reportData['skin']); @@ -502,19 +520,117 @@ public function saveReport($reportData) /** * Get report * - * @param int $reportId + * @param string $reportId * @return void */ public function loadReport($reportId) { - $this->reportId = $reportId; - $this->_reportFile = $this->_reportDir . '/' . $reportId; + try { + if (!$this->isReportIdValid($reportId)) { + throw new \RuntimeException("Report Id is invalid"); + } + $reportFile = $this->findReportFile($reportId); + if (!is_readable($reportFile)) { + throw new \RuntimeException("Report file cannot be read"); + } + $this->reportId = $reportId; + $this->_reportFile = $reportFile; + $this->_setReportData($this->serializer->unserialize(file_get_contents($this->_reportFile))); + } catch (\RuntimeException $e) { + $this->redirectToBaseUrl(); + } + } + + /** + * Searches for the report file and returns the path to it + * + * @param string $reportId + * @return string + * @throws \RuntimeException + */ + private function findReportFile(string $reportId): string + { + $reportFile = $this->getReportPath( + $this->getReportDirNestingLevel($reportId), + $reportId + ); + if (file_exists($reportFile)) { + return $reportFile; + } + $maxReportDirNestingLevel = $this->getMaxReportDirNestingLevel($reportId); + for ($i = 0; $i <= $maxReportDirNestingLevel; $i++) { + $reportFile = $this->getReportPath($i, $reportId); + if (file_exists($reportFile)) { + return $reportFile; + } + } + throw new \RuntimeException("Report file not found"); + } + + /** + * Redirect to a base url + * @return void + */ + private function redirectToBaseUrl() + { + header("Location: " . $this->getBaseUrl()); + die(); + } + + /** + * Checks report id + * + * @param string $reportId + * @return bool + */ + private function isReportIdValid(string $reportId): bool + { + return (bool)preg_match('/[a-fA-F0-9]{64}/', $reportId); + } + + /** + * Get path to reports + * + * @param integer $reportDirNestingLevel + * @param string $reportId + * @return string + */ + private function getReportPath(int $reportDirNestingLevel, string $reportId): string + { + $reportDirPath = $this->_reportDir; + for ($i = 0, $j = 0; $j < $reportDirNestingLevel; $i += 2, $j++) { + $reportDirPath .= $reportId[$i] . $reportId[$i + 1] . '/'; + } + return $reportDirPath . $reportId; + } - if (!file_exists($this->_reportFile) || !is_readable($this->_reportFile)) { - header("Location: " . $this->getBaseUrl()); - die(); + /** + * Returns nesting Level for the report files + * + * @var $reportId + * @return int + */ + private function getReportDirNestingLevel(string $reportId): int + { + $envName = 'MAGE_ERROR_REPORT_DIR_NESTING_LEVEL'; + $value = $_ENV[$envName] ?? getenv($envName); + if(false === $value && property_exists($this->_config, 'dir_nesting_level')) { + $value = $this->_config->dir_nesting_level; } - $this->_setReportData($this->serializer->unserialize(file_get_contents($this->_reportFile))); + $value = (int)$value; + $maxValue= $this->getMaxReportDirNestingLevel($reportId); + return 0 < $value && $maxValue >= $value ? $value : 0; + } + + /** + * Returns maximum nesting level directories of report files + * + * @param string $reportId + * @return integer + */ + private function getMaxReportDirNestingLevel(string $reportId): int + { + return (int)floor(strlen($reportId) / self::NUMBER_SYMBOLS_IN_SUBDIR_NAME); } /** @@ -528,11 +644,16 @@ public function sendReport() { $this->pageTitle = 'Error Submission Form'; - $this->postData['firstName'] = (isset($_POST['firstname'])) ? trim(htmlspecialchars($_POST['firstname'])) : ''; - $this->postData['lastName'] = (isset($_POST['lastname'])) ? trim(htmlspecialchars($_POST['lastname'])) : ''; - $this->postData['email'] = (isset($_POST['email'])) ? trim(htmlspecialchars($_POST['email'])) : ''; - $this->postData['telephone'] = (isset($_POST['telephone'])) ? trim(htmlspecialchars($_POST['telephone'])) : ''; - $this->postData['comment'] = (isset($_POST['comment'])) ? trim(htmlspecialchars($_POST['comment'])) : ''; + $this->postData['firstName'] = (isset($_POST['firstname'])) + ? trim($this->escaper->escapeHtml($_POST['firstname'])) : ''; + $this->postData['lastName'] = (isset($_POST['lastname'])) + ? trim($this->escaper->escapeHtml($_POST['lastname'])) : ''; + $this->postData['email'] = (isset($_POST['email'])) + ? trim($this->escaper->escapeHtml($_POST['email'])) : ''; + $this->postData['telephone'] = (isset($_POST['telephone'])) + ? trim($this->escaper->escapeHtml($_POST['telephone'])) : ''; + $this->postData['comment'] = (isset($_POST['comment'])) + ? trim($this->escaper->escapeHtml($_POST['comment'])) : ''; if (isset($_POST['submit'])) { if ($this->_validate()) { diff --git a/setup/performance-toolkit/benchmark.jmx b/setup/performance-toolkit/benchmark.jmx index d53d59c4f7de8..00ae0bdbb8a47 100644 --- a/setup/performance-toolkit/benchmark.jmx +++ b/setup/performance-toolkit/benchmark.jmx @@ -574,11 +574,21 @@ <stringProp name="Argument.value">${__P(graphqlUpdateConfigurableProductQtyInCartPercentage,0)}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> + <elementProp name="graphqlUpdateConfigurableProductQtyInCartWithPricesPercentage" elementType="Argument"> + <stringProp name="Argument.name">graphqlUpdateConfigurableProductQtyInCartWithPricesPercentage</stringProp> + <stringProp name="Argument.value">${__P(graphqlUpdateConfigurableProductQtyInCartWithPricesPercentage,0)}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> <elementProp name="graphqlUpdateSimpleProductQtyInCartPercentage" elementType="Argument"> <stringProp name="Argument.name">graphqlUpdateSimpleProductQtyInCartPercentage</stringProp> <stringProp name="Argument.value">${__P(graphqlUpdateSimpleProductQtyInCartPercentage,0)}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> + <elementProp name="graphqlUpdateSimpleProductQtyInCartWithPricesPercentage" elementType="Argument"> + <stringProp name="Argument.name">graphqlUpdateSimpleProductQtyInCartWithPricesPercentage</stringProp> + <stringProp name="Argument.value">${__P(graphqlUpdateSimpleProductQtyInCartWithPricesPercentage,0)}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> <elementProp name="graphqlUrlInfoByUrlKeyPercentage" elementType="Argument"> <stringProp name="Argument.name">graphqlUrlInfoByUrlKeyPercentage</stringProp> <stringProp name="Argument.value">${__P(graphqlUrlInfoByUrlKeyPercentage,0)}</stringProp> @@ -38390,7 +38400,7 @@ vars.put("configurable_sku", "Configurable Product - ${__time(YMD)}-${__threadNu <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(\n filter: {\n price: {gt: \"10\"}\n or: {\n sku:{like:\"%Product%\"}\n name:{like:\"%Configurable Product%\"}\n }\n }\n pageSize: 20\n currentPage: 1\n sort: {\n price: ASC\n name:DESC\n }\n ) {\n total_count\n items {\n attribute_set_id\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n \t... on PhysicalProductInterface {\n \tweight\n \t}\n }\n page_info {\n page_size\n current_page\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(\n filter: {\n price: {from: \"5\"}\n name:{match:\"Product\"}\n }\n pageSize: 20\n currentPage: 1\n sort: {\n price: ASC\n name:DESC\n }\n ) {\n total_count\n items {\n attribute_set_id\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n \t... on PhysicalProductInterface {\n \tweight\n \t}\n }\n page_info {\n page_size\n current_page\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38447,7 +38457,7 @@ vars.put("configurable_sku", "Configurable Product - ${__time(YMD)}-${__threadNu <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: { eq: \"${simple_product_sku}\" } })\n {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: { eq: \"${simple_product_sku}\" } },sort: {name: ASC})\n {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38523,7 +38533,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: {eq:\"${configurable_product_sku}\"} }) {\n total_count\n items {\n ... on PhysicalProductInterface {\n weight\n }\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: {eq:\"${configurable_product_sku}\"} }, sort: {name: ASC}) {\n total_count\n items {\n ... on PhysicalProductInterface {\n weight\n }\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38592,7 +38602,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:1\n search: \"configurable\"\n filter: {name: {like: \"Configurable Product%\"} }\n ) {\n total_count\n page_info {\n current_page\n page_size\n total_pages\n }\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t\t}\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:1\n search: \"configurable\"\n filter: {name: {match: \"Configurable Product\"} }\n sort: {name: ASC}\n ) {\n total_count\n page_info {\n current_page\n page_size\n total_pages\n }\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t\t}\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38631,7 +38641,7 @@ if (totalCount == null) { </com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor> <hashTree/> <BeanShellAssertion guiclass="BeanShellAssertionGui" testclass="BeanShellAssertion" testname="Assert total count > 0" enabled="true"> - <stringProp name="BeanShellAssertion.query">String totalCount=vars.get("graphql_search_products_query_total_count_fulltext_filter"); + <stringProp name="BeanShellAssertion.query">String totalCount=vars.get("graphql_search_products_query_total_count_fulltext_filter"); if (totalCount == null) { Failure = true; @@ -38660,7 +38670,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:${graphql_search_products_query_total_pages_fulltext_filter}\n search: \"configurable\"\n filter: {name: {like: \"Configurable Product%\"} }\n ) {\n total_count\n page_info {\n current_page\n page_size\n total_pages\n }\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t\t}\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:${graphql_search_products_query_total_pages_fulltext_filter}\n search: \"configurable\"\n filter: {name: {match: \"Configurable Product\"} }\n sort: {name: ASC}\n ) {\n total_count\n page_info {\n current_page\n page_size\n total_pages\n }\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t\t}\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38720,7 +38730,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:1\n search: \"configurable\") {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t\t}\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:1\n search: \"configurable\"\n sort: {name: ASC}) {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t\t}\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38780,7 +38790,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:1\n search: \"Option 1\") {\n filters {\n name\n filter_items_count\n request_var\n filter_items {\n label\n value_string\n items_count\n ... on SwatchLayerFilterItemInterface {\n swatch_data {\n type\n value\n }\n }\n }\n }\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n weight\n }\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:1\n search: \"Option 1\"\n sort: {name: ASC}) {\n filters {\n name\n filter_items_count\n request_var\n filter_items {\n label\n value_string\n items_count\n ... on SwatchLayerFilterItemInterface {\n swatch_data {\n type\n value\n }\n }\n }\n }\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n weight\n }\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38834,13 +38844,70 @@ if (totalCount == null) { <hashTree/> </hashTree> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Query products with full text search and aggregations" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"{\n products(\n pageSize:20\n currentPage:1\n search: \"Option 1\"\n sort: {name: ASC}) {\n aggregations{\n attribute_code\n count\n label\n options{\n count\n label\n value\n }\n }\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n weight\n }\n ... on ConfigurableProduct {\n configurable_options {\n id\n attribute_id\n label\n position\n use_default\n attribute_code\n values {\n value_index\n label\n store_label\n default_label\n use_default_value\n }\n product_id\n }\n variants {\n product {\n ... on PhysicalProductInterface {\n weight\n }\n sku\n color\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n\n\n }\n attributes {\n label\n code\n value_index\n }\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port"/> + <stringProp name="HTTPSampler.connect_timeout"/> + <stringProp name="HTTPSampler.response_timeout"/> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/query_multiple_products_with_extensible_data_objects_using_full_text_and_aggregations.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract total_count" enabled="true"> + <stringProp name="VAR">graphql_search_products_query_total_count</stringProp> + <stringProp name="JSONPATH">$.data.products.total_count</stringProp> + <stringProp name="DEFAULT"/> + <stringProp name="VARIABLE"/> + <stringProp name="SUBJECT">BODY</stringProp> + </com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor> + <hashTree/> + <BeanShellAssertion guiclass="BeanShellAssertionGui" testclass="BeanShellAssertion" testname="Assert total count > 0" enabled="true"> + <stringProp name="BeanShellAssertion.query">String totalCount=vars.get("graphql_search_products_query_total_count"); + if (totalCount == null) { + Failure = true; + FailureMessage = "Not Expected \"totalCount\" to be null"; + } else { + if (Integer.parseInt(totalCount) < 1) { + Failure = true; + FailureMessage = "Expected \"totalCount\" to be greater than zero, Actual: " + totalCount; + } else { + Failure = false; + } + } + </stringProp> + <stringProp name="BeanShellAssertion.filename"/> + <stringProp name="BeanShellAssertion.parameters"/> + <boolProp name="BeanShellAssertion.resetInterpreter">false</boolProp> + </BeanShellAssertion> + <hashTree/> + </hashTree> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Query bundle product" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\nproducts(filter: {sku: {eq:\"${bundle_product_sku}\"} }) {\n total_count\n items {\n ... on PhysicalProductInterface {\n weight\n }\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on BundleProduct {\n weight\n price_view\n dynamic_price\n dynamic_sku\n ship_bundle_items\n dynamic_weight\n items {\n option_id\n title\n required\n type\n position\n sku\n options {\n id\n qty\n position\n is_default\n price\n price_type\n can_change_quantity\n product {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n }\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\nproducts(filter: {sku: {eq:\"${bundle_product_sku}\"} }, sort: {name: ASC}) {\n total_count\n items {\n ... on PhysicalProductInterface {\n weight\n }\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on BundleProduct {\n weight\n price_view\n dynamic_price\n dynamic_sku\n ship_bundle_items\n dynamic_weight\n items {\n option_id\n title\n required\n type\n position\n sku\n options {\n id\n qty\n position\n is_default\n price\n price_type\n can_change_quantity\n product {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n }\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38909,7 +38976,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: { eq: \"${downloadable_product_sku}\" } })\n {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t }\n ... on DownloadableProduct {\n links_purchased_separately\n links_title\n downloadable_product_samples {\n id\n title\n sort_order\n sample_type\n sample_file\n sample_url\n }\n downloadable_product_links {\n id\n title\n sort_order\n is_shareable\n price\n number_of_downloads\n link_type\n sample_type\n sample_file\n sample_url\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: { eq: \"${downloadable_product_sku}\" } }, sort: {name: ASC})\n {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t }\n ... on DownloadableProduct {\n links_purchased_separately\n links_title\n downloadable_product_samples {\n id\n title\n sort_order\n sample_type\n sample_file\n sample_url\n }\n downloadable_product_links {\n id\n title\n sort_order\n is_shareable\n price\n number_of_downloads\n link_type\n sample_type\n sample_file\n sample_url\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -38996,7 +39063,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: { eq: \"${virtual_product_sku}\" } })\n {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n products(filter: {sku: { eq: \"${virtual_product_sku}\" } },sort: {name: ASC})\n {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on PhysicalProductInterface {\n \t\t\tweight\n \t\t }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -39063,7 +39130,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\nproducts(filter: {sku: {eq:\"${grouped_product_sku}\"} }) {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on GroupedProduct {\n weight\n items {\n qty\n position\n product {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\nproducts(filter: {sku: {eq:\"${grouped_product_sku}\"} }, sort: {name: ASC}) {\n total_count\n items {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n ... on GroupedProduct {\n weight\n items {\n qty\n position\n product {\n attribute_set_id\n categories\n {\n id\n position\n }\n country_of_manufacture\n created_at\n description {\n html\n }\n gift_message_available\n id\n image\n {\n url\n label\n }\n meta_description\n meta_keyword\n meta_title\n media_gallery_entries\n {\n disabled\n file\n id\n label\n media_type\n position\n types\n content\n {\n base64_encoded_data\n type\n name\n }\n video_content\n {\n media_type\n video_description\n video_metadata\n video_provider\n video_title\n video_url\n }\n }\n name\n new_from_date\n new_to_date\n options_container\n ... on CustomizableProductInterface {\n options\n {\n title\n required\n sort_order\n }\n }\n \n price {\n minimalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n maximalPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n regularPrice {\n amount {\n value\n currency\n }\n adjustments {\n amount {\n value\n currency\n }\n code\n description\n }\n }\n }\n product_links\n {\n link_type\n linked_product_sku\n linked_product_type\n position\n sku\n }\n short_description {\n html\n }\n sku\n small_image\n {\n url\n label\n }\n special_from_date\n special_price\n special_to_date\n swatch_image\n thumbnail\n {\n url\n label\n }\n tier_price\n tier_prices\n {\n customer_group_id\n percentage_value\n qty\n value\n website_id\n }\n type_id\n updated_at\n url_key\n url_path\n websites { id name code sort_order default_group_id is_default }\n }\n }\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -39479,7 +39546,7 @@ vars.putObject("category", categories[number]); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query category($id: Int!, $currentPage: Int, $pageSize: Int) {\n category(id: $id) {\n product_count\n description\n url_key\n name\n id\n breadcrumbs {\n category_name\n category_url_key\n __typename\n }\n products(pageSize: $pageSize, currentPage: $currentPage) {\n total_count\n items {\n id\n name\n # small_image\n # short_description\n url_key\n special_price\n special_from_date\n special_to_date\n price {\n regularPrice {\n amount {\n value\n currency\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n","variables":{"id":${category_id},"currentPage":1,"pageSize":12},"operationName":"category"}</stringProp> + <stringProp name="Argument.value">{"query":"query category($id: Int!, $currentPage: Int, $pageSize: Int) {\n category(id: $id) {\n product_count\n description\n url_key\n name\n id\n breadcrumbs {\n category_name\n category_url_key\n __typename\n }\n products(pageSize: $pageSize, currentPage: $currentPage, sort: {name: ASC}) {\n total_count\n items {\n id\n name\n # small_image\n # short_description\n url_key\n special_price\n special_from_date\n special_to_date\n price {\n regularPrice {\n amount {\n value\n currency\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n","variables":{"id":${category_id},"currentPage":1,"pageSize":12},"operationName":"category"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -39603,7 +39670,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }, sort: {name: ASC}) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -39727,7 +39794,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetail($name: String, $onServer: Boolean!) {\n productDetail: products(filter: { name: { eq: $name } }) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetail"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetail($product_sku: String, $onServer: Boolean!) {\n productDetail: products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetail"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -39851,7 +39918,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }, sort: {name: ASC}) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -39975,7 +40042,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetailByName($name: String, $onServer: Boolean!) {\n products(filter: { name: { eq: $name } }) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetailByName"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetailByName($product_sku: String, $onServer: Boolean!) {\n products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetailByName"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -40097,7 +40164,7 @@ vars.putObject("category", categories[number]); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productSearch($inputText: String!, $categoryId: String) {\n products(\n pageSize:12\n search: $inputText, filter: { category_id: { eq: $categoryId } }) {\n items {\n id\n name\n small_image {\n label\n url\n }\n url_key\n price {\n regularPrice {\n amount {\n value\n currency\n }\n }\n }\n }\n total_count\n filters {\n name\n filter_items_count\n request_var\n filter_items {\n label\n value_string\n }\n }\n }\n}","variables":{"inputText":"Product","categoryId":"${category_id}"},"operationName":"productSearch"}</stringProp> + <stringProp name="Argument.value">{"query":"query productSearch($inputText: String!, $categoryId: String) {\n products(\n pageSize:12\n search: $inputText, filter: { category_id: { eq: $categoryId } }, sort: {name: ASC}) {\n items {\n id\n name\n small_image {\n label\n url\n }\n url_key\n price {\n regularPrice {\n amount {\n value\n currency\n }\n }\n }\n }\n total_count\n filters {\n name\n filter_items_count\n request_var\n filter_items {\n label\n value_string\n }\n }\n }\n}","variables":{"inputText":"Product","categoryId":"${category_id}"},"operationName":"productSearch"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -40241,7 +40308,7 @@ vars.putObject("category", categories[number]); <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> <stringProp name="Argument.value"> - {"query":"query categoryList($id: Int!) {\n category(id: $id) {\n id\n children {\n id\n name\n url_key\n url_path\n children_count\n path\n image\n productImagePreview: products(pageSize: 1) {\n items {\n small_image {\n label\n url\n }\n }\n }\n }\n }\n}","variables":{"id":${category_id}},"operationName":"categoryList"} + {"query":"query categoryList($id: Int!) {\n category(id: $id) {\n id\n children {\n id\n name\n url_key\n url_path\n children_count\n path\n image\n productImagePreview: products(pageSize: 1, sort: {name: ASC}) {\n items {\n small_image {\n label\n url\n }\n }\n }\n }\n }\n}","variables":{"id":${category_id}},"operationName":"categoryList"} </stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> @@ -40631,7 +40698,7 @@ vars.putObject("category", categories[number]); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query navigationMenu($id: Int!) {\n category(id: $id) {\n id\n name\n product_count\n path\n children {\n id\n name\n position\n level\n url_key\n url_path\n product_count\n children_count\n path\n productImagePreview: products(pageSize: 1) {\n items {\n small_image {\n label\n url\n }\n }\n }\n }\n }\n}","variables":{"id":${category_id}},"operationName":"navigationMenu"}</stringProp> + <stringProp name="Argument.value">{"query":"query navigationMenu($id: Int!) {\n category(id: $id) {\n id\n name\n product_count\n path\n children {\n id\n name\n position\n level\n url_key\n url_path\n product_count\n children_count\n path\n productImagePreview: products(pageSize: 1, sort: {name: ASC}) {\n items {\n small_image {\n label\n url\n }\n }\n }\n }\n }\n}","variables":{"id":${category_id}},"operationName":"navigationMenu"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -41229,13 +41296,395 @@ vars.putObject("randomIntGenerator", random); <hashTree/> </hashTree> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Empty Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Empty Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n product {\n sku\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_empty_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="1901638450">{"data":{"cart":{"items":[]}}}</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">8</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Set Billing Address On Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"mutation {\n setBillingAddressOnCart(\n input: {\n cart_id: \"${quote_id}\"\n billing_address: {\n address: {\n firstname: \"test firstname\"\n lastname: \"test lastname\"\n company: \"test company\"\n street: [\"test street 1\", \"test street 2\"]\n city: \"test city\"\n region: \"test region\"\n postcode: \"887766\"\n country_code: \"US\"\n telephone: \"88776655\"\n save_in_address_book: false\n }\n }\n }\n ) {\n cart {\n billing_address {\n firstname\n lastname\n company\n street\n city\n postcode\n telephone\n country {\n code\n label\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/set_billing_address_on_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="1147076914">{"data":{"setBillingAddressOnCart":{"cart":{"billing_address":{"firstname":"test firstname","lastname":"test lastname","company":"test company","street":["test street 1","test street 2"],"city":"test city","postcode":"887766","telephone":"88776655","country":{"code":"US","label":"US"}}}}}}</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">8</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> + </hashTree> + + + <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Add Simple Product To Cart" enabled="true"> + <intProp name="ThroughputController.style">1</intProp> + <boolProp name="ThroughputController.perThread">false</boolProp> + <intProp name="ThroughputController.maxThroughput">1</intProp> + <stringProp name="ThroughputController.percentThroughput">${graphqlAddSimpleProductToCartPercentage}</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/scenario_controller_tmpl.jmx</stringProp></ThroughputController> + <hashTree> + <JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="Set Test Label" enabled="true"> + <stringProp name="script"> +var testLabel = "${testLabel}" ? " (${testLabel})" : ""; +if (testLabel + && sampler.getClass().getName() == 'org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy' +) { + if (sampler.getName().indexOf(testLabel) == -1) { + sampler.setName(sampler.getName() + testLabel); + } +} else if (sampler.getName().indexOf("SetUp - ") == -1) { + sampler.setName("SetUp - " + sampler.getName()); +} + </stringProp> + <stringProp name="scriptLanguage">javascript</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/setup_label.jmx</stringProp></JSR223PreProcessor> + <hashTree/> + <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Set Label" enabled="true"> + <stringProp name="BeanShellSampler.query"> + vars.put("testLabel", "GraphQL Add Simple Product To Cart"); + </stringProp> + <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> + </BeanShellSampler> + <hashTree/> + + <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true"> + <collectionProp name="HeaderManager.headers"> + <elementProp name="" elementType="Header"> + <stringProp name="Header.name">Content-Type</stringProp> + <stringProp name="Header.value">application/json</stringProp> + </elementProp> + <elementProp name="" elementType="Header"> + <stringProp name="Header.name">Accept</stringProp> + <stringProp name="Header.value">*/*</stringProp> + </elementProp> + </collectionProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/api/header_manager_before_token.jmx</stringProp></HeaderManager> + <hashTree/> + + <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Init Random Generator" enabled="true"> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/common/init_random_generator_setup.jmx</stringProp> + <stringProp name="BeanShellSampler.query"> +import java.util.Random; + +Random random = new Random(); +if (${seedForRandom} > 0) { + random.setSeed(${seedForRandom} + ${__threadNum}); +} + +vars.putObject("randomIntGenerator", random); + </stringProp> + <stringProp name="BeanShellSampler.filename"/> + <stringProp name="BeanShellSampler.parameters"/> + <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> + </BeanShellSampler> + <hashTree/> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Create Empty Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"mutation {\n createEmptyCart\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/create_empty_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract cart id" enabled="true"> + <stringProp name="VAR">quote_id</stringProp> + <stringProp name="JSONPATH">$.data.createEmptyCart</stringProp> + <stringProp name="DEFAULT"/> + <stringProp name="VARIABLE"/> + <stringProp name="SUBJECT">BODY</stringProp> + </com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor> + <hashTree/> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="1404608713">{"data":{"createEmptyCart":"</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">2</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> + + <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Prepare Simple Product Data" enabled="true"> + <stringProp name="BeanShellSampler.query"> +import java.util.Random; + +Random random = vars.getObject("randomIntGenerator"); +number = random.nextInt(props.get("simple_products_list").size()); +product = props.get("simple_products_list").get(number); + +vars.put("product_url_key", product.get("url_key")); +vars.put("product_id", product.get("id")); +vars.put("product_name", product.get("title")); +vars.put("product_uenc", product.get("uenc")); +vars.put("product_sku", product.get("sku")); + </stringProp> + <stringProp name="BeanShellSampler.filename"/> + <stringProp name="BeanShellSampler.parameters"/> + <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/product_browsing_and_adding_items_to_the_cart/simple_products_setup.jmx</stringProp></BeanShellSampler> + <hashTree/> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Add Simple Product To Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"mutation { \n addSimpleProductsToCart(\n input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n data: {\n quantity: 2\n sku: \"${product_sku}\"\n }\n }\n ]\n }\n ) {\n cart {\n items {\n quantity\n product {\n sku\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/add_simple_product_to_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="1421843282">addSimpleProductsToCart</stringProp> + <stringProp name="-1173443935">"sku":"${product_sku}"</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">2</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> + </hashTree> + + + <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Add Configurable Product To Cart" enabled="true"> + <intProp name="ThroughputController.style">1</intProp> + <boolProp name="ThroughputController.perThread">false</boolProp> + <intProp name="ThroughputController.maxThroughput">1</intProp> + <stringProp name="ThroughputController.percentThroughput">${graphqlAddConfigurableProductToCartPercentage}</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/scenario_controller_tmpl.jmx</stringProp></ThroughputController> + <hashTree> + <JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="Set Test Label" enabled="true"> + <stringProp name="script"> +var testLabel = "${testLabel}" ? " (${testLabel})" : ""; +if (testLabel + && sampler.getClass().getName() == 'org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy' +) { + if (sampler.getName().indexOf(testLabel) == -1) { + sampler.setName(sampler.getName() + testLabel); + } +} else if (sampler.getName().indexOf("SetUp - ") == -1) { + sampler.setName("SetUp - " + sampler.getName()); +} + </stringProp> + <stringProp name="scriptLanguage">javascript</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/setup_label.jmx</stringProp></JSR223PreProcessor> + <hashTree/> + <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Set Label" enabled="true"> + <stringProp name="BeanShellSampler.query"> + vars.put("testLabel", "GraphQL Add Configurable Product To Cart"); + </stringProp> + <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> + </BeanShellSampler> + <hashTree/> + + <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true"> + <collectionProp name="HeaderManager.headers"> + <elementProp name="" elementType="Header"> + <stringProp name="Header.name">Content-Type</stringProp> + <stringProp name="Header.value">application/json</stringProp> + </elementProp> + <elementProp name="" elementType="Header"> + <stringProp name="Header.name">Accept</stringProp> + <stringProp name="Header.value">*/*</stringProp> + </elementProp> + </collectionProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/api/header_manager_before_token.jmx</stringProp></HeaderManager> + <hashTree/> + + <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Init Random Generator" enabled="true"> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/common/init_random_generator_setup.jmx</stringProp> + <stringProp name="BeanShellSampler.query"> +import java.util.Random; + +Random random = new Random(); +if (${seedForRandom} > 0) { + random.setSeed(${seedForRandom} + ${__threadNum}); +} + +vars.putObject("randomIntGenerator", random); + </stringProp> + <stringProp name="BeanShellSampler.filename"/> + <stringProp name="BeanShellSampler.parameters"/> + <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> + </BeanShellSampler> + <hashTree/> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Create Empty Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"mutation {\n createEmptyCart\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/create_empty_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract cart id" enabled="true"> + <stringProp name="VAR">quote_id</stringProp> + <stringProp name="JSONPATH">$.data.createEmptyCart</stringProp> + <stringProp name="DEFAULT"/> + <stringProp name="VARIABLE"/> + <stringProp name="SUBJECT">BODY</stringProp> + </com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor> + <hashTree/> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="1404608713">{"data":{"createEmptyCart":"</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">2</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> + + <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Prepare Configurable Product Data" enabled="true"> + <stringProp name="BeanShellSampler.query"> +import java.util.Random; + +Random random = vars.getObject("randomIntGenerator"); +number = random.nextInt(props.get("configurable_products_list").size()); +product = props.get("configurable_products_list").get(number); + +vars.put("product_url_key", product.get("url_key")); +vars.put("product_id", product.get("id")); +vars.put("product_name", product.get("title")); +vars.put("product_uenc", product.get("uenc")); +vars.put("product_sku", product.get("sku")); + </stringProp> + <stringProp name="BeanShellSampler.filename"/> + <stringProp name="BeanShellSampler.parameters"/> + <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/product_browsing_and_adding_items_to_the_cart/configurable_products_setup.jmx</stringProp></BeanShellSampler> + <hashTree/> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Configurable Product Details by name" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n product {\n sku\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetailByName($product_sku: String, $onServer: Boolean!) {\n products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetailByName"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -41254,27 +41703,36 @@ vars.putObject("randomIntGenerator", random); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_empty_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_configurable_product_details_by_name.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> <collectionProp name="Asserion.test_strings"> - <stringProp name="1901638450">{"data":{"cart":{"items":[]}}}</stringProp> + <stringProp name="1201352014">"sku":"${product_sku}","name":"${product_name}"</stringProp> </collectionProp> <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> <boolProp name="Assertion.assume_success">false</boolProp> - <intProp name="Assertion.test_type">8</intProp> + <intProp name="Assertion.test_type">2</intProp> </ResponseAssertion> <hashTree/> - </hashTree> + + <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract Configurable Product option" enabled="true"> + <stringProp name="VAR">product_option</stringProp> + <stringProp name="JSONPATH">$.data.products.items[0].variants[0].product.sku</stringProp> + <stringProp name="DEFAULT"/> + <stringProp name="VARIABLE"/> + <stringProp name="SUBJECT">BODY</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/extract_configurable_product_option.jmx</stringProp></com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor> + <hashTree/> + </hashTree> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Set Billing Address On Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Add Configurable Product To Cart" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"mutation {\n setBillingAddressOnCart(\n input: {\n cart_id: \"${quote_id}\"\n billing_address: {\n address: {\n firstname: \"test firstname\"\n lastname: \"test lastname\"\n company: \"test company\"\n street: [\"test street 1\", \"test street 2\"]\n city: \"test city\"\n region: \"test region\"\n postcode: \"887766\"\n country_code: \"US\"\n telephone: \"88776655\"\n save_in_address_book: false\n }\n }\n }\n ) {\n cart {\n billing_address {\n firstname\n lastname\n company\n street\n city\n postcode\n telephone\n country {\n code\n label\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"mutation {\n addConfigurableProductsToCart(\n input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n variant_sku: \"${product_option}\"\n data: {\n quantity: 2\n sku: \"${product_option}\"\n }\n }\n ]\n }\n ) {\n cart {\n items {\n id\n quantity\n product {\n name\n sku\n }\n ... on ConfigurableCartItem {\n configurable_options {\n option_label\n }\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -41293,27 +41751,28 @@ vars.putObject("randomIntGenerator", random); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/set_billing_address_on_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/add_configurable_product_to_cart.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> <collectionProp name="Asserion.test_strings"> - <stringProp name="1147076914">{"data":{"setBillingAddressOnCart":{"cart":{"billing_address":{"firstname":"test firstname","lastname":"test lastname","company":"test company","street":["test street 1","test street 2"],"city":"test city","postcode":"887766","telephone":"88776655","country":{"code":"US","label":"US"}}}}}}</stringProp> + <stringProp name="1421843282">addConfigurableProductsToCart</stringProp> + <stringProp name="675049292">"sku":"${product_option}"</stringProp> </collectionProp> <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> <boolProp name="Assertion.assume_success">false</boolProp> - <intProp name="Assertion.test_type">8</intProp> + <intProp name="Assertion.test_type">2</intProp> </ResponseAssertion> <hashTree/> </hashTree> </hashTree> - <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Add Simple Product To Cart" enabled="true"> + <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Update Simple Product Qty In Cart" enabled="true"> <intProp name="ThroughputController.style">1</intProp> <boolProp name="ThroughputController.perThread">false</boolProp> <intProp name="ThroughputController.maxThroughput">1</intProp> - <stringProp name="ThroughputController.percentThroughput">${graphqlAddSimpleProductToCartPercentage}</stringProp> + <stringProp name="ThroughputController.percentThroughput">${graphqlUpdateSimpleProductQtyInCartPercentage}</stringProp> <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/scenario_controller_tmpl.jmx</stringProp></ThroughputController> <hashTree> <JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="Set Test Label" enabled="true"> @@ -41334,7 +41793,7 @@ if (testLabel <hashTree/> <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Set Label" enabled="true"> <stringProp name="BeanShellSampler.query"> - vars.put("testLabel", "GraphQL Add Simple Product To Cart"); + vars.put("testLabel", "GraphQL Update Simple Product Qty In Cart"); </stringProp> <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> </BeanShellSampler> @@ -41478,14 +41937,100 @@ vars.put("product_sku", product.get("sku")); </ResponseAssertion> <hashTree/> </hashTree> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n product {\n sku\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract item id" enabled="true"> + <stringProp name="VAR">item_id</stringProp> + <stringProp name="JSONPATH">$.data.cart.items[0].id</stringProp> + <stringProp name="DEFAULT"/> + <stringProp name="VARIABLE"/> + <stringProp name="SUBJECT">BODY</stringProp> + </com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor> + <hashTree/> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="-1486007127">{"data":{"cart":{"items":</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">2</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Simple Product qty In Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"mutation {\n updateCartItems(input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n cart_item_id: ${item_id}\n quantity: 5\n }\n ]\n }) {\n cart {\n items {\n id\n quantity\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/update_simple_product_qty_in_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="664196114">{"data":{"updateCartItems":{"cart":{"items":[{"id":"${item_id}","quantity":5}]}}}}</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">8</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> </hashTree> - <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Add Configurable Product To Cart" enabled="true"> + <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Update Configurable Product Qty In Cart" enabled="true"> <intProp name="ThroughputController.style">1</intProp> <boolProp name="ThroughputController.perThread">false</boolProp> <intProp name="ThroughputController.maxThroughput">1</intProp> - <stringProp name="ThroughputController.percentThroughput">${graphqlAddConfigurableProductToCartPercentage}</stringProp> + <stringProp name="ThroughputController.percentThroughput">${graphqlUpdateConfigurableProductQtyInCartPercentage}</stringProp> <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/scenario_controller_tmpl.jmx</stringProp></ThroughputController> <hashTree> <JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="Set Test Label" enabled="true"> @@ -41506,7 +42051,7 @@ if (testLabel <hashTree/> <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Set Label" enabled="true"> <stringProp name="BeanShellSampler.query"> - vars.put("testLabel", "GraphQL Add Configurable Product To Cart"); + vars.put("testLabel", "GraphQL Update Configurable Product Qty In Cart"); </stringProp> <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> </BeanShellSampler> @@ -41617,7 +42162,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetailByName($name: String, $onServer: Boolean!) {\n products(filter: { name: { eq: $name } }) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetailByName"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetailByName($product_sku: String, $onServer: Boolean!) {\n products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetailByName"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -41698,14 +42243,100 @@ vars.put("product_sku", product.get("sku")); </ResponseAssertion> <hashTree/> </hashTree> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n product {\n sku\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract item id" enabled="true"> + <stringProp name="VAR">item_id</stringProp> + <stringProp name="JSONPATH">$.data.cart.items[0].id</stringProp> + <stringProp name="DEFAULT"/> + <stringProp name="VARIABLE"/> + <stringProp name="SUBJECT">BODY</stringProp> + </com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor> + <hashTree/> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="-1486007127">{"data":{"cart":{"items":</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">2</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> + + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Configurable Product qty In Cart" enabled="true"> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"query":"mutation {\n updateCartItems(input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n cart_item_id: ${item_id}\n quantity: 5\n }\n ]\n }) {\n cart {\n items {\n id\n quantity\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port">${graphql_port_number}</stringProp> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/update_configurable_product_qty_in_cart.jmx</stringProp> + </HTTPSamplerProxy> + <hashTree> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="664196114">{"data":{"updateCartItems":{"cart":{"items":[{"id":"${item_id}","quantity":5}]}}}}</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">8</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> </hashTree> - <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Update Simple Product Qty In Cart" enabled="true"> + <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Update Simple Product Qty In Cart with Prices" enabled="true"> <intProp name="ThroughputController.style">1</intProp> <boolProp name="ThroughputController.perThread">false</boolProp> <intProp name="ThroughputController.maxThroughput">1</intProp> - <stringProp name="ThroughputController.percentThroughput">${graphqlUpdateSimpleProductQtyInCartPercentage}</stringProp> + <stringProp name="ThroughputController.percentThroughput">${graphqlUpdateSimpleProductQtyInCartWithPricesPercentage}</stringProp> <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/scenario_controller_tmpl.jmx</stringProp></ThroughputController> <hashTree> <JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="Set Test Label" enabled="true"> @@ -41726,7 +42357,7 @@ if (testLabel <hashTree/> <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Set Label" enabled="true"> <stringProp name="BeanShellSampler.query"> - vars.put("testLabel", "GraphQL Update Simple Product Qty In Cart"); + vars.put("testLabel", "GraphQL Update Simple Product Qty In Cart with Prices"); </stringProp> <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> </BeanShellSampler> @@ -41831,13 +42462,13 @@ vars.put("product_sku", product.get("sku")); <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/product_browsing_and_adding_items_to_the_cart/simple_products_setup.jmx</stringProp></BeanShellSampler> <hashTree/> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Add Simple Product To Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Add Simple Product To Cart With Prices" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"mutation { \n addSimpleProductsToCart(\n input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n data: {\n quantity: 2\n sku: \"${product_sku}\"\n }\n }\n ]\n }\n ) {\n cart {\n items {\n quantity\n product {\n sku\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"mutation { \n addSimpleProductsToCart(\n input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n data: {\n quantity: 2\n sku: \"${product_sku}\"\n }\n }\n ]\n }\n ) {\n cart {\n items {\n quantity\n prices {\n row_total{\n value\n }\n total_item_discount {\n currency\n value\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n row_total_including_tax{\n value\n }\n }\n product {\n sku\n }\n }\n prices {\n applied_taxes {\n amount {\n currency\n value\n }\n label\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n grand_total {\n currency\n value\n }\n subtotal_excluding_tax {\n value\n currency\n }\n subtotal_including_tax {\n value\n currency\n }\n subtotal_with_discount_excluding_tax {\n value\n currency\n }\n }\n }\n }\n}\n","variables":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -41856,7 +42487,7 @@ vars.put("product_sku", product.get("sku")); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/add_simple_product_to_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/add_simple_product_to_cart_with_prices.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> @@ -41871,13 +42502,13 @@ vars.put("product_sku", product.get("sku")); <hashTree/> </hashTree> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Cart With Prices" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n product {\n sku\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n prices {\n row_total{\n value\n }\n row_total_including_tax{\n value\n }\n total_item_discount{value}\n discounts{\n amount{value}\n label\n }\n }\n product {\n sku\n }\n }\n prices {\n applied_taxes {\n amount {\n currency\n value\n }\n label\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n grand_total {\n currency\n value\n }\n subtotal_excluding_tax {\n value\n currency\n }\n subtotal_including_tax {\n value\n currency\n }\n subtotal_with_discount_excluding_tax {\n value\n currency\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -41896,7 +42527,7 @@ vars.put("product_sku", product.get("sku")); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_cart_with_prices.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract item id" enabled="true"> @@ -41918,13 +42549,13 @@ vars.put("product_sku", product.get("sku")); <hashTree/> </hashTree> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Simple Product qty In Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Simple Product qty In Cart With Prices" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"mutation {\n updateCartItems(input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n cart_item_id: ${item_id}\n quantity: 5\n }\n ]\n }) {\n cart {\n items {\n id\n quantity\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"mutation {\n updateCartItems(input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n cart_item_id: ${item_id}\n quantity: 5\n }\n ]\n }) {\n cart {\n items {\n id\n quantity\n prices {\n row_total{\n value\n }\n total_item_discount {\n currency\n value\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n row_total_including_tax{\n value\n }\n }\n product {\n sku\n }\n }\n prices {\n applied_taxes {\n amount {\n currency\n value\n }\n label\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n grand_total {\n currency\n value\n }\n subtotal_excluding_tax {\n value\n currency\n }\n subtotal_including_tax {\n value\n currency\n }\n subtotal_with_discount_excluding_tax {\n value\n currency\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -41943,27 +42574,28 @@ vars.put("product_sku", product.get("sku")); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/update_simple_product_qty_in_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/update_simple_product_qty_in_cart_with_prices.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> <collectionProp name="Asserion.test_strings"> - <stringProp name="664196114">{"data":{"updateCartItems":{"cart":{"items":[{"id":"${item_id}","quantity":5}]}}}}</stringProp> + <stringProp name="1421843282">"quantity":5</stringProp> + <stringProp name="675049292">"id":"${item_id}"</stringProp> </collectionProp> <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> <boolProp name="Assertion.assume_success">false</boolProp> - <intProp name="Assertion.test_type">8</intProp> + <intProp name="Assertion.test_type">2</intProp> </ResponseAssertion> <hashTree/> </hashTree> </hashTree> - <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Update Configurable Product Qty In Cart" enabled="true"> + <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="GraphQL Update Configurable Product Qty In Cart with Prices" enabled="true"> <intProp name="ThroughputController.style">1</intProp> <boolProp name="ThroughputController.perThread">false</boolProp> <intProp name="ThroughputController.maxThroughput">1</intProp> - <stringProp name="ThroughputController.percentThroughput">${graphqlUpdateConfigurableProductQtyInCartPercentage}</stringProp> + <stringProp name="ThroughputController.percentThroughput">${graphqlUpdateConfigurableProductQtyInCartWithPricesPercentage}</stringProp> <stringProp name="TestPlan.comments">mpaf/tool/fragments/_system/scenario_controller_tmpl.jmx</stringProp></ThroughputController> <hashTree> <JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="Set Test Label" enabled="true"> @@ -41984,7 +42616,7 @@ if (testLabel <hashTree/> <BeanShellSampler guiclass="BeanShellSamplerGui" testclass="BeanShellSampler" testname="SetUp - Set Label" enabled="true"> <stringProp name="BeanShellSampler.query"> - vars.put("testLabel", "GraphQL Update Configurable Product Qty In Cart"); + vars.put("testLabel", "GraphQL Update Configurable Product Qty In Cart with Prices"); </stringProp> <boolProp name="BeanShellSampler.resetInterpreter">true</boolProp> </BeanShellSampler> @@ -42095,7 +42727,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetailByName($name: String, $onServer: Boolean!) {\n products(filter: { name: { eq: $name } }) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetailByName"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetailByName($product_sku: String, $onServer: Boolean!) {\n products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetailByName"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -42137,13 +42769,13 @@ vars.put("product_sku", product.get("sku")); <hashTree/> </hashTree> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Add Configurable Product To Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Add Configurable Product To Cart With Prices" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"mutation {\n addConfigurableProductsToCart(\n input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n variant_sku: \"${product_option}\"\n data: {\n quantity: 2\n sku: \"${product_option}\"\n }\n }\n ]\n }\n ) {\n cart {\n items {\n id\n quantity\n product {\n name\n sku\n }\n ... on ConfigurableCartItem {\n configurable_options {\n option_label\n }\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"mutation {\n addConfigurableProductsToCart(\n input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n variant_sku: \"${product_option}\"\n data: {\n quantity: 2\n sku: \"${product_option}\"\n }\n }\n ]\n }\n ) {\n cart {\n items {\n id\n quantity\n prices {\n row_total{\n value\n }\n total_item_discount {\n currency\n value\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n row_total_including_tax{\n value\n }\n }\n product {\n name\n sku\n }\n ... on ConfigurableCartItem {\n configurable_options {\n option_label\n }\n }\n }\n prices {\n applied_taxes {\n amount {\n currency\n value\n }\n label\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n grand_total {\n currency\n value\n }\n subtotal_excluding_tax {\n value\n currency\n }\n subtotal_including_tax {\n value\n currency\n }\n subtotal_with_discount_excluding_tax {\n value\n currency\n }\n }\n }\n }\n}\n","variables":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -42162,7 +42794,7 @@ vars.put("product_sku", product.get("sku")); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/add_configurable_product_to_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/add_configurable_product_to_cart_with_prices.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> @@ -42177,13 +42809,13 @@ vars.put("product_sku", product.get("sku")); <hashTree/> </hashTree> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Cart With Prices" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n product {\n sku\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"{\n cart(cart_id: \"${quote_id}\") {\n items {\n id\n quantity\n prices {\n row_total{\n value\n }\n row_total_including_tax{\n value\n }\n total_item_discount{value}\n discounts{\n amount{value}\n label\n }\n }\n product {\n sku\n }\n }\n prices {\n applied_taxes {\n amount {\n currency\n value\n }\n label\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n grand_total {\n currency\n value\n }\n subtotal_excluding_tax {\n value\n currency\n }\n subtotal_including_tax {\n value\n currency\n }\n subtotal_with_discount_excluding_tax {\n value\n currency\n }\n }\n }\n}\n","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -42202,7 +42834,7 @@ vars.put("product_sku", product.get("sku")); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/get_cart_with_prices.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor guiclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.gui.JSONPathExtractorGui" testclass="com.atlantbh.jmeter.plugins.jsonutils.jsonpathextractor.JSONPathExtractor" testname="Extract item id" enabled="true"> @@ -42224,13 +42856,13 @@ vars.put("product_sku", product.get("sku")); <hashTree/> </hashTree> - <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Configurable Product qty In Cart" enabled="true"> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Configurable Product qty In Cart With Prices" enabled="true"> <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"mutation {\n updateCartItems(input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n cart_item_id: ${item_id}\n quantity: 5\n }\n ]\n }) {\n cart {\n items {\n id\n quantity\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> + <stringProp name="Argument.value">{"query":"mutation {\n updateCartItems(input: {\n cart_id: \"${quote_id}\"\n cart_items: [\n {\n cart_item_id: ${item_id}\n quantity: 5\n }\n ]\n }) {\n cart {\n items {\n id\n quantity\n prices {\n row_total{\n value\n }\n total_item_discount {\n currency\n value\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n row_total_including_tax{\n value\n }\n }\n product {\n name\n sku\n }\n ... on ConfigurableCartItem {\n configurable_options {\n option_label\n }\n }\n }\n prices {\n applied_taxes {\n amount {\n currency\n value\n }\n label\n }\n discounts {\n amount {\n currency\n value\n }\n label\n }\n grand_total {\n currency\n value\n }\n subtotal_excluding_tax {\n value\n currency\n }\n subtotal_including_tax {\n value\n currency\n }\n subtotal_with_discount_excluding_tax {\n value\n currency\n }\n }\n }\n }\n}","variables":null,"operationName":null}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -42249,16 +42881,17 @@ vars.put("product_sku", product.get("sku")); <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> <boolProp name="HTTPSampler.monitor">false</boolProp> <stringProp name="HTTPSampler.embedded_url_re"/> - <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/update_configurable_product_qty_in_cart.jmx</stringProp> + <stringProp name="TestPlan.comments">mpaf/tool/fragments/ce/graphql/update_configurable_product_qty_in_cart_with_prices.jmx</stringProp> </HTTPSamplerProxy> <hashTree> <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true"> <collectionProp name="Asserion.test_strings"> - <stringProp name="664196114">{"data":{"updateCartItems":{"cart":{"items":[{"id":"${item_id}","quantity":5}]}}}}</stringProp> + <stringProp name="1421843282">"quantity":5</stringProp> + <stringProp name="675049292">"id":"${item_id}"</stringProp> </collectionProp> <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> <boolProp name="Assertion.assume_success">false</boolProp> - <intProp name="Assertion.test_type">8</intProp> + <intProp name="Assertion.test_type">2</intProp> </ResponseAssertion> <hashTree/> </hashTree> @@ -42659,7 +43292,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetailByName($name: String, $onServer: Boolean!) {\n products(filter: { name: { eq: $name } }) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetailByName"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetailByName($product_sku: String, $onServer: Boolean!) {\n products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetailByName"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -43409,7 +44042,7 @@ vars.putObject("category", categories[number]); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query navigationMenu($id: Int!) {\n category(id: $id) {\n id\n name\n product_count\n path\n children {\n id\n name\n position\n level\n url_key\n url_path\n product_count\n children_count\n path\n productImagePreview: products(pageSize: 1) {\n items {\n small_image {\n label\n url\n }\n }\n }\n }\n }\n}","variables":{"id":${category_id}},"operationName":"navigationMenu"}</stringProp> + <stringProp name="Argument.value">{"query":"query navigationMenu($id: Int!) {\n category(id: $id) {\n id\n name\n product_count\n path\n children {\n id\n name\n position\n level\n url_key\n url_path\n product_count\n children_count\n path\n productImagePreview: products(pageSize: 1, sort: {name: ASC}) {\n items {\n small_image {\n label\n url\n }\n }\n }\n }\n }\n}","variables":{"id":${category_id}},"operationName":"navigationMenu"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -43448,7 +44081,7 @@ vars.putObject("category", categories[number]); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productSearch($inputText: String!, $categoryId: String) {\n products(\n pageSize:12\n search: $inputText, filter: { category_id: { eq: $categoryId } }) {\n items {\n id\n name\n small_image {\n label\n url\n }\n url_key\n price {\n regularPrice {\n amount {\n value\n currency\n }\n }\n }\n }\n total_count\n filters {\n name\n filter_items_count\n request_var\n filter_items {\n label\n value_string\n }\n }\n }\n}","variables":{"inputText":"Product","categoryId":"${category_id}"},"operationName":"productSearch"}</stringProp> + <stringProp name="Argument.value">{"query":"query productSearch($inputText: String!, $categoryId: String) {\n products(\n pageSize:12\n search: $inputText, filter: { category_id: { eq: $categoryId } }, sort: {name: ASC}) {\n items {\n id\n name\n small_image {\n label\n url\n }\n url_key\n price {\n regularPrice {\n amount {\n value\n currency\n }\n }\n }\n }\n total_count\n filters {\n name\n filter_items_count\n request_var\n filter_items {\n label\n value_string\n }\n }\n }\n}","variables":{"inputText":"Product","categoryId":"${category_id}"},"operationName":"productSearch"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -43548,7 +44181,7 @@ if (totalCount == null) { <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query category($id: Int!, $currentPage: Int, $pageSize: Int) {\n category(id: $id) {\n product_count\n description\n url_key\n name\n id\n breadcrumbs {\n category_name\n category_url_key\n __typename\n }\n products(pageSize: $pageSize, currentPage: $currentPage) {\n total_count\n items {\n id\n name\n # small_image\n # short_description\n url_key\n special_price\n special_from_date\n special_to_date\n price {\n regularPrice {\n amount {\n value\n currency\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n","variables":{"id":${category_id},"currentPage":1,"pageSize":12},"operationName":"category"}</stringProp> + <stringProp name="Argument.value">{"query":"query category($id: Int!, $currentPage: Int, $pageSize: Int) {\n category(id: $id) {\n product_count\n description\n url_key\n name\n id\n breadcrumbs {\n category_name\n category_url_key\n __typename\n }\n products(pageSize: $pageSize, currentPage: $currentPage, sort: {name: ASC}) {\n total_count\n items {\n id\n name\n # small_image\n # short_description\n url_key\n special_price\n special_from_date\n special_to_date\n price {\n regularPrice {\n amount {\n value\n currency\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n","variables":{"id":${category_id},"currentPage":1,"pageSize":12},"operationName":"category"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -43607,7 +44240,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetailByName($name: String, $onServer: Boolean!) {\n products(filter: { name: { eq: $name } }) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetailByName"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetailByName($product_sku: String, $onServer: Boolean!) {\n products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetailByName"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -43646,7 +44279,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }, sort: {name: ASC}) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -43705,7 +44338,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetail($name: String, $onServer: Boolean!) {\n productDetail: products(filter: { name: { eq: $name } }) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetail"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetail($product_sku: String, $onServer: Boolean!) {\n productDetail: products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetail"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -43744,7 +44377,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetail($urlKey: String, $onServer: Boolean!) {\n productDetail: products(filter: { url_key: { eq: $urlKey } }, sort: {name: ASC}) {\n items {\n sku\n name\n price {\n regularPrice {\n amount {\n currency\n value\n }\n }\n }\n description {html}\n media_gallery_entries {\n label\n position\n disabled\n file\n }\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n # Yes, Products have `meta_keyword` and\n # everything else has `meta_keywords`.\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"urlKey":"${product_url_key}","onServer":false},"operationName":"productDetail"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> @@ -44009,7 +44642,7 @@ vars.put("product_sku", product.get("sku")); <collectionProp name="Arguments.arguments"> <elementProp name="" elementType="HTTPArgument"> <boolProp name="HTTPArgument.always_encode">false</boolProp> - <stringProp name="Argument.value">{"query":"query productDetailByName($name: String, $onServer: Boolean!) {\n products(filter: { name: { eq: $name } }) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"name":"${product_name}","onServer":false},"operationName":"productDetailByName"}</stringProp> + <stringProp name="Argument.value">{"query":"query productDetailByName($product_sku: String, $onServer: Boolean!) {\n products(filter: { sku: { eq: $product_sku } }, sort: {name: ASC}) {\n items {\n id\n sku\n name\n ... on ConfigurableProduct {\n configurable_options {\n attribute_code\n attribute_id\n id\n label\n values {\n default_label\n label\n store_label\n use_default_value\n value_index\n }\n }\n variants {\n product {\n #fashion_color\n #fashion_size\n id\n media_gallery_entries {\n disabled\n file\n label\n position\n }\n sku\n stock_status\n }\n }\n }\n meta_title @include(if: $onServer)\n meta_keyword @include(if: $onServer)\n meta_description @include(if: $onServer)\n }\n }\n}","variables":{"product_sku":"${product_sku}","onServer":false},"operationName":"productDetailByName"}</stringProp> <stringProp name="Argument.metadata">=</stringProp> </elementProp> </collectionProp> diff --git a/setup/src/Magento/Setup/Model/ObjectManagerProvider.php b/setup/src/Magento/Setup/Model/ObjectManagerProvider.php index e25b976e9207f..79216c8ec89b5 100644 --- a/setup/src/Magento/Setup/Model/ObjectManagerProvider.php +++ b/setup/src/Magento/Setup/Model/ObjectManagerProvider.php @@ -76,10 +76,9 @@ private function createCliCommands() { /** @var CommandListInterface $commandList */ $commandList = $this->objectManager->create(CommandListInterface::class); + $application = $this->serviceLocator->get(Application::class); foreach ($commandList->getCommands() as $command) { - $command->setApplication( - $this->serviceLocator->get(Application::class) - ); + $application->add($command); } } diff --git a/setup/src/Magento/Setup/Test/Unit/Model/ObjectManagerProviderTest.php b/setup/src/Magento/Setup/Test/Unit/Model/ObjectManagerProviderTest.php index 9d40b053e394e..552453c4a185c 100644 --- a/setup/src/Magento/Setup/Test/Unit/Model/ObjectManagerProviderTest.php +++ b/setup/src/Magento/Setup/Test/Unit/Model/ObjectManagerProviderTest.php @@ -47,6 +47,14 @@ public function setUp() public function testGet() { $initParams = ['param' => 'value']; + $commands = [ + new Command('setup:install'), + new Command('setup:upgrade'), + ]; + + $application = $this->getMockBuilder(Application::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); $this->serviceLocatorMock ->expects($this->atLeastOnce()) @@ -56,16 +64,21 @@ public function testGet() [InitParamListener::BOOTSTRAP_PARAM, $initParams], [ Application::class, - $this->getMockBuilder(Application::class)->disableOriginalConstructor()->getMock(), + $application, ], ] ); + $commandListMock = $this->createMock(CommandListInterface::class); + $commandListMock->expects($this->once()) + ->method('getCommands') + ->willReturn($commands); + $objectManagerMock = $this->createMock(ObjectManagerInterface::class); $objectManagerMock->expects($this->once()) ->method('create') ->with(CommandListInterface::class) - ->willReturn($this->getCommandListMock()); + ->willReturn($commandListMock); $objectManagerFactoryMock = $this->getMockBuilder(ObjectManagerFactory::class) ->disableOriginalConstructor() @@ -81,21 +94,9 @@ public function testGet() ->willReturn($objectManagerFactoryMock); $this->assertInstanceOf(ObjectManagerInterface::class, $this->model->get()); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject - */ - private function getCommandListMock() - { - $commandMock = $this->getMockBuilder(Command::class)->disableOriginalConstructor()->getMock(); - $commandMock->expects($this->once())->method('setApplication'); - - $commandListMock = $this->createMock(CommandListInterface::class); - $commandListMock->expects($this->once()) - ->method('getCommands') - ->willReturn([$commandMock]); - return $commandListMock; + foreach ($commands as $command) { + $this->assertSame($application, $command->getApplication()); + } } }