diff --git a/.php_cs.dist b/.php_cs.dist index 0f254c63283bd..87483d5b33a15 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -4,10 +4,6 @@ * See COPYING.txt for license details. */ -/** - * Pre-commit hook installation: - * vendor/bin/static-review.php hook:install dev/tools/Magento/Tools/StaticReview/pre-commit .git/hooks/pre-commit - */ $finder = PhpCsFixer\Finder::create() ->name('*.phtml') ->exclude('dev/tests/functional/generated') diff --git a/.travis.yml b/.travis.yml index 3265cc575cdca..6e6f3359767b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,11 +15,13 @@ language: php php: - 7.0 - 7.1 +git: + depth: 5 env: global: - COMPOSER_BIN_DIR=~/bin - INTEGRATION_SETS=3 - - NODE_JS_VERSION=6 + - NODE_JS_VERSION=8 - MAGENTO_HOST_NAME="magento2.travis" matrix: - TEST_SUITE=unit diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e94e46f89d1..7c8d6eb268a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,459 @@ +2.2.4 +============= +* GitHub issues: + * [#7691](https://github.com/magento/magento2/issues/7691) -- address with saveInAddressBook 0 are still being added to the address book for new customers (fixed in [magento/magento2#12171](https://github.com/magento/magento2/pull/12171)) + * [#9277](https://github.com/magento/magento2/issues/9277) -- Create new CLI command: enable/disable Magento Profiler (fixed in [magento/magento2#11407](https://github.com/magento/magento2/pull/11407)) + * [#11941](https://github.com/magento/magento2/issues/11941) -- Invoice for products that use qty decimal rounds down to whole number (fixed in [magento/magento2#11997](https://github.com/magento/magento2/pull/11997)) + * [#12083](https://github.com/magento/magento2/issues/12083) -- Cannot import zero (0) value into custom attribute (fixed in [magento/magento2#12283](https://github.com/magento/magento2/pull/12283)) + * [#3596](https://github.com/magento/magento2/issues/3596) -- Notice: Undefined index: value in /app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Select.php on line 72 (fixed in [magento/magento2#12296](https://github.com/magento/magento2/pull/12296)) + * [#9764](https://github.com/magento/magento2/issues/9764) -- exception message is wrong and misleading in findAccessorMethodName() of Magento\Framework\Reflection\NameFinder (fixed in [magento/magento2#12303](https://github.com/magento/magento2/pull/12303)) + * [#13214](https://github.com/magento/magento2/issues/13214) -- Not a correct displaying for Robots.txt (fixed in [magento/magento2#12310](https://github.com/magento/magento2/pull/12310)) + * [#9684](https://github.com/magento/magento2/issues/9684) -- No ACL set for integrations (fixed in [magento/magento2#12332](https://github.com/magento/magento2/pull/12332)) + * [#10438](https://github.com/magento/magento2/issues/10438) -- Potential error on order edit page when address has extension attributes (fixed in [magento/magento2#11787](https://github.com/magento/magento2/pull/11787)) + * [#11691](https://github.com/magento/magento2/issues/11691) -- Wrong return type for getAttributeText($attributeCode) (fixed in [magento/magento2#12003](https://github.com/magento/magento2/pull/12003)) + * [#12261](https://github.com/magento/magento2/issues/12261) -- Order confirmation email contains non functioning links (fixed in [magento/magento2#12308](https://github.com/magento/magento2/pull/12308)) + * [#12146](https://github.com/magento/magento2/issues/12146) -- Customer with empty "Date of Birth" cannot be saved (fixed in [magento/magento2#12302](https://github.com/magento/magento2/pull/12302)) + * [#10502](https://github.com/magento/magento2/issues/10502) -- Fatal error: Call getTranslateInline of null when generating some sitemap with errors (fixed in [magento/magento2#11320](https://github.com/magento/magento2/pull/11320)) + * [#11139](https://github.com/magento/magento2/issues/11139) -- Product Repeat Isuue after filter on category listing page. (fixed in [magento/magento2#11429](https://github.com/magento/magento2/pull/11429)) + * [#8003](https://github.com/magento/magento2/issues/8003) -- Using System Value for Base Currency Results in Config Error (fixed in [magento/magento2#11809](https://github.com/magento/magento2/pull/11809)) + * [#10347](https://github.com/magento/magento2/issues/10347) -- Wrong order tax amounts displayed when using specific tax configuration (fixed in [magento/magento2#11592](https://github.com/magento/magento2/pull/11592)) + * [#9360](https://github.com/magento/magento2/issues/9360) -- field doesn't work in system.xml for "radios" fields (fixed in [magento/magento2#11539](https://github.com/magento/magento2/pull/11539)) + * [#11792](https://github.com/magento/magento2/issues/11792) -- Can't add customizable options to product (fixed in [magento/magento2#11965](https://github.com/magento/magento2/pull/11965)) + * [#11528](https://github.com/magento/magento2/issues/11528) -- Validation prevents form closing (fixed in [magento/magento2#12048](https://github.com/magento/magento2/pull/12048)) + * [#12064](https://github.com/magento/magento2/issues/12064) -- Database Rollback not working with magento 2.1.9? (fixed in [magento/magento2#12108](https://github.com/magento/magento2/pull/12108)) + * [#9413](https://github.com/magento/magento2/issues/9413) -- Cannot remove product_list_toolbar in XML (fixed in [magento/magento2#11473](https://github.com/magento/magento2/pull/11473)) + * [#11669](https://github.com/magento/magento2/issues/11669) -- API salesRefundInvoiceV1 does no save invoice ID on credit memo (fixed in [magento/magento2#11670](https://github.com/magento/magento2/pull/11670)) + * [#11740](https://github.com/magento/magento2/issues/11740) -- Sending emails from Admin in Multi-Store Environment defaults to Primary Store (fixed in [magento/magento2#11992](https://github.com/magento/magento2/pull/11992)) + * [#9410](https://github.com/magento/magento2/issues/9410) -- Impossible to add swatch options via Service Contracts if there is no existing swatch option for attribute (fixed in [magento/magento2#12036](https://github.com/magento/magento2/pull/12036)) + * [#10707](https://github.com/magento/magento2/issues/10707) -- Create attribute option via API for swatch attribute fails (fixed in [magento/magento2#12036](https://github.com/magento/magento2/pull/12036)) + * [#10737](https://github.com/magento/magento2/issues/10737) -- Can't import attribute option over API if option is 'visual swatch' (fixed in [magento/magento2#12036](https://github.com/magento/magento2/pull/12036)) + * [#11032](https://github.com/magento/magento2/issues/11032) -- Unable to add new options to swatch attribute (fixed in [magento/magento2#12036](https://github.com/magento/magento2/pull/12036)) + * [#10128](https://github.com/magento/magento2/issues/10128) -- New Orders not being saved to order grid (fixed in [magento/magento2#12241](https://github.com/magento/magento2/pull/12241)) + * [#9515](https://github.com/magento/magento2/issues/9515) -- South Korea Zip Code Validation incorrect (fixed in [magento-engcom/magento2ce#903](https://github.com/magento-engcom/magento2ce/pull/903)) + * [#10210](https://github.com/magento/magento2/issues/10210) -- Transport variable can not be altered in email_invoice_set_template_vars_before Event (fixed in [magento/magento2#12132](https://github.com/magento/magento2/pull/12132)) + * [#11341](https://github.com/magento/magento2/issues/11341) -- Attribute category_ids issue (fixed in [magento/magento2#11389](https://github.com/magento/magento2/pull/11389)) + * [#12127](https://github.com/magento/magento2/issues/12127) -- Apostrophe in attribute option value in admin is not handled properly (fixed in [magento/magento2#12133](https://github.com/magento/magento2/pull/12133)) + * [#12058](https://github.com/magento/magento2/issues/12058) -- Can't save emoji in custom product options (fixed in [magento/magento2#12253](https://github.com/magento/magento2/pull/12253)) + * [#9742](https://github.com/magento/magento2/issues/9742) -- Default welcome message returns after being deleted (fixed in [magento/magento2#12328](https://github.com/magento/magento2/pull/12328)) + * [#9468](https://github.com/magento/magento2/issues/9468) -- REST API bundle-products/:sku/options/all always return is not authorized (fixed in [magento-engcom/magento2ce#904](https://github.com/magento-engcom/magento2ce/pull/904)) + * [#6634](https://github.com/magento/magento2/issues/6634) -- Yes/No attribute value is not shown on a product details page (fixed in [magento/magento2#12057](https://github.com/magento/magento2/pull/12057)) + * [#9961](https://github.com/magento/magento2/issues/9961) -- Unused product attributes display with value N/A or NO on storefront (fixed in [magento/magento2#12057](https://github.com/magento/magento2/pull/12057)) + * [#9931](https://github.com/magento/magento2/issues/9931) -- Empty image alt-text & missing alt attribute on product detail page (fixed in [magento/magento2#11323](https://github.com/magento/magento2/pull/11323)) + * [#11236](https://github.com/magento/magento2/issues/11236) -- Web Setup Wizard Icon Inconsistency (fixed in [magento/magento2#11388](https://github.com/magento/magento2/pull/11388)) + * [#11484](https://github.com/magento/magento2/issues/11484) -- Visual Merchandiser show prices of out of stock simple products for the associated configurable product. (fixed in [magento/magento2#11485](https://github.com/magento/magento2/pull/11485)) + * [#8255](https://github.com/magento/magento2/issues/8255) -- Export Products action doesn't consider hide_for_product_page value (fixed in [magento/magento2#11926](https://github.com/magento/magento2/pull/11926)) + * [#11509](https://github.com/magento/magento2/issues/11509) -- Psr logger debug method does not work by the default in developer mode (fixed in [magento/magento2#12207](https://github.com/magento/magento2/pull/12207)) + * [#11882](https://github.com/magento/magento2/issues/11882) -- It's not possible to enable "log to file" (debugging) in production mode (fixed in [magento/magento2#12207](https://github.com/magento/magento2/pull/12207)) + * [#9918](https://github.com/magento/magento2/issues/9918) -- Magento 2 automatically disables maintenance mode after certain actions (fixed in [magento/magento2#11052](https://github.com/magento/magento2/pull/11052)) + * [#11825](https://github.com/magento/magento2/issues/11825) -- 2.1.9 Item not added to the Wishlist if the user is not logged at the moment he click on the button to add it. (fixed in [magento/magento2#12038](https://github.com/magento/magento2/pull/12038)) + * [#11908](https://github.com/magento/magento2/issues/11908) -- Adding to wishlist doesn't work when not logged in (fixed in [magento/magento2#12038](https://github.com/magento/magento2/pull/12038)) + * [#758](https://github.com/magento/magento2/issues/758) -- Coding standards: arrays (fixed in [magento/magento2#12499](https://github.com/magento/magento2/pull/12499)) + * [#11324](https://github.com/magento/magento2/issues/11324) -- Updating a product via the REST API assigns it to all websites automatically. (fixed in [magento/magento2#11444](https://github.com/magento/magento2/pull/11444)) + * [#9633](https://github.com/magento/magento2/issues/9633) -- Web Setup Wizard 500 error when session storage is configured to use memcache (fixed in [magento/magento2#11608](https://github.com/magento/magento2/pull/11608)) + * [#6770](https://github.com/magento/magento2/issues/6770) -- M2.1.1 : Re-saving a product attribute with a different name than it's code results in an error (fixed in [magento/magento2#11617](https://github.com/magento/magento2/pull/11617)) + * [#11059](https://github.com/magento/magento2/issues/11059) -- 92 usages of expectException() with ignored $message parameter (fixed in [magento/magento2#11099](https://github.com/magento/magento2/pull/11099)) + * [#11409](https://github.com/magento/magento2/issues/11409) -- Too many password reset requests even when disabled in settings (fixed in [magento/magento2#11435](https://github.com/magento/magento2/pull/11435)) + * [#12110](https://github.com/magento/magento2/issues/12110) -- Missing cascade into attribute set deletion (fixed in [magento/magento2#12167](https://github.com/magento/magento2/pull/12167)) + * [#12268](https://github.com/magento/magento2/issues/12268) -- Gallery issues on configurable product page (fixed in [magento/magento2#12469](https://github.com/magento/magento2/pull/12469) and [magento-engcom/magento2ce#991](https://github.com/magento-engcom/magento2ce/pull/991)) + * [#12506](https://github.com/magento/magento2/issues/12506) -- Fixup typo getDispretionPath -> getDispersionPath (fixed in [magento/magento2#12507](https://github.com/magento/magento2/pull/12507)) + * [#12482](https://github.com/magento/magento2/issues/12482) -- Sitemap image links in MultiStore (fixed in [magento-engcom/magento2ce#935](https://github.com/magento-engcom/magento2ce/pull/935)) + * [#8437](https://github.com/magento/magento2/issues/8437) -- Silent error when an email template is not found (fixed in [magento-engcom/magento2ce#970](https://github.com/magento-engcom/magento2ce/pull/970)) + * [#8176](https://github.com/magento/magento2/issues/8176) -- LinkManagement::getChildren() does not include product ID's (and visibility) (fixed in [magento-engcom/magento2ce#986](https://github.com/magento-engcom/magento2ce/pull/986)) + * [#12613](https://github.com/magento/magento2/issues/12613) -- Verbiage Update Required: Product Image Watermark size Validation Message (fixed in [magento-engcom/magento2ce#985](https://github.com/magento-engcom/magento2ce/pull/985)) + * [#12180](https://github.com/magento/magento2/issues/12180) -- M2.2.1 Unable to open Address book after account creation (fixed in [magento/magento2#12220](https://github.com/magento/magento2/pull/12220)) + * [#12450](https://github.com/magento/magento2/issues/12450) -- Store not found when adding a ? to site URL. (fixed in [magento/magento2#12529](https://github.com/magento/magento2/pull/12529)) + * [#12468](https://github.com/magento/magento2/issues/12468) -- Sort by Price not working on CatalogSearch Page in Magento 2 (fixed in [magento-engcom/magento2ce#929](https://github.com/magento-engcom/magento2ce/pull/929)) + * [#7467](https://github.com/magento/magento2/issues/7467) -- File Put Contents file with empty content (fixed in [magento-engcom/magento2ce#962](https://github.com/magento-engcom/magento2ce/pull/962)) + * [#8410](https://github.com/magento/magento2/issues/8410) -- Custom Checkout Step and Shipping Step are Highlighted and Combined upon Checkout page load (fixed in [magento-engcom/magento2ce#975](https://github.com/magento-engcom/magento2ce/pull/975)) + * [#12582](https://github.com/magento/magento2/issues/12582) -- Can't remove item description from wishlist (fixed in [magento-engcom/magento2ce#981](https://github.com/magento-engcom/magento2ce/pull/981)) + * [#8862](https://github.com/magento/magento2/issues/8862) -- Can't emptying values by magento 2 api (fixed in [magento-engcom/magento2ce#916](https://github.com/magento-engcom/magento2ce/pull/916)) + * [#8011](https://github.com/magento/magento2/issues/8011) -- Strip Tags from attribute (fixed in [magento-engcom/magento2ce#968](https://github.com/magento-engcom/magento2ce/pull/968)) + * [#12526](https://github.com/magento/magento2/issues/12526) -- Currency change, Bank Transfer but checkout page shows "Your credit card will be charged for" (fixed in [magento-engcom/magento2ce#993](https://github.com/magento-engcom/magento2ce/pull/993)) + * [#12535](https://github.com/magento/magento2/issues/12535) -- Product change sku via repository (fixed in [magento-engcom/magento2ce#984](https://github.com/magento-engcom/magento2ce/pull/984)) + * [#8507](https://github.com/magento/magento2/issues/8507) -- There is invalid type in PHPDoc block of \Magento\Framework\Data\Tree::getNodeById() (fixed in [magento-engcom/magento2ce#964](https://github.com/magento-engcom/magento2ce/pull/964)) + * [#10123](https://github.com/magento/magento2/issues/10123) -- Invoice entity_model in table eav_entity_type (fixed in [magento-engcom/magento2ce#980](https://github.com/magento-engcom/magento2ce/pull/980)) + * [#9055](https://github.com/magento/magento2/issues/9055) -- Default Store is always used when retrieving sequence value's for sales entity's. (fixed in [magento/magento2#11702](https://github.com/magento/magento2/pull/11702)) + * [#8601](https://github.com/magento/magento2/issues/8601) -- Can bypass Minimum Order Amount Logic (fixed in [magento-engcom/magento2ce#963](https://github.com/magento-engcom/magento2ce/pull/963)) + * [#10797](https://github.com/magento/magento2/issues/10797) -- catalogProductTierPriceManagementV1 DELETE and POST operation wipes out media gallery selections when used on store code "all". (fixed in [magento-engcom/magento2ce#977](https://github.com/magento-engcom/magento2ce/pull/977)) + * [#12560](https://github.com/magento/magento2/issues/12560) -- Back-End issue for multi-store website: when editing Order shipping/billing address - allowed countries are selected from wrong Store View (fixed in [magento-engcom/magento2ce#982](https://github.com/magento-engcom/magento2ce/pull/982)) + * [#2907](https://github.com/magento/magento2/issues/2907) -- Integration Test Annotation magentoAppArea breaks with some valid values (fixed in [magento-engcom/magento2ce#996](https://github.com/magento-engcom/magento2ce/pull/996)) + * [#5738](https://github.com/magento/magento2/issues/5738) -- SearchCriteriaBuilder builds wrong criteria (ORDER BY part) (fixed in [magento-engcom/magento2ce#1003](https://github.com/magento-engcom/magento2ce/pull/1003)) + * [#12259](https://github.com/magento/magento2/issues/12259) -- Save and Duplicated product not working (fixed in [magento-engcom/magento2ce#983](https://github.com/magento-engcom/magento2ce/pull/983)) + * [#8204](https://github.com/magento/magento2/issues/8204) -- catalog:images:resize = getimagesize(): Read error! in vendor/magento/module-catalog/Model/Product/Image.php on line 410 if an image is 0 bytes (fixed in [magento-engcom/magento2ce#1000](https://github.com/magento-engcom/magento2ce/pull/1000)) + * [#12285](https://github.com/magento/magento2/issues/12285) -- The option false for mobile device don't work in product view page gallery (fixed in [magento-engcom/magento2ce#1006](https://github.com/magento-engcom/magento2ce/pull/1006)) + * [#12490](https://github.com/magento/magento2/issues/12490) -- I can't disable full screen gallery on mobile on magento 2.2.1 (fixed in [magento-engcom/magento2ce#1006](https://github.com/magento-engcom/magento2ce/pull/1006)) + * [#10814](https://github.com/magento/magento2/issues/10814) -- Attribute repository resets sourceModel for new attributes (fixed in [magento-engcom/magento2ce#1012](https://github.com/magento-engcom/magento2ce/pull/1012)) + * [#12632](https://github.com/magento/magento2/issues/12632) -- Magento Connect no longer exist (fixed in [magento/magento2#12633](https://github.com/magento/magento2/pull/12633)) + * [#8647](https://github.com/magento/magento2/issues/8647) -- Order of how arguments are merged in multiple di.xml-files causes unexpected results (fixed in [magento-engcom/magento2ce#995](https://github.com/magento-engcom/magento2ce/pull/995)) + * [#12378](https://github.com/magento/magento2/issues/12378) -- Regions list in Directory module for India (fixed in [magento-engcom/magento2ce#1007](https://github.com/magento-engcom/magento2ce/pull/1007)) + * [#11946](https://github.com/magento/magento2/issues/11946) -- Layer navigation showing wrong product count (fixed in [magento/magento2#12063](https://github.com/magento/magento2/pull/12063)) + * [#12452](https://github.com/magento/magento2/issues/12452) -- ACL permissions issue (fixed in [magento/magento2#12661](https://github.com/magento/magento2/pull/12661)) + * [#12660](https://github.com/magento/magento2/issues/12660) -- Invalid parameter configuration provided for $block argument upon no ACL permissions to the block (fixed in [magento/magento2#12661](https://github.com/magento/magento2/pull/12661)) + * [#12084](https://github.com/magento/magento2/issues/12084) -- Product csv import > fail on round brackets in image filename (fixed in [magento-engcom/magento2ce#1017](https://github.com/magento-engcom/magento2ce/pull/1017)) + * [#12656](https://github.com/magento/magento2/issues/12656) -- Checkout: Whitespace in front of coupon code causes "Coupon code is not valid" (fixed in [magento-engcom/magento2ce#1021](https://github.com/magento-engcom/magento2ce/pull/1021)) + * [#12667](https://github.com/magento/magento2/issues/12667) -- Incorrect partial attribute (EAV) reindex (Update by Schedule) for configurable product with childs visibility "Not Visible Individually" (fixed in [magento-engcom/magento2ce#1023](https://github.com/magento-engcom/magento2ce/pull/1023)) + * [#10743](https://github.com/magento/magento2/issues/10743) -- Magento 2 is not showing Popular Search Terms (fixed in [magento-engcom/magento2ce#1024](https://github.com/magento-engcom/magento2ce/pull/1024)) + * [#5774](https://github.com/magento/magento2/issues/5774) -- Tier price and custom options give bad results (fixed in [magento/magento2#11563](https://github.com/magento/magento2/pull/11563)) + * [#8615](https://github.com/magento/magento2/issues/8615) -- REST API unable to make requests with slash (/) in SKU (fixed in [magento-engcom/magento2ce#949](https://github.com/magento-engcom/magento2ce/pull/949)) + * [#10133](https://github.com/magento/magento2/issues/10133) -- Please add your expectations for @deprecated annotations (fixed in [magento/magento2#11070](https://github.com/magento/magento2/pull/11070)) + * [#12713](https://github.com/magento/magento2/issues/12713) -- Currency symbol overlaps entered attribute option's price while creating Configurable Product (fixed in [magento/magento2#12730](https://github.com/magento/magento2/pull/12730)) + * [#9453](https://github.com/magento/magento2/issues/9453) -- Reopened: '?SID' in URL even if disabled (fixed in [magento/magento2#12743](https://github.com/magento/magento2/pull/12743)) + * [#9720](https://github.com/magento/magento2/issues/9720) -- Menu item dependencies (dependsOnModule, dependsOnConfig) are broken (fixed in [magento/magento2#12747](https://github.com/magento/magento2/pull/12747)) + * [#6965](https://github.com/magento/magento2/issues/6965) -- \Magento\Directory\Model\PriceCurrency::format() fails without conversion rate (fixed in [magento-engcom/magento2ce#1022](https://github.com/magento-engcom/magento2ce/pull/1022)) + * [#12627](https://github.com/magento/magento2/issues/12627) -- Referer is not added to login url in checkout config (fixed in [magento/magento2#12630](https://github.com/magento/magento2/pull/12630)) + * [#12206](https://github.com/magento/magento2/issues/12206) -- Tracking link returns 404 page in admin panel (fixed in [magento/magento2#12732](https://github.com/magento/magento2/pull/12732)) + * [#6113](https://github.com/magento/magento2/issues/6113) -- Validate range-words in Form component (UI Component) (fixed in [magento/magento2#12739](https://github.com/magento/magento2/pull/12739)) + * [#12719](https://github.com/magento/magento2/issues/12719) -- Welcome message is shown with customer's first and last names after confirming account (fixed in [magento/magento2#12738](https://github.com/magento/magento2/pull/12738)) + * [#5035](https://github.com/magento/magento2/issues/5035) -- I can not to subscribe on change of all sections in Stores ->Configuration using event admin_system_config_changed_section (fixed in [magento/magento2#12758](https://github.com/magento/magento2/pull/12758)) + * [#12715](https://github.com/magento/magento2/issues/12715) -- Storefront Back to Sign in button does not work as expected (fixed in [magento/magento2#12759](https://github.com/magento/magento2/pull/12759)) + * [#11743](https://github.com/magento/magento2/issues/11743) -- AbstractPdf - ZendException font is not set (fixed in [magento-engcom/magento2ce#1016](https://github.com/magento-engcom/magento2ce/pull/1016)) + * [#7241](https://github.com/magento/magento2/issues/7241) -- No option to start with blank option for prefix and suffix in checkout. (fixed in [magento/magento2#11462](https://github.com/magento/magento2/pull/11462)) + * [#5188](https://github.com/magento/magento2/issues/5188) -- Error generating URN-catalog when blank one exists (fixed in [magento/magento2#11686](https://github.com/magento/magento2/pull/11686)) + * [#11936](https://github.com/magento/magento2/issues/11936) -- required attribute set id filter on attribute group repository getList (fixed in [magento/magento2#12105](https://github.com/magento/magento2/pull/12105)) + * [#12625](https://github.com/magento/magento2/issues/12625) -- when saving a page in magento 2.2.1, 'Modified' date field is not getting updated (fixed in [magento/magento2#12636](https://github.com/magento/magento2/pull/12636)) + * [#11953](https://github.com/magento/magento2/issues/11953) -- Product configuration creator does not warn about invalid SKUs (fixed in [magento/magento2#12737](https://github.com/magento/magento2/pull/12737)) + * [#12439](https://github.com/magento/magento2/issues/12439) -- Newsletter subscription success email not sent after confirmation (fixed in [magento/magento2#12751](https://github.com/magento/magento2/pull/12751)) + * [#8830](https://github.com/magento/magento2/issues/8830) -- Can`t delete row in dynamicRows component (fixed in [magento-engcom/magento2ce#921](https://github.com/magento-engcom/magento2ce/pull/921)) + * [#12712](https://github.com/magento/magento2/issues/12712) -- Latest Google Chrome Browser issue with duplicate #email (fixed in [magento-engcom/magento2ce#1036](https://github.com/magento-engcom/magento2ce/pull/1036)) + * [#6916](https://github.com/magento/magento2/issues/6916) -- Update Bundle Product without changes in bundle items (fixed in [magento/magento2#12734](https://github.com/magento/magento2/pull/12734)) + * [#12374](https://github.com/magento/magento2/issues/12374) -- Model hasDataChanges always true (fixed in [magento/magento2#12736](https://github.com/magento/magento2/pull/12736)) + * [#11885](https://github.com/magento/magento2/issues/11885) -- Magento 2.2 Paypal Can't Accept Checkout Agreements Before Routing to PayPal (fixed in [magento/magento2#12401](https://github.com/magento/magento2/pull/12401)) + * [#12844](https://github.com/magento/magento2/issues/12844) -- "Cannot instantiate interface Magento\Framework\Interception\ObjectManager\ConfigInterface" error in integration tests (fixed in [magento/magento2#12845](https://github.com/magento/magento2/pull/12845)) + * [#12294](https://github.com/magento/magento2/issues/12294) -- Bug: Adding Custom Attribute - The value of Admin scope can't be empty (fixed in [magento/magento2#12755](https://github.com/magento/magento2/pull/12755)) + * [#12900](https://github.com/magento/magento2/issues/12900) -- Braintree "Place Order" button is disabled after failed validation (fixed in [magento/magento2#12902](https://github.com/magento/magento2/pull/12902)) + * [#12555](https://github.com/magento/magento2/issues/12555) -- Naming collision in Javascript ui registry (backend) (fixed in [magento/magento2#12945](https://github.com/magento/magento2/pull/12945)) + * [#4292](https://github.com/magento/magento2/issues/4292) -- Why can't one switch back to default mode ? (fixed in [magento/magento2#12752](https://github.com/magento/magento2/pull/12752)) + * [#2156](https://github.com/magento/magento2/issues/2156) -- Why does \Magento\Translation\Model\Js\DataProvider use \Magento\Framework\Phrase\Renderer\Translate, not \Magento\Framework\Phrase\Renderer\Composite? (fixed in [magento/magento2#12953](https://github.com/magento/magento2/pull/12953)) + * [#7441](https://github.com/magento/magento2/issues/7441) -- Configurable attribute options are not sorted (fixed in [magento/magento2#12963](https://github.com/magento/magento2/pull/12963)) + * [#10869](https://github.com/magento/magento2/issues/10869) -- field lengths differ across many tables (fixed in [magento/magento2#13015](https://github.com/magento/magento2/pull/13015)) + * [#12446](https://github.com/magento/magento2/issues/12446) -- Remove /home from the sitemap.xml (fixed in [magento/magento2#12649](https://github.com/magento/magento2/pull/12649)) + * [#12894](https://github.com/magento/magento2/issues/12894) -- Can't remove State is required for all countries (fixed in [magento/magento2#12917](https://github.com/magento/magento2/pull/12917)) + * [#12393](https://github.com/magento/magento2/issues/12393) -- Attribute with "Catalog Input Type for Store Owner" equal "Fixed Product Tax" for Multi-store (fixed in [magento/magento2#13019](https://github.com/magento/magento2/pull/13019)) + * [#9036](https://github.com/magento/magento2/issues/9036) -- Database backup doesn't include triggers (fixed in [magento/magento2#11369](https://github.com/magento/magento2/pull/11369)) + * [#12209](https://github.com/magento/magento2/issues/12209) -- Substitution payment method - Incorrect message (fixed in [magento/magento2#12731](https://github.com/magento/magento2/pull/12731)) + * [#10415](https://github.com/magento/magento2/issues/10415) -- Customer First and Last names not being trimmed of leading and trailing spaces on save. (fixed in [magento/magento2#12964](https://github.com/magento/magento2/pull/12964)) + * [#12601](https://github.com/magento/magento2/issues/12601) -- A space between the category page and the main footer when applying specific settings (fixed in [magento/magento2#13026](https://github.com/magento/magento2/pull/13026)) + * [#12320](https://github.com/magento/magento2/issues/12320) -- Newsletter subscribe button title wrapped (fixed in [magento/magento2#13041](https://github.com/magento/magento2/pull/13041) and [magento/magento2#13029](https://github.com/magento/magento2/pull/13029)) + * [#11796](https://github.com/magento/magento2/issues/11796) -- Magento2.2.0 home page product grid issues (fixed in [magento/magento2#13081](https://github.com/magento/magento2/pull/13081)) + * [#12828](https://github.com/magento/magento2/issues/12828) -- Uncaught Error: Script error for: trackingCode error on every frontend page (fixed in [magento/magento2#13061](https://github.com/magento/magento2/pull/13061)) + * [#5129](https://github.com/magento/magento2/issues/5129) -- Product details page zoom issue when dropdown menu have overlap area with it. (fixed in [magento/magento2#13084](https://github.com/magento/magento2/pull/13084)) + * [#6486](https://github.com/magento/magento2/issues/6486) -- Unable to save certain product properties via Rest API (fixed in [magento-engcom/magento2ce#1018](https://github.com/magento-engcom/magento2ce/pull/1018)) + * [#9969](https://github.com/magento/magento2/issues/9969) -- Cancel order and restore quote methods increase stocks twice (fixed in [magento/magento2#12668](https://github.com/magento/magento2/pull/12668)) + * [#12221](https://github.com/magento/magento2/issues/12221) -- Google analytics pageview being triggered twice (fixed in [magento/magento2#13034](https://github.com/magento/magento2/pull/13034)) + * [#12705](https://github.com/magento/magento2/issues/12705) -- Integrity constraint violation error after reordering product with custom options (fixed in [magento/magento2#13036](https://github.com/magento/magento2/pull/13036)) + * [#12876](https://github.com/magento/magento2/issues/12876) -- Multiple newsletter confirmation emails sent (fixed in [magento/magento2#13044](https://github.com/magento/magento2/pull/13044)) + * [#8114](https://github.com/magento/magento2/issues/8114) -- "Save Block"-button on Add New Block silently ignores clicks if the content is empty. (fixed in [magento-engcom/magento2ce#1032](https://github.com/magento-engcom/magento2ce/pull/1032)) + * [#8453](https://github.com/magento/magento2/issues/8453) -- Price outlining in Invoice PDF (fixed in [magento-engcom/magento2ce#1216](https://github.com/magento-engcom/magento2ce/pull/1216)) + * [#12967](https://github.com/magento/magento2/issues/12967) -- Undeclared dependency magento/zendframework1 by magento/framework (fixed in [magento/magento2#12990](https://github.com/magento/magento2/pull/12990)) + * [#12787](https://github.com/magento/magento2/issues/12787) -- Newsletter\Model\Subscriber::loadByEmail() does not use MySQL index (fixed in [magento/magento2#13033](https://github.com/magento/magento2/pull/13033)) + * [#12877](https://github.com/magento/magento2/issues/12877) -- [2.2.1] Magento Database Backup Command Fails (Fix included) (fixed in [magento/magento2#13066](https://github.com/magento/magento2/pull/13066)) + * [#5550](https://github.com/magento/magento2/issues/5550) -- Incorrect language on swatch error (fixed in [magento-engcom/magento2ce#1117](https://github.com/magento-engcom/magento2ce/pull/1117)) + * [#11828](https://github.com/magento/magento2/issues/11828) -- Visual Swatches not showing swatch color in admin (fixed in [magento/magento2#13101](https://github.com/magento/magento2/pull/13101)) + * [#13095](https://github.com/magento/magento2/issues/13095) -- No locale for Swedish (Finland) (fixed in [magento-engcom/magento2ce#1207](https://github.com/magento-engcom/magento2ce/pull/1207)) + * [#11428](https://github.com/magento/magento2/issues/11428) -- Cart Price Rule Label is not working (fixed in [magento/magento2#13141](https://github.com/magento/magento2/pull/13141)) + * [#11497](https://github.com/magento/magento2/issues/11497) -- Discount Rule does not show Default Rule Label (fixed in [magento/magento2#13141](https://github.com/magento/magento2/pull/13141)) + * [#12430](https://github.com/magento/magento2/issues/12430) -- While assigning prices to configurable products, prices aren's readable when using custom price symbol. (fixed in [magento/magento2#13025](https://github.com/magento/magento2/pull/13025)) + * [#12322](https://github.com/magento/magento2/issues/12322) -- Bug with CDATA in XML layout update (fixed in [magento-engcom/magento2ce#1163](https://github.com/magento-engcom/magento2ce/pull/1163)) + * [#12714](https://github.com/magento/magento2/issues/12714) -- Extra records are in exported CSV file for order (fixed in [magento/magento2#13208](https://github.com/magento/magento2/pull/13208)) + * [#8624](https://github.com/magento/magento2/issues/8624) -- Stock status not coming back after qty update (fixed in [magento-engcom/magento2ce#955](https://github.com/magento-engcom/magento2ce/pull/955)) + * [#11897](https://github.com/magento/magento2/issues/11897) -- Catalog product list widget not working with multiple sku (fixed in [magento-engcom/magento2ce#1050](https://github.com/magento-engcom/magento2ce/pull/1050)) + * [#12147](https://github.com/magento/magento2/issues/12147) -- The function "isUsingStaticUrlsAllowed" (configuration setting "cms/wysiwyg/use_static_urls_in_catalog") doesn't have any effect with the WYSIWYG editor image insertion (fixed in [magento-engcom/magento2ce#1215](https://github.com/magento-engcom/magento2ce/pull/1215)) + * [#12819](https://github.com/magento/magento2/issues/12819) -- CartTotalRepository cannot handle extension attributes in quote addresses in 2.2.2 (fixed in [magento-engcom/magento2ce#1186](https://github.com/magento-engcom/magento2ce/pull/1186)) + * [#12993](https://github.com/magento/magento2/issues/12993) -- Type error in Cart/Totals (fixed in [magento-engcom/magento2ce#1186](https://github.com/magento-engcom/magento2ce/pull/1186)) + * [#12342](https://github.com/magento/magento2/issues/12342) -- JSTestDriver removal (fixed in [magento/magento2#12406](https://github.com/magento/magento2/pull/12406)) + * [#13126](https://github.com/magento/magento2/issues/13126) -- 2.2.2 - Duplicating Bundle Product Removes Bundle Options From Original Product (fixed in [magento-engcom/magento2ce#1217](https://github.com/magento-engcom/magento2ce/pull/1217)) + * [#7768](https://github.com/magento/magento2/issues/7768) -- Adding 'is_saleable' attribute to sort of product collection causes exception and adding 'is_salable' has no effect (fixed in [magento-engcom/magento2ce#1045](https://github.com/magento-engcom/magento2ce/pull/1045)) + * [#12231](https://github.com/magento/magento2/issues/12231) -- New Cart Rule : Small styles issue because of styles-old.css (fixed in [magento-engcom/magento2ce#1146](https://github.com/magento-engcom/magento2ce/pull/1146)) + * [#5697](https://github.com/magento/magento2/issues/5697) -- [2.1.0] Misleading feedback when sending tracking information email (fixed in [magento-engcom/magento2ce#1245](https://github.com/magento-engcom/magento2ce/pull/1245)) + * [#7213](https://github.com/magento/magento2/issues/7213) -- WEBAPI: PHP session is always started 2.1.2 (fixed in [magento-engcom/magento2ce#1247](https://github.com/magento-engcom/magento2ce/pull/1247)) + * [#5948](https://github.com/magento/magento2/issues/5948) -- Magento 2 configurable product selection stock issue (fixed in [magento/magento2#12936](https://github.com/magento/magento2/pull/12936)) + * [#10661](https://github.com/magento/magento2/issues/10661) -- Opacity png watermark became white box on product images (fixed in [magento/magento2#11060](https://github.com/magento/magento2/pull/11060)) + * [#13327](https://github.com/magento/magento2/issues/13327) -- Menu ui-state-active not removed from previous opened menu item (fixed in [magento/magento2#13341](https://github.com/magento/magento2/pull/13341)) + * [#8621](https://github.com/magento/magento2/issues/8621) -- M2.1 Multishipping Checkout step New Address - Old State is saved when country is changed (fixed in [magento/magento2#13364](https://github.com/magento/magento2/pull/13364)) + * [#7760](https://github.com/magento/magento2/issues/7760) -- M2.1.2 : Shipment Tracking REST API should throw an error if order doesn't exist. (fixed in [magento-engcom/magento2ce#1162](https://github.com/magento-engcom/magento2ce/pull/1162)) + * [#7849](https://github.com/magento/magento2/issues/7849) -- M2.x.x Translation Missing in Checkout for Tax (fixed in [magento-engcom/magento2ce#1147](https://github.com/magento-engcom/magento2ce/pull/1147)) + * [#12860](https://github.com/magento/magento2/issues/12860) -- Sort by Product Name doesn't work with Ancor and available filters (fixed in [magento-engcom/magento2ce#1192](https://github.com/magento-engcom/magento2ce/pull/1192)) + * [#7848](https://github.com/magento/magento2/issues/7848) -- M2.1.x : Require Customer To Be Logged In To Checkout (fixed in [magento-engcom/magento2ce#1148](https://github.com/magento-engcom/magento2ce/pull/1148)) + * [#11527](https://github.com/magento/magento2/issues/11527) -- Notification messages not disappearing after being displayed (fixed in [magento-engcom/magento2ce#1111](https://github.com/magento-engcom/magento2ce/pull/1111)) + * [#7698](https://github.com/magento/magento2/issues/7698) -- Admin Global Search was build in a hurry (fixed in [magento-engcom/magento2ce#1167](https://github.com/magento-engcom/magento2ce/pull/1167)) + * [#12574](https://github.com/magento/magento2/issues/12574) -- ConfigurationTest fails when installing via composer (fixed in [magento-engcom/magento2ce#1161](https://github.com/magento-engcom/magento2ce/pull/1161)) + * [#11798](https://github.com/magento/magento2/issues/11798) -- Magento 2.1.9 - Refunding / Credit Memo Total Value is not updated (fixed in [magento-engcom/magento2ce#1185](https://github.com/magento-engcom/magento2ce/pull/1185)) + * [#13497](https://github.com/magento/magento2/issues/13497) -- Method getUrl in Magento\Catalog\Model\Product\Attribute\Frontend returns image url with double slash (fixed in [magento/magento2#13498](https://github.com/magento/magento2/pull/13498)) + * [#12081](https://github.com/magento/magento2/issues/12081) -- Magento 2.2.0: Translations for 'Item in Cart' missing in mini cart. (fixed in [magento/magento2#13528](https://github.com/magento/magento2/pull/13528)) + * [#11252](https://github.com/magento/magento2/issues/11252) -- Custom attribute - File not allowing uploads (fixed in [magento/magento2#13563](https://github.com/magento/magento2/pull/13563)) + * [#12817](https://github.com/magento/magento2/issues/12817) -- Coupon code with canceled order (fixed in [magento-engcom/magento2ce#1095](https://github.com/magento-engcom/magento2ce/pull/1095)) + * [#11963](https://github.com/magento/magento2/issues/11963) -- Magento 2.2 language switching not working on catalog and Product Pages (fixed in [magento-engcom/magento2ce#1143](https://github.com/magento-engcom/magento2ce/pull/1143)) + * [#12791](https://github.com/magento/magento2/issues/12791) -- Customer & Product Tax class wrongly styled (fixed in [magento/magento2#13643](https://github.com/magento/magento2/pull/13643)) + * [#13429](https://github.com/magento/magento2/issues/13429) -- Magento 2.2.2 password reset strength meter (fixed in [magento/magento2#13761](https://github.com/magento/magento2/pull/13761)) + * [#13760](https://github.com/magento/magento2/issues/13760) -- Remove deprecated Brazilian currencies in the setup process (fixed in [magento/magento2#13770](https://github.com/magento/magento2/pull/13770)) + * [#5451](https://github.com/magento/magento2/issues/5451) -- Rating titles with whitespace results in broken ID attributes (fixed in [magento-engcom/magento2ce#1119](https://github.com/magento-engcom/magento2ce/pull/1119)) + * [#8035](https://github.com/magento/magento2/issues/8035) -- Join extension attributes are not added to Order results (REST api) (fixed in [magento-engcom/magento2ce#1168](https://github.com/magento-engcom/magento2ce/pull/1168)) + * [#13595](https://github.com/magento/magento2/issues/13595) -- loadCache for Block Magento\Theme\Block\Html\Footer dont work (fixed in [magento/magento2#13762](https://github.com/magento/magento2/pull/13762)) + * [#10595](https://github.com/magento/magento2/issues/10595) -- Low Stock Report Grid Empty (fixed in [magento/magento2#13682](https://github.com/magento/magento2/pull/13682)) + * [#13315](https://github.com/magento/magento2/issues/13315) -- Mobile "Payment Methods" step looks bad on mobile (fixed in [magento/magento2#13777](https://github.com/magento/magento2/pull/13777)) + * [#13791](https://github.com/magento/magento2/issues/13791) -- Submitting search form (mini) with empty value throws error on preventDefault (fixed in [magento/magento2#13811](https://github.com/magento/magento2/pull/13811)) + * [#12711](https://github.com/magento/magento2/issues/12711) -- Default Welcome message is broken on storefront with enabled translate-inline (fixed in [magento/magento2#13038](https://github.com/magento/magento2/pull/13038)) + * [#5863](https://github.com/magento/magento2/issues/5863) -- URL Rewrite issues occur very often /catalog/product/view/id/711/s/product-name/category/16/ (fixed in [magento/magento2#13567](https://github.com/magento/magento2/pull/13567)) + * [#8227](https://github.com/magento/magento2/issues/8227) -- After upgrade to 2.1.3 url rewrite problem multi store (fixed in [magento/magento2#13567](https://github.com/magento/magento2/pull/13567)) + * [#8957](https://github.com/magento/magento2/issues/8957) -- Permanent Redirect for old URL missing via API (fixed in [magento/magento2#13567](https://github.com/magento/magento2/pull/13567)) + * [#10073](https://github.com/magento/magento2/issues/10073) -- Magento don't create product redirect if URL key on store view level was changed. (fixed in [magento/magento2#13567](https://github.com/magento/magento2/pull/13567)) + * [#13240](https://github.com/magento/magento2/issues/13240) -- Permanent 301 redirect is not generated when product url changes on storeview scope (fixed in [magento/magento2#13567](https://github.com/magento/magento2/pull/13567)) + * [#13768](https://github.com/magento/magento2/issues/13768) -- Expired backend password - Attention: Something went wrong (fixed in [magento/magento2#13787](https://github.com/magento/magento2/pull/13787)) + * [#4454](https://github.com/magento/magento2/issues/4454) -- CMS Page with in layout update xml (fixed in [magento/magento2#13817](https://github.com/magento/magento2/pull/13817)) + * [#13350](https://github.com/magento/magento2/issues/13350) -- Magento 2.2 Encoding Issue -> Google Analytics (fixed in [magento/magento2#13844](https://github.com/magento/magento2/pull/13844)) + * [#13827](https://github.com/magento/magento2/issues/13827) -- Google Analytics character encoding issue ( \u0020 ) (fixed in [magento/magento2#13844](https://github.com/magento/magento2/pull/13844)) + * [#7765](https://github.com/magento/magento2/issues/7765) -- Filter block on category is still present also mode is to just show "static block" (fixed in [magento-engcom/magento2ce#1159](https://github.com/magento-engcom/magento2ce/pull/1159)) + * [#11512](https://github.com/magento/magento2/issues/11512) -- Incorrect use of 503 status code (fixed in [magento/magento2#11513](https://github.com/magento/magento2/pull/11513)) + * [#12889](https://github.com/magento/magento2/issues/12889) -- Wrong shipping fee in backend with multiple store views (fixed in [magento-engcom/magento2ce#1132](https://github.com/magento-engcom/magento2ce/pull/1132)) + * [#13216](https://github.com/magento/magento2/issues/13216) -- `quoteAddressToFormAddressData` mutates the argument (fixed in [magento/magento2#13217](https://github.com/magento/magento2/pull/13217)) + * [#13631](https://github.com/magento/magento2/issues/13631) -- Totals sort order is not respected in customer account order view (fixed in [magento/magento2#13641](https://github.com/magento/magento2/pull/13641)) + * [#7515](https://github.com/magento/magento2/issues/7515) -- Error when submit customer/account/editPost form and session expired (fixed in [magento-engcom/magento2ce#1187](https://github.com/magento-engcom/magento2ce/pull/1187)) + * [#12404](https://github.com/magento/magento2/issues/12404) -- Output of setup:static-content:deploy contains red color, should be a friendlier color (fixed in [magento/magento2#13709](https://github.com/magento/magento2/pull/13709)) + * [#13006](https://github.com/magento/magento2/issues/13006) -- Drop down values are not showing in catalog product grid magento2 (fixed in [magento/magento2#13861](https://github.com/magento/magento2/pull/13861)) + * [#13899](https://github.com/magento/magento2/issues/13899) -- Postal code (zip code) for Canada should allow postal codes without space (fixed in [magento/magento2#13930](https://github.com/magento/magento2/pull/13930)) +* GitHub pull requests: + * [magento/magento2#12171](https://github.com/magento/magento2/pull/12171) -- 7691: address with saveInAddressBook 0 are still being added to the address book for new customers(backport to 2.2) (by @RomaKis) + * [magento/magento2#12239](https://github.com/magento/magento2/pull/12239) -- Fixed php notice when invalid ui_component config is used (by @vovayatsyuk) + * [magento/magento2#11407](https://github.com/magento/magento2/pull/11407) -- Added CLI command to enable and disable the Profiler (by @peterjaap) + * [magento/magento2#12257](https://github.com/magento/magento2/pull/12257) -- Phpdoc improvements (by @KarlDeux) + * [magento/magento2#11997](https://github.com/magento/magento2/pull/11997) -- 11941: Invoice for products that use qty decimal rounds down to whole number. (by @nmalevanec) + * [magento/magento2#12283](https://github.com/magento/magento2/pull/12283) -- magento/magento2#12083: Cannot import zero (0) value into custom attribute (by @p-bystritsky) + * [magento/magento2#12296](https://github.com/magento/magento2/pull/12296) -- Issue: 3596. Resolve Notice with undefined index 'value' (by @madonzy) + * [magento/magento2#12303](https://github.com/magento/magento2/pull/12303) -- 9764: exception message is wrong and misleading in findAccessorMethodName() of Magento\Framework\Reflection\NameFinder (by @RomaKis) + * [magento/magento2#12304](https://github.com/magento/magento2/pull/12304) -- Handle empty or incorrect lines in a language CSV (by @FreekVandeursen) + * [magento/magento2#12276](https://github.com/magento/magento2/pull/12276) -- Webshop throws an exception when sharing wishlist with RSS enabled (by @mediactbv) + * [magento/magento2#12310](https://github.com/magento/magento2/pull/12310) -- Fix robots.txt content type to 'text/plain' (by @tufahu) + * [magento/magento2#12332](https://github.com/magento/magento2/pull/12332) -- 9684: No ACL set for integrations (by @RomaKis) + * [magento/magento2#11787](https://github.com/magento/magento2/pull/11787) -- Fix #10438: Potential error on order edit page when address has extension attributes (by @joni-jones) + * [magento/magento2#12003](https://github.com/magento/magento2/pull/12003) -- magento/magento2#11691: Wrong return type for getAttributeText($attributeCode) (by @p-bystritsky) + * [magento/magento2#12308](https://github.com/magento/magento2/pull/12308) -- 12261: Order confirmation email contains non functioning links #12261 (by @RomaKis) + * [magento/magento2#12302](https://github.com/magento/magento2/pull/12302) -- Fixed 'Non-numeric value' warning on account create/save when DOB field is visible (by @vovayatsyuk) + * [magento/magento2#11320](https://github.com/magento/magento2/pull/11320) -- Fix email not sent when sitemap generation has errors (by @marinagociu) + * [magento/magento2#11429](https://github.com/magento/magento2/pull/11429) -- Magento 2.2.0 A solution for Product Repeat Issue after filter on category listing page. (by @mayankzalavadia) + * [magento/magento2#11550](https://github.com/magento/magento2/pull/11550) -- Even existing credit memos should be refundable if their state is open (by @ajpevers) + * [magento/magento2#11809](https://github.com/magento/magento2/pull/11809) -- 8003: Using System Value for Base Currency Results in Config Error. (by @nmalevanec) + * [magento/magento2#11592](https://github.com/magento/magento2/pull/11592) -- Fix issue #10347 - Wrong order tax amounts displayed when using specific tax configuration (2.2-develop) (by @PieterCappelle) + * [magento/magento2#11539](https://github.com/magento/magento2/pull/11539) -- Fix depends field not working for radio elements (by @jahvi) + * [magento/magento2#11846](https://github.com/magento/magento2/pull/11846) -- Fixed a js bug where ui_component labels have the wrong sort order. (by @deiserh) + * [magento/magento2#11965](https://github.com/magento/magento2/pull/11965) -- 11792: Can't add customizable options to product (by @RomaKis) + * [magento/magento2#12048](https://github.com/magento/magento2/pull/12048) -- #11528 can't save customizable options (by @luismiguelyangehuaman) + * [magento/magento2#12108](https://github.com/magento/magento2/pull/12108) -- 12064: Database Rollback not working with magento 2.1.9? (by @RomaKis) + * [magento/magento2#12387](https://github.com/magento/magento2/pull/12387) -- Update CAPTCHA labels to reflect the symbols in the CAPTCHA image (by @RhodriOwainDavies) + * [magento/magento2#12120](https://github.com/magento/magento2/pull/12120) -- Update AbstractBackend.php (by @hewersonfreitas) + * [magento/magento2#12154](https://github.com/magento/magento2/pull/12154) -- Add link to issue gates wiki page in the labels section of the readme (by @dmanners) + * [magento/magento2#11422](https://github.com/magento/magento2/pull/11422) -- [Backport 2.2] Translate order getCreatedAtFormatted() to store locale (by @JeroenVanLeusden) + * [magento/magento2#11473](https://github.com/magento/magento2/pull/11473) -- Fix for remove 'product_list_toolbar' block from layout in XML #9413 (by @mariuscris) + * [magento/magento2#11670](https://github.com/magento/magento2/pull/11670) -- save invoice ID on credit memo when using API method salesRefundInvoiceV1 (by @ajpevers) + * [magento/magento2#11992](https://github.com/magento/magento2/pull/11992) -- 11740: Sending emails from Admin in Multi-Store Environment defaults to Primary Store (by @RomaKis) + * [magento/magento2#12036](https://github.com/magento/magento2/pull/12036) -- Add swatch option: Prevent loosing data and default value if data is not populated via adminhtml (by @gomencal) + * [magento/magento2#12227](https://github.com/magento/magento2/pull/12227) -- Shipping method fixtures not compatible with getShippingMethod(true) in OrderCreateTest (by @andrew-garside-temando) + * [magento/magento2#12241](https://github.com/magento/magento2/pull/12241) -- 10128: New Orders not being saved to order grid (by @RomaKis) + * [magento/magento2#12132](https://github.com/magento/magento2/pull/12132) -- 10210: Transport variable can not be altered in email_invoice_set_template_vars_before Event (backport MAGETWO-69482 to 2.2) (by @RomaKis) + * [magento/magento2#11389](https://github.com/magento/magento2/pull/11389) -- Attribute category_ids issue (by @manuelson) + * [magento/magento2#12133](https://github.com/magento/magento2/pull/12133) -- Fix for issue 12127: Single quotation marks are now decoded properly in admin attribute option input fields (by @erfanimani) + * [magento/magento2#12253](https://github.com/magento/magento2/pull/12253) -- New validation: 3bytes characters filter (4 bytes characters cannot be stored using UTF8) (by @KarlDeux) + * [magento/magento2#12328](https://github.com/magento/magento2/pull/12328) -- 9742: Default welcome message returns after being deleted #9742 (by @RomaKis) + * [magento/magento2#12057](https://github.com/magento/magento2/pull/12057) -- [Backport] magento/magento2#9961: Unused product attributes display with value N/A or NO on storefront. (by @p-bystritsky) + * [magento/magento2#12441](https://github.com/magento/magento2/pull/12441) -- Add command "app:config:status" to check if "app:config:import" needed (by @jalogut) + * [magento/magento2#12443](https://github.com/magento/magento2/pull/12443) -- Fixed missing 'size' and 'type' props on a third-party category images [Backport 2.2] (by @vovayatsyuk) + * [magento/magento2#12495](https://github.com/magento/magento2/pull/12495) -- Fixed invalid parameter type in phpdoc block in Topmenu class (by @vovayatsyuk) + * [magento/magento2#11323](https://github.com/magento/magento2/pull/11323) -- Defaulting missing alt-text for a product to use the product name. (by @brobie) + * [magento/magento2#11388](https://github.com/magento/magento2/pull/11388) -- Fix #11236: Web Setup Wizard Icon Inconsistency (by @dverkade) + * [magento/magento2#11485](https://github.com/magento/magento2/pull/11485) -- do the stock check on default level because the stock on website leve… (by @joost-florijn-kega) + * [magento/magento2#11926](https://github.com/magento/magento2/pull/11926) -- 8255: Export Products action doesn't consider hide_for_product_page value. (by @nmalevanec) + * [magento/magento2#12207](https://github.com/magento/magento2/pull/12207) -- 11882: It's not possible to enable "log to file" (debugging) in production mode. Psr logger debug method does not work by the default in developer mode. (by @nmalevanec) + * [magento/magento2#11052](https://github.com/magento/magento2/pull/11052) -- Keep maintenance mode on if it was previously enabled (by @jokeputs) + * [magento/magento2#12038](https://github.com/magento/magento2/pull/12038) -- #11825: Generate new FormKey and replace for oldRequestParams Wishlist (by @osrecio) + * [magento/magento2#12161](https://github.com/magento/magento2/pull/12161) -- Fix delay initialization options for customized JQuery UI menu widget (by @scazz010) + * [magento/magento2#12466](https://github.com/magento/magento2/pull/12466) -- Category page X-Magento-Tags headers contains product cache identities even which category display mode is set to "Static block only" (by @atishgoswami) + * [magento/magento2#12515](https://github.com/magento/magento2/pull/12515) -- The left and the right parts of assignment are equal (by @lfluvisotto) + * [magento/magento2#12499](https://github.com/magento/magento2/pull/12499) -- Format generated config files using the short array syntax (by @cykirsch) + * [magento/magento2#12513](https://github.com/magento/magento2/pull/12513) -- Duplicate array key (by @lfluvisotto) + * [magento/magento2#12516](https://github.com/magento/magento2/pull/12516) -- Case mismatch (by @lfluvisotto) + * [magento/magento2#11444](https://github.com/magento/magento2/pull/11444) -- [Backport 2.2-develop] #11324 REST API - Only associate automatically product with all websites when creating product in All Store Views scope (by @adrian-martinez-interactiv4) + * [magento/magento2#11608](https://github.com/magento/magento2/pull/11608) -- Fix for issue 9633 500 error on setup wizard with memcache (by @sylink) + * [magento/magento2#11617](https://github.com/magento/magento2/pull/11617) -- Re saving product attribute (by @raumatbel) + * [magento/magento2#12359](https://github.com/magento/magento2/pull/12359) -- Add a --no-update option to sampledata:deploy and sampledata:remove commands (by @schmengler) + * [magento/magento2#12530](https://github.com/magento/magento2/pull/12530) -- Added correction for og:type content value (by @atishgoswami) + * [magento/magento2#11099](https://github.com/magento/magento2/pull/11099) -- Fix syntax of expectException() calls (by @schmengler) + * [magento/magento2#11435](https://github.com/magento/magento2/pull/11435) -- [Backport 2.2-develop] #11409: Too many password reset requests even when disabled in settings (by @adrian-martinez-interactiv4) + * [magento/magento2#12122](https://github.com/magento/magento2/pull/12122) -- [2.2] - Add command to view mview state and queue (by @convenient) + * [magento/magento2#12167](https://github.com/magento/magento2/pull/12167) -- 12110: Missing cascade into attribute set deletion. (by @nmalevanec) + * [magento/magento2#12469](https://github.com/magento/magento2/pull/12469) -- Added namespace to product videos fotorama events (by @roma84) + * [magento/magento2#12507](https://github.com/magento/magento2/pull/12507) -- Issue 12506: Fixup typo getDispretionPath -> getDispersionPath (by @PascalBrouwers) + * [magento/magento2#12539](https://github.com/magento/magento2/pull/12539) -- Trying to get data from non existent products (by @angelo983) + * [magento/magento2#12541](https://github.com/magento/magento2/pull/12541) -- [Backport 2.2-develop] Fix swagger-ui on instances of Magento running on a non-standard port (by @JeroenVanLeusden) + * [magento/magento2#12220](https://github.com/magento/magento2/pull/12220) -- 12180 Remove unnecessary use operator for Context, causes 503 error i… (by @chris-pook) + * [magento/magento2#12477](https://github.com/magento/magento2/pull/12477) -- NewRelic: Disables Module Deployments, Creates new Deploy Marker Command (by @fooman) + * [magento/magento2#12529](https://github.com/magento/magento2/pull/12529) -- #12450: Set Current Store from Store Code if isUseStoreInUrl (by @osrecio) + * [magento/magento2#12606](https://github.com/magento/magento2/pull/12606) -- Fix error loading theme configuration on PHP 7.2 (by @Alanaktion) + * [magento/magento2#12610](https://github.com/magento/magento2/pull/12610) -- Update CrontabManager.php (by @WaPoNe) + * [magento/magento2#12639](https://github.com/magento/magento2/pull/12639) -- Remove @escapeNotVerified from documentation (by @mzeis) + * [magento/magento2#11702](https://github.com/magento/magento2/pull/11702) -- Fix getReservedOrderId() to use current store instead of default store (by @tdgroot) + * [magento/magento2#12633](https://github.com/magento/magento2/pull/12633) -- Magento Connect no longer exist (by @miguelbalparda) + * [magento/magento2#12063](https://github.com/magento/magento2/pull/12063) -- 11946: Layer navigation showing wrong product count (by @RomaKis) + * [magento/magento2#12661](https://github.com/magento/magento2/pull/12661) -- [2.2-develop] Fixes #12660 invalid parameter configuration provided for argument (by @Tomasz-Silpion) + * [magento/magento2#11563](https://github.com/magento/magento2/pull/11563) -- Add price calculation improvement for product option value price (by @marinagociu) + * [magento/magento2#12666](https://github.com/magento/magento2/pull/12666) -- Fix incorrect DHL Product codes (by @gwharton) + * [magento/magento2#12723](https://github.com/magento/magento2/pull/12723) -- [2.2 Backport] Create CODE_OF_CONDUCT.md (by @ishakhsuvarov) + * [magento/magento2#11070](https://github.com/magento/magento2/pull/11070) -- Remove deprecation without alternative (by @schmengler) + * [magento/magento2#12730](https://github.com/magento/magento2/pull/12730) -- 12713 (by @EfremovaVI) + * [magento/magento2#12743](https://github.com/magento/magento2/pull/12743) -- #9453 - ported down c2e5d77a9516c8305585e819c2f0a0629648cc14 (by @strell) + * [magento/magento2#12747](https://github.com/magento/magento2/pull/12747) -- magento/magento2#9720 Menu item dependencies (dependsOnModule, depend… (by @hannassy) + * [magento/magento2#12767](https://github.com/magento/magento2/pull/12767) -- magento/magento2#12699: Multiselect Attribute is not saved (by @awarche) + * [magento/magento2#12786](https://github.com/magento/magento2/pull/12786) -- Fix typo in SINGLE_PRODUCT_LAYOUT_HANLDE (by @aschrammel) + * [magento/magento2#12630](https://github.com/magento/magento2/pull/12630) -- Add customer login url from Customer Url model to checkout config so … (by @quisse) + * [magento/magento2#12732](https://github.com/magento/magento2/pull/12732) -- Fix issue when tracking link returns 404 page in admin panel (by @ihor-sviziev) + * [magento/magento2#12739](https://github.com/magento/magento2/pull/12739) -- magento/magento2#6113: Validate range-words in Form component (UI Component) (by @Zamoroka) + * [magento/magento2#12738](https://github.com/magento/magento2/pull/12738) -- magento/magento2#12719: Use full name in welcome message (by @xpoback) + * [magento/magento2#12758](https://github.com/magento/magento2/pull/12758) -- magento/magento2#5035 Cannot subscribe to events with a number in name (by @Mobecls) + * [magento/magento2#12759](https://github.com/magento/magento2/pull/12759) -- Fix Back to Sign in url on confirmation form (by @StasKozar) + * [magento/magento2#12810](https://github.com/magento/magento2/pull/12810) -- Stop the profiler when returning early in \Magento\Eav\Model\Config::getAttribute (by @nicka101) + * [magento/magento2#12826](https://github.com/magento/magento2/pull/12826) -- Fix PhpDoc to show correct parameter types (by @FreekVandeursen) + * [magento/magento2#11462](https://github.com/magento/magento2/pull/11462) -- #7241: Always add empty option for prefix and/or suffix if optional (by @avstudnitz) + * [magento/magento2#11686](https://github.com/magento/magento2/pull/11686) -- Fix error when generating urn catalog for empty misc.xml (by @tdgroot) + * [magento/magento2#11878](https://github.com/magento/magento2/pull/11878) -- [BUGFIX] Made method public so a plugin is possible. (by @dheesbeen) + * [magento/magento2#12105](https://github.com/magento/magento2/pull/12105) -- #11936:required attribute set id filter on attribute group repository getList (by @tzyganu) + * [magento/magento2#12636](https://github.com/magento/magento2/pull/12636) -- #12625: Add Current Date to update_time Field for Block and Pages (by @osrecio) + * [magento/magento2#12737](https://github.com/magento/magento2/pull/12737) -- magento/magento2#11953: Product configuration creator does not warn about invalid SKUs (by @Zamoroka) + * [magento/magento2#12751](https://github.com/magento/magento2/pull/12751) -- magento/magento2#12439 Newsletter subscription success email not sent… (by @Styopchik) + * [magento/magento2#12884](https://github.com/magento/magento2/pull/12884) -- [Backport 2.2] Update functional.suite.dist.yml to handle a custom backend name (by @scribam) + * [magento/magento2#12734](https://github.com/magento/magento2/pull/12734) -- #6916 Fix notice during Update Bundle Product without changes (by @dzianis-yurevich) + * [magento/magento2#12859](https://github.com/magento/magento2/pull/12859) -- Throw ValidationException for invalid xml (by @pmclain) + * [magento/magento2#12875](https://github.com/magento/magento2/pull/12875) -- Add more parameters to ajax:addToCart (by @srenon) + * [magento/magento2#12736](https://github.com/magento/magento2/pull/12736) -- Issues/12374 (by @virtual97) + * [magento/magento2#12401](https://github.com/magento/magento2/pull/12401) -- Correctly set payment information when using paypal (by @therool) + * [magento/magento2#12768](https://github.com/magento/magento2/pull/12768) -- magento/magento2: Missing ext-bcmath dependency added (by @Mobecls) + * [magento/magento2#12845](https://github.com/magento/magento2/pull/12845) -- Add missing preference for ObjectManager\ConfigInterface in integrati… (by @schmengler) + * [magento/magento2#12857](https://github.com/magento/magento2/pull/12857) -- Update progress.phtml (by @jonashrem) + * [magento/magento2#12887](https://github.com/magento/magento2/pull/12887) -- Remove unused if statement in order invoice save (by @JeroenVanLeusden) + * [magento/magento2#12931](https://github.com/magento/magento2/pull/12931) -- Display scroll bar of admin store switcher in OSX computers. (by @jalogut) + * [magento/magento2#12946](https://github.com/magento/magento2/pull/12946) -- Respect "Learn More Link" in Recently Viewed Products widget options (by @JeroenVanLeusden) + * [magento/magento2#12951](https://github.com/magento/magento2/pull/12951) -- [Bug] Correctly construct Magento\Framework\Phrase (by @punkstar) + * [magento/magento2#12755](https://github.com/magento/magento2/pull/12755) -- magento/magento2#12294: Bug: Adding Custom Attribute - The value of A… (by @virtual97) + * [magento/magento2#12902](https://github.com/magento/magento2/pull/12902) -- Fix #12900: Braintree "Place Order" button is disabled after failed validation (by @joni-jones) + * [magento/magento2#12945](https://github.com/magento/magento2/pull/12945) -- Naming collision in Javascript ui registry (backend) to 2.2 (by @VladimirZaets) + * [magento/magento2#12521](https://github.com/magento/magento2/pull/12521) -- Match flexible static file version in nginx sample config (by @scottsb) + * [magento/magento2#12752](https://github.com/magento/magento2/pull/12752) -- magento/magento2#4292: Ability to sitch to default mode (by @Etty) + * [magento/magento2#12953](https://github.com/magento/magento2/pull/12953) -- [Backport to 2.2-develop] Fix #2156 Js\Dataprovider uses the RendererInterface. (by @dverkade) + * [magento/magento2#12963](https://github.com/magento/magento2/pull/12963) -- Sort configurable attribute options by sort_order (by @wardcapp) + * [magento/magento2#12862](https://github.com/magento/magento2/pull/12862) -- Change _getHtml to append class rather than overwrite for children (by @jonshipman) + * [magento/magento2#13015](https://github.com/magento/magento2/pull/13015) -- [Backport to 2.2-develop] The quote address fields length expanded in the database (by @dverkade) + * [magento/magento2#13027](https://github.com/magento/magento2/pull/13027) -- Change of copyright year from 2017 to 2018. (by @bhargavmehta) + * [magento/magento2#12649](https://github.com/magento/magento2/pull/12649) -- #12446: Add GetUtilityPageIdentifiers for Manage Custom Pages to be excluded … (by @osrecio) + * [magento/magento2#12917](https://github.com/magento/magento2/pull/12917) -- Fix issue 12894: Can't remove State is required for all countries (by @vasilii-b) + * [magento/magento2#12922](https://github.com/magento/magento2/pull/12922) -- Handle multiple errors in customer address validation when shown in adminhtml customer edit page (by @adrian-martinez-interactiv4) + * [magento/magento2#13019](https://github.com/magento/magento2/pull/13019) -- [Backport to 2.2-develop] Attribute with "Catalog Input Type for Store Owner" equal "Fixed Product Tax" for Multi-store (by @dverkade) + * [magento/magento2#13052](https://github.com/magento/magento2/pull/13052) -- Make "top destinations" config field configurable on store level (by @avstudnitz) + * [magento/magento2#12901](https://github.com/magento/magento2/pull/12901) -- FIX: remove not used count() from templates (by @Coderimus) + * [magento/magento2#13050](https://github.com/magento/magento2/pull/13050) -- Updated cron documentation URL to 2.2 (by @robbie-thompson) + * [magento/magento2#11369](https://github.com/magento/magento2/pull/11369) -- Database backup doesn't include triggers #9036 (by @denisristic) + * [magento/magento2#12731](https://github.com/magento/magento2/pull/12731) -- magento/magento2#12209: Substitution payment method - Incorrect message (by @Zamoroka) + * [magento/magento2#12964](https://github.com/magento/magento2/pull/12964) -- Add trim filter to first, middle and lastname. (by @wardcapp) + * [magento/magento2#12985](https://github.com/magento/magento2/pull/12985) -- Fix jumping content on page reload in admin area (by @avoelkl) + * [magento/magento2#13026](https://github.com/magento/magento2/pull/13026) -- Feature space between category page (by @sanjay-wagento) + * [magento/magento2#13041](https://github.com/magento/magento2/pull/13041) -- Solution For Newsletter subscribe button title wrapped (by @monaemipro) + * [magento/magento2#13051](https://github.com/magento/magento2/pull/13051) -- Fix JS error on cart from postcode validation when 'US' is deselected as an allowed country (by @codekipple) + * [magento/magento2#13076](https://github.com/magento/magento2/pull/13076) -- Fix issues caused by using continue in loops (by @ihor-sviziev) + * [magento/magento2#13029](https://github.com/magento/magento2/pull/13029) -- Newsletter Label is broking on chinese Language like 订阅 (by @dasharath-wagento) + * [magento/magento2#12965](https://github.com/magento/magento2/pull/12965) -- Fix vault_payment_token install script type where column defaults were not set (by @helloitsluke) + * [magento/magento2#13030](https://github.com/magento/magento2/pull/13030) -- Resolved Checkout-Payment-Wrong promo code cancelled issue (by @chiragp-wagento) + * [magento/magento2#13039](https://github.com/magento/magento2/pull/13039) -- Feature minimum order amount notice issue (by @neeta-wagento) + * [magento/magento2#13061](https://github.com/magento/magento2/pull/13061) -- Fix for requireJS loading issues (for ad blockers) (by @Yonn-Trimoreau) + * [magento/magento2#13081](https://github.com/magento/magento2/pull/13081) -- Fix for #11796 Magento2.2.0 home page product grid issues (by @punitv) + * [magento/magento2#13084](https://github.com/magento/magento2/pull/13084) -- Fixed magnifier issue. (by @mayankzalavadia) + * [magento/magento2#12668](https://github.com/magento/magento2/pull/12668) -- Fix for reverting stock twice for cancelled orders (by @dverkade) + * [magento/magento2#13034](https://github.com/magento/magento2/pull/13034) -- Magento 2.2 Develop fix for #12221 Google Analytics Pageview Triggered twice (by @bhargavmehta) + * [magento/magento2#13036](https://github.com/magento/magento2/pull/13036) -- magento/magento2#12705: Integrity constraint violation error after re… (by @vinayshah) + * [magento/magento2#13044](https://github.com/magento/magento2/pull/13044) -- Fix Newsletter Subscribe Workflow (by @torhoehn) + * [magento/magento2#13161](https://github.com/magento/magento2/pull/13161) -- Updated README file to take resources from 2.2 instead of 2.0. (by @bhargavmehta) + * [magento/magento2#12990](https://github.com/magento/magento2/pull/12990) -- [2.2.x] Fix undeclared dependency magento/zendframework1 by magento/framework (by @ihor-sviziev) + * [magento/magento2#12998](https://github.com/magento/magento2/pull/12998) -- Make customer name link to customer dashboard (by @srenon) + * [magento/magento2#13033](https://github.com/magento/magento2/pull/13033) -- Newsletter\Model\Subscriber::loadByEmail() does not use MySQL index (by @devamitbera) + * [magento/magento2#13066](https://github.com/magento/magento2/pull/13066) -- Fix for #12877 as per @azeemism (by @jagritijoshi) + * [magento/magento2#13086](https://github.com/magento/magento2/pull/13086) -- Add failsafe to items.phtml (by @samgranger) + * [magento/magento2#13169](https://github.com/magento/magento2/pull/13169) -- Optimization: magento/module-eav is_null change to strict comparison … (by @Coderimus) + * [magento/magento2#13170](https://github.com/magento/magento2/pull/13170) -- Optimization: magento/module-tax is_null change to strict comparison (by @Coderimus) + * [magento/magento2#13155](https://github.com/magento/magento2/pull/13155) -- Optimization: module-sales is_null change to strict comparison instead (by @Coderimus) + * [magento/magento2#13171](https://github.com/magento/magento2/pull/13171) -- Optimization: magento/module-catalog is_null change to strict comparison (by @Coderimus) + * [magento/magento2#13174](https://github.com/magento/magento2/pull/13174) -- Fix: remove TestObserver class (by @Coderimus) + * [magento/magento2#12807](https://github.com/magento/magento2/pull/12807) -- Reorder adding of page layout handles (by @aschrammel) + * [magento/magento2#13101](https://github.com/magento/magento2/pull/13101) -- 11828 Fix issue with swatch colour block not showing in admin panel once colour selected (PHP7.1.x issue). (by @chris-pook) + * [magento/magento2#13082](https://github.com/magento/magento2/pull/13082) -- Fix Magento_Checkout address formatting (by @nfourteen) + * [magento/magento2#13141](https://github.com/magento/magento2/pull/13141) -- Fix missing discount label in checkout (by @ihor-sviziev) + * [magento/magento2#13025](https://github.com/magento/magento2/pull/13025) -- fixed issue prices aren't readable when using custom price symbol (by @pradeep-wagento) + * [magento/magento2#13208](https://github.com/magento/magento2/pull/13208) -- #12714 - pass parameter for export button url (by @sanjay-wagento) + * [magento/magento2#12406](https://github.com/magento/magento2/pull/12406) -- Issue/12342/js test driver removal (by @KarlDeux) + * [magento/magento2#13310](https://github.com/magento/magento2/pull/13310) -- Add the domReady! statement (by @arnoudhgz) + * [magento/magento2#13324](https://github.com/magento/magento2/pull/13324) -- Alignement Array assignement (by @Nolwennig) + * [magento/magento2#12936](https://github.com/magento/magento2/pull/12936) -- FIX: out-of-stock options for configurable product visible on frontend as sellable (by @Coderimus) + * [magento/magento2#11060](https://github.com/magento/magento2/pull/11060) -- Handle transparncy correctly for watermark (by @elzekool) + * [magento/magento2#13408](https://github.com/magento/magento2/pull/13408) -- Translate time zone label according to current locale in Stores > Configuration > Advanced Reporting (by @adrian-martinez-interactiv4) + * [magento/magento2#12650](https://github.com/magento/magento2/pull/12650) -- Add fallback for Product_links position attribute if not set in request (by @mohammedsalem) + * [magento/magento2#13341](https://github.com/magento/magento2/pull/13341) -- Bugfix/13327 ui active state not removed from previous menu item (by @arnoudhgz) + * [magento/magento2#13364](https://github.com/magento/magento2/pull/13364) -- [Backport 2.2] In checkout->multishipping-> new addres clean region when select country without dropdown for states (by @enriquei4) + * [magento/magento2#13373](https://github.com/magento/magento2/pull/13373) -- Edited doc block of the walk method in a Collection (by @ByteCreation) + * [magento/magento2#13436](https://github.com/magento/magento2/pull/13436) -- Product Link Save Handler - Remove not used constructor dependency (by @ihor-sviziev) + * [magento/magento2#13449](https://github.com/magento/magento2/pull/13449) -- Fix default discount tax calculation in double (by @VincentMarmiesse) + * [magento/magento2#13450](https://github.com/magento/magento2/pull/13450) -- Removed each function usage (by @ihor-sviziev) + * [magento/magento2#13485](https://github.com/magento/magento2/pull/13485) -- Update code formatting in Swagger Block (by @JeroenVanLeusden) + * [magento/magento2#13132](https://github.com/magento/magento2/pull/13132) -- Update the Emogrifier dependency to ^2.0.0 (by @oliverklee) + * [magento/magento2#13494](https://github.com/magento/magento2/pull/13494) -- Fixing of Problem with updating stock item qty and stock status (by @nuzil) + * [magento/magento2#13498](https://github.com/magento/magento2/pull/13498) -- issue #13497 - Method getUrl in Magento\Catalog\Model\Product\Attribu… (by @igortregub) + * [magento/magento2#13040](https://github.com/magento/magento2/pull/13040) -- magento/magento2#: Customer Login/Logout Issue (by @vinayshah) + * [magento/magento2#13462](https://github.com/magento/magento2/pull/13462) -- Switch updatecart qty input validators to dynamic instead of hardcoding (by @gil--) + * [magento/magento2#13528](https://github.com/magento/magento2/pull/13528) -- Fix for #12081: missing translations in the js-translations.json (by @mattijv) + * [magento/magento2#13563](https://github.com/magento/magento2/pull/13563) -- magento/magento2#11252: fix adminhtml file attribute edit form (by @Mkennethsmith) + * [magento/magento2#13551](https://github.com/magento/magento2/pull/13551) -- Fix json encoded attribute backend type to not encode attribute value multiple times (by @tkotosz) + * [magento/magento2#12843](https://github.com/magento/magento2/pull/12843) -- Display a more meaningful error message in case of misspelt module name (by @JanisE) + * [magento/magento2#13438](https://github.com/magento/magento2/pull/13438) -- Product image builder - Override attributes when builder used multiple times (by @ihor-sviziev) + * [magento/magento2#13596](https://github.com/magento/magento2/pull/13596) -- Fix adding values to system variable collection (by @mszydlo) + * [magento/magento2#13614](https://github.com/magento/magento2/pull/13614) -- Show redirect_to_base config in store scope (by @JeroenVanLeusden) + * [magento/magento2#11504](https://github.com/magento/magento2/pull/11504) -- Add MagentoStyle as Console Input/output helper object... (by @wesleywmd) + * [magento/magento2#13587](https://github.com/magento/magento2/pull/13587) -- Show maintenance IP-address without commas (by @barryvdh) + * [magento/magento2#13679](https://github.com/magento/magento2/pull/13679) -- Update StorageInterface.php (by @davidangel) + * [magento/magento2#13663](https://github.com/magento/magento2/pull/13663) -- Refactoring: remove unuseful temporary variable (by @real34) + * [magento/magento2#13698](https://github.com/magento/magento2/pull/13698) -- [Travis Test Fix] Add MagentoStyle as Console Input/output (by @magento-engcom-team) + * [magento/magento2#13586](https://github.com/magento/magento2/pull/13586) -- Add option to add IP address to existing list (by @barryvdh) + * [magento/magento2#13643](https://github.com/magento/magento2/pull/13643) -- Fixes #12791 - Use a selector to only select the correct tax rate sel… (by @hostep) + * [magento/magento2#13661](https://github.com/magento/magento2/pull/13661) -- Typo (address not addres) (by @srenon) + * [magento/magento2#13678](https://github.com/magento/magento2/pull/13678) -- Add RewriteBase directive template in .htaccess file into pub/static folder (by @ccasciotti) + * [magento/magento2#13740](https://github.com/magento/magento2/pull/13740) -- Display a more meaningful error message in case of misspelt module name unit test. (by @nmalevanec) + * [magento/magento2#13742](https://github.com/magento/magento2/pull/13742) -- Fix adding values to system variable collection unit test. (by @nmalevanec) + * [magento/magento2#13761](https://github.com/magento/magento2/pull/13761) -- Fix bug Magento 2.2.2 password reset strength meter #13429 (by @aoldoni) + * [magento/magento2#13759](https://github.com/magento/magento2/pull/13759) -- Add ObserverInterface to the api (by @fooman) + * [magento/magento2#13770](https://github.com/magento/magento2/pull/13770) -- Remove not-allowed currencies from the currencies dropdown in Setup (by @r-martins) + * [magento/magento2#12749](https://github.com/magento/magento2/pull/12749) -- Grid filtration doesn't work for mysql special characters (by @laconica-sergey) + * [magento/magento2#13280](https://github.com/magento/magento2/pull/13280) -- Add option "lock-config" for shell command "config:set" (by @avstudnitz) + * [magento/magento2#13584](https://github.com/magento/magento2/pull/13584) -- Ensure DeploymentConfig Reader always returns an array (by @barryvdh) + * [magento/magento2#13680](https://github.com/magento/magento2/pull/13680) -- Cast handling fee to float (by @schmengler) + * [magento/magento2#13762](https://github.com/magento/magento2/pull/13762) -- Remove forced setting of cache_lifetime to false in constructor and set default cache_lifetime to 3600 (by @zolat) + * [magento/magento2#12564](https://github.com/magento/magento2/pull/12564) -- Add visibility and status filter to category product grid (by @peterjaap) + * [magento/magento2#13682](https://github.com/magento/magento2/pull/13682) -- [Backport-2.2] of PR-#10935 Fix LowStock report in All Websites view (by @gwharton) + * [magento/magento2#13700](https://github.com/magento/magento2/pull/13700) -- Fix faulty admin spinner animation (by @RNanoware) + * [magento/magento2#13777](https://github.com/magento/magento2/pull/13777) -- Fix #13315. Mobile 'Payments methods' step looks bad on mobile (by @Frodigo) + * [magento/magento2#13811](https://github.com/magento/magento2/pull/13811) -- Added missing event parameter for proxy function on the search form submit (by @koenner01) + * [magento/magento2#13816](https://github.com/magento/magento2/pull/13816) -- Add @api annotation to block argument marker interface (by @Vinai) + * [magento/magento2#13830](https://github.com/magento/magento2/pull/13830) -- Minicart should require dropdownDialog (by @amenk) + * [magento/magento2#13038](https://github.com/magento/magento2/pull/13038) -- Default Welcome message is broken on storefront with enabled translate-inline (by @pareshpansuriya) + * [magento/magento2#13567](https://github.com/magento/magento2/pull/13567) -- Add integration tests for product urls rewrite generation (by @adrien-louis-r) + * [magento/magento2#13787](https://github.com/magento/magento2/pull/13787) -- Issue-13768 Fixed error messages on admin user account page after redirect for force password change (by @nuzil) + * [magento/magento2#13817](https://github.com/magento/magento2/pull/13817) -- Allow changing head and body element through xml layout updates (by @cedricziel) + * [magento/magento2#13828](https://github.com/magento/magento2/pull/13828) -- Inconsistent Redirect in Admin Notification Controller (by @chickenland) + * [magento/magento2#13844](https://github.com/magento/magento2/pull/13844) -- Fix issue 13827 (by @julienanquetil) + * [magento/magento2#13897](https://github.com/magento/magento2/pull/13897) -- Fix typo in securityCheckers array (by @pmclain) + * [magento/magento2#13796](https://github.com/magento/magento2/pull/13796) -- Save CMS Block using repository (by @JeroenVanLeusden) + * [magento/magento2#13814](https://github.com/magento/magento2/pull/13814) -- Load CMS Page using repository in save action (by @JeroenVanLeusden) + * [magento/magento2#11513](https://github.com/magento/magento2/pull/11513) -- Modify Report processor to return 500 (by @andrewhowdencom) + * [magento/magento2#13914](https://github.com/magento/magento2/pull/13914) -- Pass Expected Data Type in backgroundColor Call (2.2) (by @northernco) + * [magento/magento2#13217](https://github.com/magento/magento2/pull/13217) -- Fix JS address converter function from mutating its argument (by @vaaralav) + * [magento/magento2#13641](https://github.com/magento/magento2/pull/13641) -- Add missing implementation for applySortOrder() (by @schmengler) + * [magento/magento2#13709](https://github.com/magento/magento2/pull/13709) -- Changes static content deploy log levels verbosity (by @hostep) + * [magento/magento2#13750](https://github.com/magento/magento2/pull/13750) -- Less clean up (by @Karlasa) + * [magento/magento2#13861](https://github.com/magento/magento2/pull/13861) -- Solved this issue : Drop down values are not showing in catalog produ… (by @hiren-wagento) + * [magento/magento2#13930](https://github.com/magento/magento2/pull/13930) -- #13899 Solve Canada Zip Code pattern (by @tadeobarranco) + * [magento/magento2#13966](https://github.com/magento/magento2/pull/13966) -- Setup Lists - Make allowedCurrencies property private (by @ihor-sviziev) + 2.2.2 ============= * GitHub issues: diff --git a/COPYING.txt b/COPYING.txt index d2cbcd01539dd..040bdd5f3ce72 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ -Copyright © 2013-2017 Magento, Inc. +Copyright © 2013-present Magento, Inc. Each Magento source file included in this distribution is licensed under OSL 3.0 or the Magento Enterprise Edition (MEE) license diff --git a/README.md b/README.md index 1dd81a7eed272..99fe14cbe33df 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -[![Build Status](https://travis-ci.org/magento/magento2.svg?branch=develop)](https://travis-ci.org/magento/magento2) +[![Build Status](https://travis-ci.org/magento/magento2.svg?branch=2.2-develop)](https://travis-ci.org/magento/magento2) +[![Open Source Helpers](https://www.codetriage.com/magento/magento2/badges/users.svg)](https://www.codetriage.com/magento/magento2) [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/magento/magento2?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/magento-2/localized.png)](https://crowdin.com/project/magento-2)

Welcome

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

Contributing to the Magento 2 code base

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

Community Maintainers

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

Top Contributors

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

Labels applied by the Magento team

| Label | Description | diff --git a/app/bootstrap.php b/app/bootstrap.php index 3d474cea45432..e77c6d432c816 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -14,12 +14,12 @@ if (!defined('PHP_VERSION_ID') || !(PHP_VERSION_ID === 70002 || PHP_VERSION_ID === 70004 || PHP_VERSION_ID >= 70006)) { if (PHP_SAPI == 'cli') { echo 'Magento supports 7.0.2, 7.0.4, and 7.0.6 or later. ' . - 'Please read http://devdocs.magento.com/guides/v1.0/install-gde/system-requirements.html'; + 'Please read http://devdocs.magento.com/guides/v2.2/install-gde/system-requirements.html'; } else { echo <<

Magento supports PHP 7.0.2, 7.0.4, and 7.0.6 or later. Please read - + Magento System Requirements. HTML; diff --git a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php index 6c0dfd1db7d16..94c7d955f592b 100644 --- a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php +++ b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php @@ -39,6 +39,6 @@ public function execute() $this->messageManager->addException($e, __("We couldn't remove the messages because of an error.")); } } - $this->getResponse()->setRedirect($this->_redirect->getRedirectUrl($this->getUrl('*'))); + $this->_redirect('adminhtml/*/'); } } diff --git a/app/code/Magento/AdminNotification/composer.json b/app/code/Magento/AdminNotification/composer.json index 59a3845cbd4b7..c0c6be46ce769 100644 --- a/app/code/Magento/AdminNotification/composer.json +++ b/app/code/Magento/AdminNotification/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-admin-notification", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-backend": "100.2.*", "magento/module-media-storage": "100.2.*", @@ -11,7 +11,7 @@ "lib-libxml": "*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/AdvancedPricingImportExport/composer.json b/app/code/Magento/AdvancedPricingImportExport/composer.json index 79e6e2d368736..a3d6e36e66a0f 100644 --- a/app/code/Magento/AdvancedPricingImportExport/composer.json +++ b/app/code/Magento/AdvancedPricingImportExport/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-advanced-pricing-import-export", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-catalog": "102.0.*", "magento/module-catalog-inventory": "100.2.*", "magento/module-eav": "101.0.*", @@ -13,7 +13,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Analytics/Block/Adminhtml/System/Config/CollectionTimeLabel.php b/app/code/Magento/Analytics/Block/Adminhtml/System/Config/CollectionTimeLabel.php index c4118792255cd..34f2b7d53d9be 100644 --- a/app/code/Magento/Analytics/Block/Adminhtml/System/Config/CollectionTimeLabel.php +++ b/app/code/Magento/Analytics/Block/Adminhtml/System/Config/CollectionTimeLabel.php @@ -5,13 +5,35 @@ */ namespace Magento\Analytics\Block\Adminhtml\System\Config; +use Magento\Framework\App\ObjectManager; + /** * Provides label with default Time Zone */ class CollectionTimeLabel extends \Magento\Config\Block\System\Config\Form\Field { /** - * Add default time zone to comment + * @var \Magento\Framework\Locale\ResolverInterface + */ + private $localeResolver; + + /** + * @param \Magento\Backend\Block\Template\Context $context + * @param array $data + * @param \Magento\Framework\Locale\ResolverInterface|null $localeResolver + */ + public function __construct( + \Magento\Backend\Block\Template\Context $context, + array $data = [], + \Magento\Framework\Locale\ResolverInterface $localeResolver = null + ) { + $this->localeResolver = $localeResolver ?: + ObjectManager::getInstance()->get(\Magento\Framework\Locale\ResolverInterface::class); + parent::__construct($context, $data); + } + + /** + * Add current time zone to comment, properly translated according to locale * * @param \Magento\Framework\Data\Form\Element\AbstractElement $element * @return string @@ -19,7 +41,9 @@ class CollectionTimeLabel extends \Magento\Config\Block\System\Config\Form\Field public function render(\Magento\Framework\Data\Form\Element\AbstractElement $element) { $timeZoneCode = $this->_localeDate->getConfigTimezone(); - $getLongTimeZoneName = \IntlTimeZone::createTimeZone($timeZoneCode)->getDisplayName(); + $locale = $this->localeResolver->getLocale(); + $getLongTimeZoneName = \IntlTimeZone::createTimeZone($timeZoneCode) + ->getDisplayName(false, \IntlTimeZone::DISPLAY_LONG, $locale); $element->setData( 'comment', sprintf("%s (%s)", $getLongTimeZoneName, $timeZoneCode) diff --git a/app/code/Magento/Analytics/composer.json b/app/code/Magento/Analytics/composer.json index 349e5f3c08c4c..2e9b4bf158321 100644 --- a/app/code/Magento/Analytics/composer.json +++ b/app/code/Magento/Analytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-backend": "100.2.*", "magento/module-config": "101.0.*", "magento/module-integration": "100.2.*", @@ -10,7 +10,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.0", + "version": "100.2.1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Authorization/composer.json b/app/code/Magento/Authorization/composer.json index c0b0f9c6b13df..22483b7961841 100644 --- a/app/code/Magento/Authorization/composer.json +++ b/app/code/Magento/Authorization/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-authorization", "description": "Authorization module provides access to Magento ACL functionality.", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-backend": "100.2.*", "magento/framework": "101.0.*" }, diff --git a/app/code/Magento/Authorizenet/Model/Directpost.php b/app/code/Magento/Authorizenet/Model/Directpost.php index 0f10fd633cb5b..de567a8895f7e 100644 --- a/app/code/Magento/Authorizenet/Model/Directpost.php +++ b/app/code/Magento/Authorizenet/Model/Directpost.php @@ -5,10 +5,9 @@ */ namespace Magento\Authorizenet\Model; -use Magento\Framework\HTTP\ZendClientFactory; +use Magento\Framework\App\ObjectManager; use Magento\Payment\Model\Method\ConfigInterface; use Magento\Payment\Model\Method\TransparentInterface; -use Magento\Sales\Model\Order\Email\Sender\OrderSender; /** * Authorize.net DirectPost payment method model. @@ -102,7 +101,7 @@ class Directpost extends \Magento\Authorizenet\Model\Authorizenet implements Tra protected $response; /** - * @var OrderSender + * @var \Magento\Sales\Model\Order\Email\Sender\OrderSender */ protected $orderSender; @@ -123,6 +122,16 @@ class Directpost extends \Magento\Authorizenet\Model\Authorizenet implements Tra */ private $psrLogger; + /** + * @var \Magento\Sales\Api\PaymentFailuresInterface + */ + private $paymentFailures; + + /** + * @var \Magento\Sales\Model\Order + */ + private $order; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -141,11 +150,12 @@ class Directpost extends \Magento\Authorizenet\Model\Authorizenet implements Tra * @param \Magento\Sales\Model\OrderFactory $orderFactory * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Quote\Api\CartRepositoryInterface $quoteRepository - * @param OrderSender $orderSender + * @param \Magento\Sales\Model\Order\Email\Sender\OrderSender $orderSender * @param \Magento\Sales\Api\TransactionRepositoryInterface $transactionRepository * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data + * @param \Magento\Sales\Api\PaymentFailuresInterface|null $paymentFailures * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -162,7 +172,7 @@ public function __construct( \Magento\Authorizenet\Model\Directpost\Request\Factory $requestFactory, \Magento\Authorizenet\Model\Directpost\Response\Factory $responseFactory, TransactionService $transactionService, - ZendClientFactory $httpClientFactory, + \Magento\Framework\HTTP\ZendClientFactory $httpClientFactory, \Magento\Sales\Model\OrderFactory $orderFactory, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Quote\Api\CartRepositoryInterface $quoteRepository, @@ -170,7 +180,8 @@ public function __construct( \Magento\Sales\Api\TransactionRepositoryInterface $transactionRepository, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + \Magento\Sales\Api\PaymentFailuresInterface $paymentFailures = null ) { $this->orderFactory = $orderFactory; $this->storeManager = $storeManager; @@ -179,6 +190,8 @@ public function __construct( $this->orderSender = $orderSender; $this->transactionRepository = $transactionRepository; $this->_code = static::METHOD_CODE; + $this->paymentFailures = $paymentFailures ? : ObjectManager::getInstance() + ->get(\Magento\Sales\Api\PaymentFailuresInterface::class); parent::__construct( $context, @@ -561,13 +574,10 @@ public function process(array $responseData) $this->validateResponse(); $response = $this->getResponse(); - //operate with order - $orderIncrementId = $response->getXInvoiceNum(); $responseText = $this->dataHelper->wrapGatewayError($response->getXResponseReasonText()); $isError = false; - if ($orderIncrementId) { - /* @var $order \Magento\Sales\Model\Order */ - $order = $this->orderFactory->create()->loadByIncrementId($orderIncrementId); + if ($this->getOrderIncrementId()) { + $order = $this->getOrderFromResponse(); //check payment method $payment = $order->getPayment(); if (!$payment || $payment->getMethod() != $this->getCode()) { @@ -632,9 +642,10 @@ public function checkResponseCode() return true; case self::RESPONSE_CODE_DECLINED: case self::RESPONSE_CODE_ERROR: - throw new \Magento\Framework\Exception\LocalizedException( - $this->dataHelper->wrapGatewayError($this->getResponse()->getXResponseReasonText()) - ); + $errorMessage = $this->dataHelper->wrapGatewayError($this->getResponse()->getXResponseReasonText()); + $order = $this->getOrderFromResponse(); + $this->paymentFailures->handle((int)$order->getQuoteId(), $errorMessage); + throw new \Magento\Framework\Exception\LocalizedException($errorMessage); default: throw new \Magento\Framework\Exception\LocalizedException( __('There was a payment authorization error.') @@ -988,12 +999,40 @@ protected function getTransactionResponse($transactionId) private function getPsrLogger() { if (null === $this->psrLogger) { - $this->psrLogger = \Magento\Framework\App\ObjectManager::getInstance() + $this->psrLogger = ObjectManager::getInstance() ->get(\Psr\Log\LoggerInterface::class); } return $this->psrLogger; } + /** + * Fetch order by increment id from response. + * + * @return \Magento\Sales\Model\Order + */ + private function getOrderFromResponse(): \Magento\Sales\Model\Order + { + if (!$this->order) { + $this->order = $this->orderFactory->create(); + + if ($incrementId = $this->getOrderIncrementId()) { + $this->order = $this->order->loadByIncrementId($incrementId); + } + } + + return $this->order; + } + + /** + * Fetch order increment id from response. + * + * @return string + */ + private function getOrderIncrementId(): string + { + return $this->getResponse()->getXInvoiceNum(); + } + /** * Checks if filter action is Report Only. Transactions that trigger this filter are processed as normal, * but are also reported in the Merchant Interface as triggering this filter. diff --git a/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php b/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php index dbb6ac8333c14..26d96b9bc2d90 100644 --- a/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php +++ b/app/code/Magento/Authorizenet/Test/Unit/Model/DirectpostTest.php @@ -5,6 +5,7 @@ */ namespace Magento\Authorizenet\Test\Unit\Model; +use Magento\Sales\Api\PaymentFailuresInterface; use Magento\Framework\Simplexml\Element; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; use Magento\Authorizenet\Model\Directpost; @@ -74,6 +75,14 @@ class DirectpostTest extends \PHPUnit\Framework\TestCase */ protected $requestFactory; + /** + * @var PaymentFailuresInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentFailures; + + /** + * @inheritdoc + */ protected function setUp() { $this->scopeConfigMock = $this->getMockBuilder(\Magento\Framework\App\Config\ScopeConfigInterface::class) @@ -104,6 +113,12 @@ protected function setUp() ->setMethods(['getTransactionDetails']) ->getMock(); + $this->paymentFailures = $this->getMockBuilder( + PaymentFailuresInterface::class + ) + ->disableOriginalConstructor() + ->getMock(); + $this->requestFactory = $this->getRequestFactoryMock(); $httpClientFactoryMock = $this->getHttpClientFactoryMock(); @@ -117,7 +132,8 @@ protected function setUp() 'responseFactory' => $this->responseFactoryMock, 'transactionRepository' => $this->transactionRepositoryMock, 'transactionService' => $this->transactionServiceMock, - 'httpClientFactory' => $httpClientFactoryMock + 'httpClientFactory' => $httpClientFactoryMock, + 'paymentFailures' => $this->paymentFailures, ] ); } @@ -313,12 +329,16 @@ public function checkResponseCodeSuccessDataProvider() } /** - * @param bool $responseCode + * Checks response failures behaviour. + * + * @param int $responseCode + * @param int $failuresHandlerCalls + * @return void * * @expectedException \Magento\Framework\Exception\LocalizedException * @dataProvider checkResponseCodeFailureDataProvider */ - public function testCheckResponseCodeFailure($responseCode) + public function testCheckResponseCodeFailure(int $responseCode, int $failuresHandlerCalls) { $reasonText = 'reason text'; @@ -333,18 +353,35 @@ public function testCheckResponseCodeFailure($responseCode) ->with($reasonText) ->willReturn(__('Gateway error: %1', $reasonText)); + $orderMock = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->getMock(); + + $orderMock->expects($this->exactly($failuresHandlerCalls)) + ->method('getQuoteId') + ->willReturn(1); + + $this->paymentFailures->expects($this->exactly($failuresHandlerCalls)) + ->method('handle') + ->with(1); + + $reflection = new \ReflectionClass($this->directpost); + $order = $reflection->getProperty('order'); + $order->setAccessible(true); + $order->setValue($this->directpost, $orderMock); + $this->directpost->checkResponseCode(); } /** * @return array */ - public function checkResponseCodeFailureDataProvider() + public function checkResponseCodeFailureDataProvider(): array { return [ - ['responseCode' => Directpost::RESPONSE_CODE_DECLINED], - ['responseCode' => Directpost::RESPONSE_CODE_ERROR], - ['responseCode' => 999999] + ['responseCode' => Directpost::RESPONSE_CODE_DECLINED, 1], + ['responseCode' => Directpost::RESPONSE_CODE_ERROR, 1], + ['responseCode' => 999999, 0], ]; } diff --git a/app/code/Magento/Authorizenet/composer.json b/app/code/Magento/Authorizenet/composer.json index 1022bd47a5786..c389a1c6c4dfa 100644 --- a/app/code/Magento/Authorizenet/composer.json +++ b/app/code/Magento/Authorizenet/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-authorizenet", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-sales": "101.0.*", "magento/module-store": "100.2.*", "magento/module-quote": "101.0.*", diff --git a/app/code/Magento/Backend/Block/Cache.php b/app/code/Magento/Backend/Block/Cache.php index e14358396aa70..82c36bf3a1fe4 100644 --- a/app/code/Magento/Backend/Block/Cache.php +++ b/app/code/Magento/Backend/Block/Cache.php @@ -22,24 +22,29 @@ protected function _construct() $this->_headerText = __('Cache Storage Management'); parent::_construct(); $this->buttonList->remove('add'); - $this->buttonList->add( - 'flush_magento', - [ - 'label' => __('Flush Magento Cache'), - 'onclick' => 'setLocation(\'' . $this->getFlushSystemUrl() . '\')', - 'class' => 'primary flush-cache-magento' - ] - ); - $message = __('The cache storage may contain additional data. Are you sure that you want to flush it?'); - $this->buttonList->add( - 'flush_system', - [ - 'label' => __('Flush Cache Storage'), - 'onclick' => 'confirmSetLocation(\'' . $message . '\', \'' . $this->getFlushStorageUrl() . '\')', - 'class' => 'flush-cache-storage' - ] - ); + if ($this->_authorization->isAllowed('Magento_Backend::flush_magento_cache')) { + $this->buttonList->add( + 'flush_magento', + [ + 'label' => __('Flush Magento Cache'), + 'onclick' => 'setLocation(\'' . $this->getFlushSystemUrl() . '\')', + 'class' => 'primary flush-cache-magento' + ] + ); + } + + if ($this->_authorization->isAllowed('Magento_Backend::flush_cache_storage')) { + $message = __('The cache storage may contain additional data. Are you sure that you want to flush it?'); + $this->buttonList->add( + 'flush_system', + [ + 'label' => __('Flush Cache Storage'), + 'onclick' => 'confirmSetLocation(\'' . $message . '\', \'' . $this->getFlushStorageUrl() . '\')', + 'class' => 'flush-cache-storage' + ] + ); + } } /** diff --git a/app/code/Magento/Backend/Block/Cache/Permissions.php b/app/code/Magento/Backend/Block/Cache/Permissions.php new file mode 100644 index 0000000000000..272a603145f09 --- /dev/null +++ b/app/code/Magento/Backend/Block/Cache/Permissions.php @@ -0,0 +1,62 @@ +authorization = $authorization; + } + + /** + * @return bool + */ + public function hasAccessToFlushCatalogImages() + { + return $this->authorization->isAllowed('Magento_Backend::flush_catalog_images'); + } + /** + * @return bool + */ + public function hasAccessToFlushJsCss() + { + return $this->authorization->isAllowed('Magento_Backend::flush_js_css'); + } + /** + * @return bool + */ + public function hasAccessToFlushStaticFiles() + { + return $this->authorization->isAllowed('Magento_Backend::flush_static_files'); + } + /** + * @return bool + */ + public function hasAccessToAdditionalActions() + { + return ($this->hasAccessToFlushCatalogImages() + || $this->hasAccessToFlushJsCss() + || $this->hasAccessToFlushStaticFiles()); + } +} diff --git a/app/code/Magento/Backend/Block/GlobalSearch.php b/app/code/Magento/Backend/Block/GlobalSearch.php index f4a46283808f4..b45eb84cdaee9 100644 --- a/app/code/Magento/Backend/Block/GlobalSearch.php +++ b/app/code/Magento/Backend/Block/GlobalSearch.php @@ -3,19 +3,61 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Backend\Block; +use Magento\Backend\Model\GlobalSearch\SearchEntityFactory; +use Magento\Backend\Model\GlobalSearch\SearchEntity; +use Magento\Framework\App\ObjectManager; + /** * @api * @since 100.0.2 */ class GlobalSearch extends \Magento\Backend\Block\Template { + /** + * @var SearchEntityFactory + */ + private $searchEntityFactory; + /** * @var string */ protected $_template = 'Magento_Backend::system/search.phtml'; + /** + * @var array + */ + private $entityResources; + + /** + * @var array + */ + private $entityPaths; + + /** + * @param Template\Context $context + * @param array $data + * @param array $entityResources + * @param array $entityPaths + * @param SearchEntityFactory|null $searchEntityFactory + */ + public function __construct( + Template\Context $context, + array $data = [], + array $entityResources = [], + array $entityPaths = [], + SearchEntityFactory $searchEntityFactory = null + ) { + $this->entityResources = $entityResources; + $this->entityPaths = $entityPaths; + $this->searchEntityFactory = $searchEntityFactory ?: ObjectManager::getInstance() + ->get(SearchEntityFactory::class); + + parent::__construct($context, $data); + } + /** * Get components configuration * @return array @@ -31,7 +73,52 @@ public function getWidgetInitOptions() 'filterProperty' => 'name', 'preventClickPropagation' => false, 'minLength' => 2, + 'submitInputOnEnter' => false, ] ]; } + + /** + * Get entities which are allowed to show. + * + * @return SearchEntity[] + */ + public function getEntitiesToShow() + { + $allowedEntityTypes = []; + $entitiesToShow = []; + + foreach ($this->entityResources as $entityType => $resource) { + if ($this->getAuthorization()->isAllowed($resource)) { + $allowedEntityTypes[] = $entityType; + } + } + + foreach ($allowedEntityTypes as $entityType) { + $url = $this->getUrlEntityType($entityType); + + $searchEntity = $this->searchEntityFactory->create(); + $searchEntity->setId('searchPreview' . $entityType); + $searchEntity->setTitle('in ' . $entityType); + $searchEntity->setUrl($url); + + $entitiesToShow[] = $searchEntity; + } + + return $entitiesToShow; + } + + /** + * Get url path by entity type. + * + * @param string $entityType + * + * @return string + */ + private function getUrlEntityType(string $entityType) + { + $urlPath = $this->entityPaths[$entityType] ?? ''; + + return $this->getUrl($urlPath); + } } diff --git a/app/code/Magento/Backend/Block/System/Store/Grid/Render/Group.php b/app/code/Magento/Backend/Block/System/Store/Grid/Render/Group.php index 59657f38465d7..3d7154eb20f92 100644 --- a/app/code/Magento/Backend/Block/System/Store/Grid/Render/Group.php +++ b/app/code/Magento/Backend/Block/System/Store/Grid/Render/Group.php @@ -27,6 +27,7 @@ public function render(\Magento\Framework\DataObject $row) $this->getUrl('adminhtml/*/editGroup', ['group_id' => $row->getGroupId()]) . '">' . $this->escapeHtml($row->getData($this->getColumn()->getIndex())) . - ''; + '
' + . '(' . __('Code') . ': ' . $row->getGroupCode() . ')'; } } diff --git a/app/code/Magento/Backend/Block/System/Store/Grid/Render/Store.php b/app/code/Magento/Backend/Block/System/Store/Grid/Render/Store.php index 23b2de683a958..9cfc8bfc52691 100644 --- a/app/code/Magento/Backend/Block/System/Store/Grid/Render/Store.php +++ b/app/code/Magento/Backend/Block/System/Store/Grid/Render/Store.php @@ -27,6 +27,7 @@ public function render(\Magento\Framework\DataObject $row) $this->getUrl('adminhtml/*/editStore', ['store_id' => $row->getStoreId()]) . '">' . $this->escapeHtml($row->getData($this->getColumn()->getIndex())) . - ''; + '
' . + '(' . __('Code') . ': ' . $row->getStoreCode() . ')'; } } diff --git a/app/code/Magento/Backend/Block/System/Store/Grid/Render/Website.php b/app/code/Magento/Backend/Block/System/Store/Grid/Render/Website.php index 913e2c903d20c..487eb4f8acfda 100644 --- a/app/code/Magento/Backend/Block/System/Store/Grid/Render/Website.php +++ b/app/code/Magento/Backend/Block/System/Store/Grid/Render/Website.php @@ -24,6 +24,7 @@ public function render(\Magento\Framework\DataObject $row) $this->getUrl('adminhtml/*/editWebsite', ['website_id' => $row->getWebsiteId()]) . '">' . $this->escapeHtml($row->getData($this->getColumn()->getIndex())) . - ''; + '
' . + '(' . __('Code') . ': ' . $row->getCode() . ')'; } } diff --git a/app/code/Magento/Backend/Block/Template.php b/app/code/Magento/Backend/Block/Template.php index d0f39b54c1492..477be0f82462b 100644 --- a/app/code/Magento/Backend/Block/Template.php +++ b/app/code/Magento/Backend/Block/Template.php @@ -17,10 +17,12 @@ * Example: * * - * My\Module\ViewModel\Custom + * My\Module\ViewModel\Custom * * * + * Your class object can then be accessed by doing $block->getViewModel() + * * @api * @SuppressWarnings(PHPMD.NumberOfChildren) * @since 100.0.2 diff --git a/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Date.php b/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Date.php index 40a5d92c56b6f..632603d389d21 100644 --- a/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Date.php +++ b/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Date.php @@ -127,7 +127,7 @@ public function getHtml() /** * @param string|null $index - * @return string + * @return array|string|int|float|null */ public function getEscapedValue($index = null) { @@ -138,6 +138,11 @@ public function getEscapedValue($index = null) $this->_localeDate->getDateFormat(\IntlDateFormatter::SHORT) ); } + + if (is_string($value)) { + return $this->escapeHtml($value); + } + return $value; } diff --git a/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Datetime.php b/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Datetime.php index 96b3471db845e..a5e4a34389671 100644 --- a/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Datetime.php +++ b/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Datetime.php @@ -140,8 +140,8 @@ public function getHtml() /** * Return escaped value for calendar * - * @param string $index - * @return string + * @param string|null $index + * @return array|string|int|float|null */ public function getEscapedValue($index = null) { @@ -150,6 +150,11 @@ public function getEscapedValue($index = null) if ($value instanceof \DateTimeInterface) { return $this->_localeDate->formatDateTime($value); } + + if (is_string($value)) { + return $this->escapeHtml($value); + } + return $value; } diff --git a/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Radio.php b/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Radio.php index 2cbe264c5f396..479a2b6b20293 100644 --- a/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Radio.php +++ b/app/code/Magento/Backend/Block/Widget/Grid/Column/Filter/Radio.php @@ -31,8 +31,7 @@ public function getCondition() { if ($this->getValue()) { return $this->getColumn()->getValue(); - } else { - return [['neq' => $this->getColumn()->getValue()], ['is' => new \Zend_Db_Expr('NULL')]]; } + return [['neq' => $this->getColumn()->getValue()], ['is' => new \Zend_Db_Expr('NULL')]]; } } diff --git a/app/code/Magento/Backend/Block/Widget/Grid/Column/Renderer/Massaction.php b/app/code/Magento/Backend/Block/Widget/Grid/Column/Renderer/Massaction.php index 320713f8b57c4..a611e91f32f00 100644 --- a/app/code/Magento/Backend/Block/Widget/Grid/Column/Renderer/Massaction.php +++ b/app/code/Magento/Backend/Block/Widget/Grid/Column/Renderer/Massaction.php @@ -65,7 +65,7 @@ public function render(\Magento\Framework\DataObject $row) */ protected function _getCheckboxHtml($value, $checked) { - $id = 'id_' . rand(0, 999); + $id = 'id_' . random_int(0, 999); $html = '

-

- -

-

- - -

-

- - -

- isInProductionMode()): - ?> -

- - -

- - getChildHtml() ?> -
+hasAccessToAdditionalActions()): ?> +
+ hasAccessToFlushCatalogImages()): ?> +

+ escapeHtml(__('Additional Cache Management')); ?> +

+

+ + escapeHtml(__('Pregenerated product images files')); ?> +

+ + hasAccessToFlushJsCss()): ?> +

+ + escapeHtml(__('Themes JavaScript and CSS files combined to one file')) ?> +

+ + isInProductionMode() && $permissions->hasAccessToFlushStaticFiles()): ?> +

+ + escapeHtml(__('Preprocessed view files and static files')); ?> +

+ + getChildHtml() ?> +
+ diff --git a/app/code/Magento/Backend/view/adminhtml/templates/system/search.phtml b/app/code/Magento/Backend/view/adminhtml/templates/system/search.phtml index b50183ced29b4..3c65c0358eb57 100644 --- a/app/code/Magento/Backend/view/adminhtml/templates/system/search.phtml +++ b/app/code/Magento/Backend/view/adminhtml/templates/system/search.phtml @@ -27,18 +27,15 @@ - diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/options.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/options.phtml index 6ff0e193a774f..f812a27f87ad9 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/options.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/options.phtml @@ -57,7 +57,7 @@ $stores = $block->getStoresSortedBySortOrder(); - - diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml index c8c915a3140da..9c5cce7865532 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml @@ -16,12 +16,13 @@ getProduct(); ?>
-
getOptions()): ?> enctype="multipart/form-data"> + getBlockHtml('formkey') ?> getChildHtml('form_top') ?> hasOptions()):?> diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml index 5a064b33355a4..a39701b2c8a17 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml @@ -47,27 +47,20 @@ "data": getGalleryImagesJson() ?>, "options": { "nav": "getVar("gallery/nav") ?>", - getVar("gallery/loop"))): ?> - "loop": getVar("gallery/loop") ?>, - - getVar("gallery/keyboard"))): ?> - "keyboard": getVar("gallery/keyboard") ?>, - - getVar("gallery/arrows"))): ?> - "arrows": getVar("gallery/arrows") ?>, - - getVar("gallery/allowfullscreen"))): ?> - "allowfullscreen": getVar("gallery/allowfullscreen") ?>, - - getVar("gallery/caption"))): ?> - "showCaption": getVar("gallery/caption") ?>, - + "loop": getVar("gallery/loop") ? 'true' : 'false' ?>, + "keyboard": getVar("gallery/keyboard") ? 'true' : 'false' ?>, + "arrows": getVar("gallery/arrows") ? 'true' : 'false' ?>, + "allowfullscreen": getVar("gallery/allowfullscreen") ? 'true' : 'false' ?>, + "showCaption": getVar("gallery/caption") ? 'true' : 'false' ?>, "width": "getImageAttribute('product_page_image_medium', 'width') ?>", "thumbwidth": "getImageAttribute('product_page_image_small', 'width') ?>", getImageAttribute('product_page_image_small', 'height') || $block->getImageAttribute('product_page_image_small', 'width')): ?> "thumbheight": getImageAttribute('product_page_image_small', 'height') ?: $block->getImageAttribute('product_page_image_small', 'width'); ?>, + getVar("gallery/thumbmargin"))): ?> + "thumbmargin": getVar("gallery/thumbmargin"); ?>, + getImageAttribute('product_page_image_medium', 'height') || $block->getImageAttribute('product_page_image_medium', 'width')): ?> "height": getImageAttribute('product_page_image_medium', 'height') ?: $block->getImageAttribute('product_page_image_medium', 'width'); ?>, @@ -76,28 +69,18 @@ "transitionduration": getVar("gallery/transition/duration") ?>, "transition": "getVar("gallery/transition/effect") ?>", - getVar("gallery/navarrows"))): ?> - "navarrows": getVar("gallery/navarrows") ?>, - + "navarrows": getVar("gallery/navarrows") ? 'true' : 'false' ?>, "navtype": "getVar("gallery/navtype") ?>", "navdir": "getVar("gallery/navdir") ?>" }, "fullscreen": { "nav": "getVar("gallery/fullscreen/nav") ?>", - getVar("gallery/fullscreen/loop")): ?> - "loop": getVar("gallery/fullscreen/loop") ?>, - + "loop": getVar("gallery/fullscreen/loop") ? 'true' : 'false' ?>, "navdir": "getVar("gallery/fullscreen/navdir") ?>", - getVar("gallery/transition/navarrows")): ?> - "navarrows": getVar("gallery/fullscreen/navarrows") ?>, - + "navarrows": getVar("gallery/fullscreen/navarrows") ? 'true' : 'false' ?>, "navtype": "getVar("gallery/fullscreen/navtype") ?>", - getVar("gallery/fullscreen/arrows")): ?> - "arrows": getVar("gallery/fullscreen/arrows") ?>, - - getVar("gallery/fullscreen/caption")): ?> - "showCaption": getVar("gallery/fullscreen/caption") ?>, - + "arrows": getVar("gallery/fullscreen/arrows") ? 'true' : 'false' ?>, + "showCaption": getVar("gallery/fullscreen/caption") ? 'true' : 'false' ?>, getVar("gallery/fullscreen/transition/duration")): ?> "transitionduration": getVar("gallery/fullscreen/transition/duration") ?>, diff --git a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js index 7686de1d45c5d..b2da91c3b55c1 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js @@ -97,7 +97,11 @@ define([ success: function (res) { var eventData, parameters; - $(document).trigger('ajax:addToCart', form.data().productSku); + $(document).trigger('ajax:addToCart', { + 'sku': form.data().productSku, + 'form': form, + 'response': res + }); if (self.isLoaderEnabled()) { $('body').trigger(self.options.processStop); diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js b/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js new file mode 100644 index 0000000000000..032b8541939c3 --- /dev/null +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js @@ -0,0 +1,184 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'Magento_Theme/js/model/breadcrumb-list' +], function ($, breadcrumbList) { + 'use strict'; + + return function (widget) { + + $.widget('mage.breadcrumbs', widget, { + options: { + categoryUrlSuffix: '', + useCategoryPathInUrl: false, + product: '', + categoryItemSelector: '.category-item', + menuContainer: '[data-action="navigation"] > ul' + }, + + /** @inheritdoc */ + _render: function () { + this._appendCatalogCrumbs(); + this._super(); + }, + + /** + * Append category and product crumbs. + * + * @private + */ + _appendCatalogCrumbs: function () { + var categoryCrumbs = this._resolveCategoryCrumbs(); + + categoryCrumbs.forEach(function (crumbInfo) { + breadcrumbList.push(crumbInfo); + }); + + if (this.options.product) { + breadcrumbList.push(this._getProductCrumb()); + } + }, + + /** + * Resolve categories crumbs. + * + * @return Array + * @private + */ + _resolveCategoryCrumbs: function () { + var menuItem = this._resolveCategoryMenuItem(), + categoryCrumbs = []; + + if (menuItem !== null && menuItem.length) { + categoryCrumbs.unshift(this._getCategoryCrumb(menuItem)); + + while ((menuItem = this._getParentMenuItem(menuItem)) !== null) { + categoryCrumbs.unshift(this._getCategoryCrumb(menuItem)); + } + } + + return categoryCrumbs; + }, + + /** + * Returns crumb data. + * + * @param {Object} menuItem + * @return {Object} + * @private + */ + _getCategoryCrumb: function (menuItem) { + return { + 'name': 'category', + 'label': menuItem.text(), + 'link': menuItem.attr('href'), + 'title': '' + }; + }, + + /** + * Returns product crumb. + * + * @return {Object} + * @private + */ + _getProductCrumb: function () { + return { + 'name': 'product', + 'label': this.options.product, + 'link': '', + 'title': '' + }; + }, + + /** + * Find parent menu item for current. + * + * @param {Object} menuItem + * @return {Object|null} + * @private + */ + _getParentMenuItem: function (menuItem) { + var classes, + classNav, + parentClass, + parentMenuItem = null; + + if (!menuItem) { + return null; + } + + classes = menuItem.parent().attr('class'); + classNav = classes.match(/(nav\-)[0-9]+(\-[0-9]+)+/gi); + + if (classNav) { + classNav = classNav[0]; + parentClass = classNav.substr(0, classNav.lastIndexOf('-')); + + if (parentClass.lastIndexOf('-') !== -1) { + parentMenuItem = $(this.options.menuContainer).find('.' + parentClass + ' > a'); + parentMenuItem = parentMenuItem.length ? parentMenuItem : null; + } + } + + return parentMenuItem; + }, + + /** + * Returns category menu item. + * + * Tries to resolve category from url or from referrer as fallback and + * find menu item from navigation menu by category url. + * + * @return {Object|null} + * @private + */ + _resolveCategoryMenuItem: function () { + var categoryUrl = this._resolveCategoryUrl(), + menu = $(this.options.menuContainer), + categoryMenuItem = null; + + if (categoryUrl && menu.length) { + categoryMenuItem = menu.find( + this.options.categoryItemSelector + + ' > a[href="' + categoryUrl + '"]' + ); + } + + return categoryMenuItem; + }, + + /** + * Returns category url. + * + * @return {String} + * @private + */ + _resolveCategoryUrl: function () { + var categoryUrl; + + if (this.options.useCategoryPathInUrl) { + // In case category path is used in product url - resolve category url from current url. + categoryUrl = window.location.href.split('?')[0]; + categoryUrl = categoryUrl.substring(0, categoryUrl.lastIndexOf('/')) + + this.options.categoryUrlSuffix; + } else { + // In other case - try to resolve it from referrer (without parameters). + categoryUrl = document.referrer; + + if (categoryUrl.indexOf('?') > 0) { + categoryUrl = categoryUrl.substr(0, categoryUrl.indexOf('?')); + } + } + + return categoryUrl; + } + }); + + return $.mage.breadcrumbs; + }; +}); diff --git a/app/code/Magento/CatalogAnalytics/composer.json b/app/code/Magento/CatalogAnalytics/composer.json index bc3c8a1010449..a89ec10eac3ea 100644 --- a/app/code/Magento/CatalogAnalytics/composer.json +++ b/app/code/Magento/CatalogAnalytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/framework": "101.0.*", "magento/module-catalog": "102.0.*" }, diff --git a/app/code/Magento/CatalogImportExport/Model/Export/Product.php b/app/code/Magento/CatalogImportExport/Model/Export/Product.php index 45530ed6d7bae..6a14eb8b7a817 100644 --- a/app/code/Magento/CatalogImportExport/Model/Export/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Export/Product.php @@ -1346,6 +1346,12 @@ protected function optionRowToCellString($option) } /** + * Collect custom options data for products that will be exported. + * + * Option name and type will be collected for all store views, all other data (which can't be changed on store view + * level will be collected for DEFAULT_STORE_ID only. + * Store view specified data will be saved to the additional store view row. + * * @param int[] $productIds * @return array * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -1356,13 +1362,12 @@ protected function getCustomOptionsData($productIds) $customOptionsData = []; foreach (array_keys($this->_storeIdToCode) as $storeId) { - if (Store::DEFAULT_STORE_ID != $storeId) { - continue; - } $options = $this->_optionColFactory->create(); /* @var \Magento\Catalog\Model\ResourceModel\Product\Option\Collection $options*/ - $options->addOrder('sort_order'); - $options->reset()->addOrder('sort_order')->addTitleToResult( + $options->reset()->addOrder( + 'sort_order', + \Magento\Catalog\Model\ResourceModel\Product\Option\Collection::SORT_ORDER_ASC + )->addTitleToResult( $storeId )->addPriceToResult( $storeId @@ -1375,34 +1380,36 @@ protected function getCustomOptionsData($productIds) foreach ($options as $option) { $row = []; $productId = $option['product_id']; - $row['name'] = $option['title']; $row['type'] = $option['type']; - $row['required'] = $option['is_require']; - $row['price'] = $option['price']; - $row['price_type'] = ($option['price_type'] == 'percent') ? $option['price_type'] : 'fixed'; - $row['sku'] = $option['sku']; - if ($option['max_characters']) { - $row['max_characters'] = $option['max_characters']; - } - - foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { - if (!isset($option[$fileOptionKey])) { - continue; + if (Store::DEFAULT_STORE_ID === $storeId) { + $row['required'] = $option['is_require']; + $row['price'] = $option['price']; + $row['price_type'] = ($option['price_type'] === 'percent') ? 'percent' : 'fixed'; + $row['sku'] = $option['sku']; + if ($option['max_characters']) { + $row['max_characters'] = $option['max_characters']; } - $row[$fileOptionKey] = $option[$fileOptionKey]; - } + foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { + if (!isset($option[$fileOptionKey])) { + continue; + } + $row[$fileOptionKey] = $option[$fileOptionKey]; + } + } $values = $option->getValues(); if ($values) { foreach ($values as $value) { - $valuePriceType = ($value['price_type'] == 'percent') ? $value['price_type'] : 'fixed'; $row['option_title'] = $value['title']; - $row['price'] = $value['price']; - $row['price_type'] = $valuePriceType; - $row['sku'] = $value['sku']; + if (Store::DEFAULT_STORE_ID === $storeId) { + $row['option_title'] = $value['title']; + $row['price'] = $value['price']; + $row['price_type'] = ($value['price_type'] === 'percent') ? 'percent' : 'fixed'; + $row['sku'] = $value['sku']; + } $customOptionsData[$productId][$storeId][] = $this->optionRowToCellString($row); } } else { diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index 9dbeaeaba6938..2199936101357 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -1666,7 +1666,7 @@ protected function _saveProducts() $storeId = !empty($rowData[self::COL_STORE]) ? $this->getStoreIdByCode($rowData[self::COL_STORE]) : Store::DEFAULT_STORE_ID; - if (isset($rowData['_media_is_disabled'])) { + if (isset($rowData['_media_is_disabled']) && strlen(trim($rowData['_media_is_disabled']))) { $disabledImages = array_flip( explode($this->getMultipleValueSeparator(), $rowData['_media_is_disabled']) ); @@ -2116,39 +2116,8 @@ protected function _saveStockItem() $row = []; $sku = $rowData[self::COL_SKU]; if ($this->skuProcessor->getNewSku($sku) !== null) { - $row['product_id'] = $this->skuProcessor->getNewSku($sku)['entity_id']; + $row = $this->formatStockDataForRow($rowData); $productIdsToReindex[] = $row['product_id']; - - $row['website_id'] = $this->stockConfiguration->getDefaultScopeId(); - $row['stock_id'] = $this->stockRegistry->getStock($row['website_id'])->getStockId(); - - $stockItemDo = $this->stockRegistry->getStockItem($row['product_id'], $row['website_id']); - $existStockData = $stockItemDo->getData(); - - $row = array_merge( - $this->defaultStockData, - array_intersect_key($existStockData, $this->defaultStockData), - array_intersect_key($rowData, $this->defaultStockData), - $row - ); - - if ($this->stockConfiguration->isQty( - $this->skuProcessor->getNewSku($sku)['type_id'] - ) - ) { - $stockItemDo->setData($row); - $row['is_in_stock'] = $this->stockStateProvider->verifyStock($stockItemDo); - if ($this->stockStateProvider->verifyNotification($stockItemDo)) { - $row['low_stock_date'] = $this->dateTime->gmDate( - 'Y-m-d H:i:s', - (new \DateTime())->getTimestamp() - ); - } - $row['stock_status_changed_auto'] = - (int)!$this->stockStateProvider->verifyStock($stockItemDo); - } else { - $row['qty'] = 0; - } } if (!isset($stockData[$sku])) { @@ -2840,4 +2809,50 @@ private function getExistingSku($sku) { return $this->_oldSku[strtolower($sku)]; } + + /** + * Format row data to DB compatible values + * + * @param array $rowData + * @return array + */ + private function formatStockDataForRow(array $rowData) + { + $sku = $rowData[self::COL_SKU]; + $row['product_id'] = $this->skuProcessor->getNewSku($sku)['entity_id']; + $row['website_id'] = $this->stockConfiguration->getDefaultScopeId(); + $row['stock_id'] = $this->stockRegistry->getStock($row['website_id'])->getStockId(); + + $stockItemDo = $this->stockRegistry->getStockItem($row['product_id'], $row['website_id']); + $existStockData = $stockItemDo->getData(); + + $row = array_merge( + $this->defaultStockData, + array_intersect_key($existStockData, $this->defaultStockData), + array_intersect_key($rowData, $this->defaultStockData), + $row + ); + + if ($this->stockConfiguration->isQty( + $this->skuProcessor->getNewSku($sku)['type_id'] + ) + ) { + $stockItemDo->setData($row); + $row['is_in_stock'] = $stockItemDo->getBackorders() && isset($row['is_in_stock']) + ? $row['is_in_stock'] + : $this->stockStateProvider->verifyStock($stockItemDo); + if ($this->stockStateProvider->verifyNotification($stockItemDo)) { + $row['low_stock_date'] = $this->dateTime->gmDate( + 'Y-m-d H:i:s', + (new \DateTime())->getTimestamp() + ); + } + $row['stock_status_changed_auto'] = + (int)!$this->stockStateProvider->verifyStock($stockItemDo); + } else { + $row['qty'] = 0; + } + + return $row; + } } diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php index aa3f46a433a4d..0a6e8032d484a 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php @@ -14,6 +14,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\Collection as ProductOptionValueCollection; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory as ProductOptionValueCollectionFactory; +use Magento\Store\Model\Store; /** * Entity class which provide possibility to import product custom options @@ -23,6 +24,8 @@ * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) * @since 100.0.2 */ class Option extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity @@ -761,7 +764,7 @@ protected function _findExistingOptionId(array $newOptionData, array $newOptionT ksort($newOptionTitles); $existingOptions = $this->_oldCustomOptions[$productId]; foreach ($existingOptions as $optionId => $optionData) { - if ($optionData['type'] == $newOptionData['type'] && $optionData['titles'] == $newOptionTitles) { + if ($optionData['type'] == $newOptionData['type'] && $optionData['titles'][0] == $newOptionTitles[0]) { return $optionId; } } @@ -794,15 +797,15 @@ protected function _addRowsErrors($errorCode, array $errorNumbers) protected function _validateMainRow(array $rowData, $rowNumber) { if (!empty($rowData[self::COLUMN_STORE]) && !array_key_exists( - $rowData[self::COLUMN_STORE], - $this->_storeCodeToId - ) + $rowData[self::COLUMN_STORE], + $this->_storeCodeToId + ) ) { $this->_productEntity->addRowError(self::ERROR_INVALID_STORE, $rowNumber); } elseif (!empty($rowData[self::COLUMN_TYPE]) && !array_key_exists( - $rowData[self::COLUMN_TYPE], - $this->_specificTypes - ) + $rowData[self::COLUMN_TYPE], + $this->_specificTypes + ) ) { // type $this->_productEntity->addRowError(self::ERROR_INVALID_TYPE, $rowNumber); @@ -907,9 +910,9 @@ protected function _saveNewOptionData(array $rowData, $rowNumber) protected function _validateSecondaryRow(array $rowData, $rowNumber) { if (!empty($rowData[self::COLUMN_STORE]) && !array_key_exists( - $rowData[self::COLUMN_STORE], - $this->_storeCodeToId - ) + $rowData[self::COLUMN_STORE], + $this->_storeCodeToId + ) ) { $this->_productEntity->addRowError(self::ERROR_INVALID_STORE, $rowNumber); } elseif (!empty($rowData[self::COLUMN_ROW_PRICE]) && !is_numeric(rtrim($rowData[self::COLUMN_ROW_PRICE], '%')) @@ -1124,13 +1127,19 @@ private function processOptionRow($name, $optionRow) { $result = [ self::COLUMN_TYPE => $name ? $optionRow['type'] : '', - self::COLUMN_IS_REQUIRED => $optionRow['required'], - self::COLUMN_ROW_SKU => $optionRow['sku'], - self::COLUMN_PREFIX . 'sku' => $optionRow['sku'], self::COLUMN_ROW_TITLE => '', self::COLUMN_ROW_PRICE => '' ]; - + if (isset($optionRow['_custom_option_store'])) { + $result[self::COLUMN_STORE] = $optionRow['_custom_option_store']; + } + if (isset($optionRow['required'])) { + $result[self::COLUMN_IS_REQUIRED] = $optionRow['required']; + } + if (isset($optionRow['sku'])) { + $result[self::COLUMN_ROW_SKU] = $optionRow['sku']; + $result[self::COLUMN_PREFIX . 'sku'] = $optionRow['sku']; + } if (isset($optionRow['option_title'])) { $result[self::COLUMN_ROW_TITLE] = $optionRow['option_title']; } @@ -1175,7 +1184,8 @@ private function addFileOptions($result, $optionRow) } /** - * Import data rows + * Import data rows. + * Additional store view data (option titles) will be sought in store view specified import file rows * * @return boolean * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -1189,7 +1199,8 @@ protected function _importData() $this->_tables['catalog_product_option_type_value'] ); $prevOptionId = 0; - + $optionId = null; + $valueId = null; while ($bunch = $this->_dataSourceModel->getNextBunch()) { $products = []; $options = []; @@ -1200,13 +1211,23 @@ protected function _importData() $typeTitles = []; $parentCount = []; $childCount = []; + $optionsToRemove = []; foreach ($bunch as $rowNumber => $rowData) { - + if (isset($optionId, $valueId) && empty($rowData[PRODUCT::COL_STORE_VIEW_CODE])) { + $nextOptionId = $optionId; + $nextValueId = $valueId; + } + $optionId = $nextOptionId; + $valueId = $nextValueId; $multiRowData = $this->_getMultiRowFormat($rowData); - + if (!empty($rowData[self::COLUMN_SKU]) && isset($this->_productsSkuToId[$rowData[self::COLUMN_SKU]])) { + $this->_rowProductId = $this->_productsSkuToId[$rowData[self::COLUMN_SKU]]; + if (array_key_exists('custom_options', $rowData) && trim($rowData['custom_options']) === "") { + $optionsToRemove[] = $this->_rowProductId; + } + } foreach ($multiRowData as $optionData) { - $combinedData = array_merge($rowData, $optionData); if (!$this->isRowAllowedToImport($combinedData, $rowNumber)) { @@ -1218,7 +1239,7 @@ protected function _importData() $optionData = $this->_collectOptionMainData( $combinedData, $prevOptionId, - $nextOptionId, + $optionId, $products, $prices ); @@ -1228,7 +1249,7 @@ protected function _importData() $this->_collectOptionTypeData( $combinedData, $prevOptionId, - $nextValueId, + $valueId, $typeValues, $typePrices, $typeTitles, @@ -1239,32 +1260,23 @@ protected function _importData() } } - // Save prepared custom options data !!! + // Remove all existing options if import behaviour is APPEND + // in other case remove options for products with empty "custom_options" row only if ($this->getBehavior() != \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { $this->_deleteEntities(array_keys($products)); + } elseif (!empty($optionsToRemove)) { + // Remove options for products with empty "custom_options" row + $this->_deleteEntities($optionsToRemove); } + // Save prepared custom options data if ($this->_isReadyForSaving($options, $titles, $typeValues)) { - if ($this->getBehavior() == \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { - $this->_compareOptionsWithExisting($options, $titles, $prices, $typeValues); - $this->restoreOriginalOptionTypeIds($typeValues, $typePrices, $typeTitles); - } - - $this->_saveOptions( - $options - )->_saveTitles( - $titles - )->_savePrices( - $prices - )->_saveSpecificTypeValues( - $typeValues - )->_saveSpecificTypePrices( - $typePrices - )->_saveSpecificTypeTitles( - $typeTitles - )->_updateProducts( - $products - ); + $types = [ + 'values' => $typeValues, + 'prices' => $typePrices, + 'titles' => $typeTitles + ]; + $this->savePreparedCustomOptions($products, $options, $titles, $prices, $types); } } @@ -1311,15 +1323,12 @@ protected function _collectOptionMainData( $optionData = null; if ($this->_rowIsMain) { - $optionData = $this->_getOptionData($rowData, $this->_rowProductId, $nextOptionId, $this->_rowType); - - if (!$this->_isRowHasSpecificType( - $this->_rowType - ) && ($priceData = $this->_getPriceData( - $rowData, - $nextOptionId, - $this->_rowType - )) + $optionData = empty($rowData[Product::COL_STORE_VIEW_CODE]) + ? $this->_getOptionData($rowData, $this->_rowProductId, $nextOptionId, $this->_rowType) + : ''; + + if (!$this->_isRowHasSpecificType($this->_rowType) + && ($priceData = $this->_getPriceData($rowData, $nextOptionId, $this->_rowType)) ) { $prices[$nextOptionId] = $priceData; } @@ -1347,6 +1356,7 @@ protected function _collectOptionMainData( * @param array &$childCount * @return void * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ protected function _collectOptionTypeData( array $rowData, @@ -1365,39 +1375,27 @@ protected function _collectOptionTypeData( $typeValues[$prevOptionId][] = $specificTypeData['value']; // ensure default title is set - if (!isset($typeTitles[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID])) { - $typeTitles[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = $specificTypeData['title']; + if (!isset($typeTitles[$nextValueId][Store::DEFAULT_STORE_ID])) { + $typeTitles[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['title']; } if ($specificTypeData['price']) { if ($this->_isPriceGlobal) { - $typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = $specificTypeData['price']; + $typePrices[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['price']; } else { // ensure default price is set - if (!isset($typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID])) { - $typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = $specificTypeData['price']; + if (!isset($typePrices[$nextValueId][Store::DEFAULT_STORE_ID])) { + $typePrices[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['price']; } $typePrices[$nextValueId][$this->_rowStoreId] = $specificTypeData['price']; } } - $nextValueId++; - if (isset($parentCount[$prevOptionId])) { - $parentCount[$prevOptionId]++; - } else { - $parentCount[$prevOptionId] = 1; - } } - - if (!isset($childCount[$this->_rowStoreId][$prevOptionId])) { - $childCount[$this->_rowStoreId][$prevOptionId] = 0; - } - $parentValueId = $nextValueId - $parentCount[$prevOptionId] + $childCount[$this->_rowStoreId][$prevOptionId]; - $specificTypeData = $this->_getSpecificTypeData($rowData, $parentValueId, false); + $specificTypeData = $this->_getSpecificTypeData($rowData, 0, false); //For others stores if ($specificTypeData) { - $typeTitles[$parentValueId][$this->_rowStoreId] = $specificTypeData['title']; - $childCount[$this->_rowStoreId][$prevOptionId]++; + $typeTitles[$nextValueId++][$this->_rowStoreId] = $specificTypeData['title']; } } } @@ -1412,7 +1410,7 @@ protected function _collectOptionTypeData( */ protected function _collectOptionTitle(array $rowData, $prevOptionId, array &$titles) { - $defaultStoreId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $defaultStoreId = Store::DEFAULT_STORE_ID; if (!empty($rowData[self::COLUMN_TITLE])) { if (!isset($titles[$prevOptionId][$defaultStoreId])) { // ensure default title is set @@ -1523,12 +1521,9 @@ private function getExistingOptionTypeId($optionId, $storeId, $optionTypeTitle) */ protected function _parseRequiredData(array $rowData) { - if ($rowData[self::COLUMN_SKU] != '' && isset($this->_productsSkuToId[$rowData[self::COLUMN_SKU]])) { - $this->_rowProductId = $this->_productsSkuToId[$rowData[self::COLUMN_SKU]]; - } elseif (!isset($this->_rowProductId)) { + if (!isset($this->_rowProductId)) { return false; } - // Init store if (!empty($rowData[self::COLUMN_STORE])) { if (!isset($this->_storeCodeToId[$rowData[self::COLUMN_STORE]])) { @@ -1536,7 +1531,7 @@ protected function _parseRequiredData(array $rowData) } $this->_rowStoreId = $this->_storeCodeToId[$rowData[self::COLUMN_STORE]]; } else { - $this->_rowStoreId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $this->_rowStoreId = Store::DEFAULT_STORE_ID; } // Init option type and set param which tell that row is main if (!empty($rowData[self::COLUMN_TYPE])) { @@ -1655,7 +1650,7 @@ protected function _getPriceData(array $rowData, $optionId, $type) ) { $priceData = [ 'option_id' => $optionId, - 'store_id' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, + 'store_id' => Store::DEFAULT_STORE_ID, 'price_type' => 'fixed', ]; @@ -1982,4 +1977,44 @@ private function getProductIdentifierField() } return $this->productEntityIdentifierField; } + + /** + * Save prepared custom options + * + * @param array $products + * @param array $options + * @param array $titles + * @param array $prices + * @param array $types + * + * @return void + */ + private function savePreparedCustomOptions( + array $products, + array $options, + array $titles, + array $prices, + array $types + ) { + if ($this->getBehavior() == \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { + $this->_compareOptionsWithExisting($options, $titles, $prices, $types['values']); + $this->restoreOriginalOptionTypeIds($types['values'], $types['prices'], $types['titles']); + } + + $this->_saveOptions( + $options + )->_saveTitles( + $titles + )->_savePrices( + $prices + )->_saveSpecificTypeValues( + $types['values'] + )->_saveSpecificTypePrices( + $types['prices'] + )->_saveSpecificTypeTitles( + $types['titles'] + )->_updateProducts( + $products + ); + } } diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php index 939d6b2de67ee..17d084002926a 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php @@ -503,7 +503,7 @@ public function prepareAttributesWithDefaultValueForSave(array $rowData, $withDe if ($attrParams['is_static']) { continue; } - if (isset($rowData[$attrCode]) && strlen($rowData[$attrCode])) { + if (isset($rowData[$attrCode]) && strlen(trim($rowData[$attrCode]))) { if (in_array($attrParams['type'], ['select', 'boolean'])) { $resultAttrs[$attrCode] = $attrParams['options'][strtolower($rowData[$attrCode])]; } elseif ('multiselect' == $attrParams['type']) { diff --git a/app/code/Magento/CatalogImportExport/composer.json b/app/code/Magento/CatalogImportExport/composer.json index cd2cb8e26f1c2..823f9a454e64f 100644 --- a/app/code/Magento/CatalogImportExport/composer.json +++ b/app/code/Magento/CatalogImportExport/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-import-export", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-catalog": "102.0.*", "magento/module-catalog-url-rewrite": "100.2.*", "magento/module-eav": "101.0.*", @@ -16,7 +16,7 @@ "ext-ctype": "*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php b/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php index 83defa64df250..c6d2a202018b5 100644 --- a/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php +++ b/app/code/Magento/CatalogInventory/Api/Data/StockStatusInterface.php @@ -14,6 +14,14 @@ */ interface StockStatusInterface extends ExtensibleDataInterface { + /**#@+ + * Stock Status values + */ + const STATUS_OUT_OF_STOCK = 0; + + const STATUS_IN_STOCK = 1; + /**#@-*/ + /**#@+ * Stock status object data keys */ diff --git a/app/code/Magento/CatalogInventory/Block/Plugin/ProductView.php b/app/code/Magento/CatalogInventory/Block/Plugin/ProductView.php index cf47f39faac45..8355a96e3d0e8 100644 --- a/app/code/Magento/CatalogInventory/Block/Plugin/ProductView.php +++ b/app/code/Magento/CatalogInventory/Block/Plugin/ProductView.php @@ -39,8 +39,8 @@ public function afterGetQuantityValidators( $params = []; $params['minAllowed'] = (float)$stockItem->getMinSaleQty(); - if ($stockItem->getQtyMaxAllowed()) { - $params['maxAllowed'] = $stockItem->getQtyMaxAllowed(); + if ($stockItem->getMaxSaleQty()) { + $params['maxAllowed'] = (float)$stockItem->getMaxSaleQty(); } if ($stockItem->getQtyIncrements() > 0) { $params['qtyIncrements'] = (float)$stockItem->getQtyIncrements(); diff --git a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php index 3fb0790640ffc..30a1cce77cd70 100644 --- a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php +++ b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php @@ -103,8 +103,7 @@ public function validate(Observer $observer) $quoteItem = $observer->getEvent()->getItem(); if (!$quoteItem || !$quoteItem->getProductId() || - !$quoteItem->getQuote() || - $quoteItem->getQuote()->getIsSuperMode() + !$quoteItem->getQuote() ) { return; } @@ -117,6 +116,18 @@ public function validate(Observer $observer) throw new LocalizedException(__('The stock item for Product is not valid.')); } + if (($options = $quoteItem->getQtyOptions()) && $qty > 0) { + foreach ($options as $option) { + $this->optionInitializer->initialize($option, $quoteItem, $qty); + } + } else { + $this->stockItemInitializer->initialize($stockItem, $quoteItem, $qty); + } + + if ($quoteItem->getQuote()->getIsSuperMode()) { + return; + } + /* @var \Magento\CatalogInventory\Api\Data\StockStatusInterface $stockStatus */ $stockStatus = $this->stockRegistry->getStockStatus($product->getId(), $product->getStore()->getWebsiteId()); @@ -159,7 +170,7 @@ public function validate(Observer $observer) /** * Check item for options */ - if (($options = $quoteItem->getQtyOptions()) && $qty > 0) { + if ($options) { $qty = $product->getTypeInstance()->prepareQuoteItemQty($qty, $product); $quoteItem->setData('qty', $qty); if ($stockStatus) { @@ -193,7 +204,7 @@ public function validate(Observer $observer) $removeError = true; foreach ($options as $option) { - $result = $this->optionInitializer->initialize($option, $quoteItem, $qty); + $result = $option->getStockStateResult(); if ($result->getHasError()) { $option->setHasError(true); //Setting this to false, so no error statuses are cleared @@ -206,7 +217,7 @@ public function validate(Observer $observer) } } else { if ($quoteItem->getParentItem() === null) { - $result = $this->stockItemInitializer->initialize($stockItem, $quoteItem, $qty); + $result = $quoteItem->getStockStateResult(); if ($result->getHasError()) { $this->addErrorInfoToQuote($result, $quoteItem); } else { diff --git a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php index 3e972a1b84203..b99e43d52f470 100644 --- a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php +++ b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php @@ -133,6 +133,8 @@ public function initialize( $stockItem->unsIsChildItem(); + $option->setStockStateResult($result); + return $result; } } diff --git a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php index 6bdc4c67de658..6fb0a949941ec 100644 --- a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php +++ b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php @@ -135,6 +135,8 @@ public function initialize( $quoteItem->setBackorders($result->getItemBackorders()); } + $quoteItem->setStockStateResult($result); + return $result; } } diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Indexer/Stock/DefaultStock.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Indexer/Stock/DefaultStock.php index 366cb1c3902a3..317d055d3e481 100644 --- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Indexer/Stock/DefaultStock.php +++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Indexer/Stock/DefaultStock.php @@ -288,6 +288,7 @@ protected function _prepareIndexTable($entityIds = null) */ protected function _updateIndex($entityIds) { + $this->deleteOldRecords($entityIds); $connection = $this->getConnection(); $select = $this->_getStockStatusSelect($entityIds, true); $select = $this->getQueryProcessorComposite()->processQuery($select, $entityIds, true); @@ -310,7 +311,6 @@ protected function _updateIndex($entityIds) } } - $this->deleteOldRecords($entityIds); $this->_updateIndexTable($data); return $this; diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php index 9223fd32e3567..4a39ac2868046 100644 --- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php +++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php @@ -113,20 +113,20 @@ protected function _construct() } /** - * Lock Stock Item records + * Lock Stock Item records. * * @param int[] $productIds * @param int $websiteId * @return array */ - public function lockProductsStock($productIds, $websiteId) + public function lockProductsStock(array $productIds, $websiteId) { if (empty($productIds)) { return []; } $itemTable = $this->getTable('cataloginventory_stock_item'); $select = $this->getConnection()->select()->from(['si' => $itemTable]) - ->where('website_id=?', $websiteId) + ->where('website_id = ?', $websiteId) ->where('product_id IN(?)', $productIds) ->forUpdate(true); @@ -136,12 +136,19 @@ public function lockProductsStock($productIds, $websiteId) ->columns( [ 'product_id' => 'entity_id', - 'type_id' => 'type_id' + 'type_id' => 'type_id', ] ); - $this->getConnection()->query($select); + $items = []; - return $this->getConnection()->fetchAll($selectProducts); + foreach ($this->getConnection()->query($select)->fetchAll() as $si) { + $items[$si['product_id']] = $si; + } + foreach ($this->getConnection()->fetchAll($selectProducts) as $p) { + $items[$p['product_id']]['type_id'] = $p['type_id']; + } + + return $items; } /** diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php index e9f3cd59af0bb..4e04ed059c8e2 100644 --- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php +++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php @@ -5,8 +5,8 @@ */ namespace Magento\CatalogInventory\Model\ResourceModel\Stock; -use Magento\CatalogInventory\Model\Stock; use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\CatalogInventory\Model\Stock; use Magento\Framework\App\ObjectManager; /** @@ -46,19 +46,23 @@ class Status extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb * @param \Magento\Store\Model\WebsiteFactory $websiteFactory * @param \Magento\Eav\Model\Config $eavConfig * @param string $connectionName + * @param \Magento\CatalogInventory\Api\StockConfigurationInterface $stockConfiguration */ public function __construct( \Magento\Framework\Model\ResourceModel\Db\Context $context, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Store\Model\WebsiteFactory $websiteFactory, \Magento\Eav\Model\Config $eavConfig, - $connectionName = null + $connectionName = null, + $stockConfiguration = null ) { parent::__construct($context, $connectionName); $this->_storeManager = $storeManager; $this->_websiteFactory = $websiteFactory; $this->eavConfig = $eavConfig; + $this->stockConfiguration = $stockConfiguration ?: ObjectManager::getInstance() + ->get(StockConfigurationInterface::class); } /** @@ -204,7 +208,7 @@ public function getProductCollection($lastEntityId = 0, $limit = 1000) */ public function addStockStatusToSelect(\Magento\Framework\DB\Select $select, \Magento\Store\Model\Website $website) { - $websiteId = $this->getStockConfiguration()->getDefaultScopeId(); + $websiteId = $this->getWebsiteId($website->getId()); $select->joinLeft( ['stock_status' => $this->getMainTable()], 'e.entity_id = stock_status.product_id AND stock_status.website_id=' . $websiteId, @@ -221,7 +225,7 @@ public function addStockStatusToSelect(\Magento\Framework\DB\Select $select, \Ma */ public function addStockDataToCollection($collection, $isFilterInStock) { - $websiteId = $this->getStockConfiguration()->getDefaultScopeId(); + $websiteId = $this->getWebsiteId(); $joinCondition = $this->getConnection()->quoteInto( 'e.entity_id = stock_status_index.product_id' . ' AND stock_status_index.website_id = ?', $websiteId @@ -255,7 +259,7 @@ public function addStockDataToCollection($collection, $isFilterInStock) */ public function addIsInStockFilterToCollection($collection) { - $websiteId = $this->getStockConfiguration()->getDefaultScopeId(); + $websiteId = $this->getWebsiteId(); $joinCondition = $this->getConnection()->quoteInto( 'e.entity_id = stock_status_index.product_id' . ' AND stock_status_index.website_id = ?', $websiteId @@ -277,6 +281,19 @@ public function addIsInStockFilterToCollection($collection) return $this; } + /** + * @param \Magento\Store\Model\Website $websiteId + * @return int + */ + private function getWebsiteId($websiteId = null) + { + if (null === $websiteId) { + $websiteId = $this->stockConfiguration->getDefaultScopeId(); + } + + return $websiteId; + } + /** * Retrieve Product(s) status for store * Return array where key is a product_id, value - status @@ -335,18 +352,4 @@ public function getProductStatus($productIds, $storeId = null) } return $statuses; } - - /** - * @return StockConfigurationInterface - * - * @deprecated 100.1.0 - */ - private function getStockConfiguration() - { - if ($this->stockConfiguration === null) { - $this->stockConfiguration = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\CatalogInventory\Api\StockConfigurationInterface::class); - } - return $this->stockConfiguration; - } } diff --git a/app/code/Magento/CatalogInventory/Model/Source/Stock.php b/app/code/Magento/CatalogInventory/Model/Source/Stock.php index f64026cce23a5..9ed891d1dcc0f 100644 --- a/app/code/Magento/CatalogInventory/Model/Source/Stock.php +++ b/app/code/Magento/CatalogInventory/Model/Source/Stock.php @@ -26,4 +26,23 @@ public function getAllOptions() ['value' => \Magento\CatalogInventory\Model\Stock::STOCK_OUT_OF_STOCK, 'label' => __('Out of Stock')] ]; } + + /** + * Add Value Sort To Collection Select. + * + * @param \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection + * @param string $dir + * + * @return $this + */ + public function addValueSortToCollection($collection, $dir = \Magento\Framework\Data\Collection::SORT_ORDER_DESC) + { + $collection->getSelect()->joinLeft( + ['stock_item_table' => 'cataloginventory_stock_item'], + "e.entity_id=stock_item_table.product_id", + [] + ); + $collection->getSelect()->order("stock_item_table.qty $dir"); + return $this; + } } diff --git a/app/code/Magento/CatalogInventory/Model/Stock/Item.php b/app/code/Magento/CatalogInventory/Model/Stock/Item.php index b4b70041ce148..efba8cd97d040 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/Item.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/Item.php @@ -401,7 +401,8 @@ public function getQtyIncrements() if ($this->getUseConfigQtyIncrements()) { $this->qtyIncrements = $this->stockConfiguration->getQtyIncrements($this->getStoreId()); } else { - $this->qtyIncrements = (int) $this->getData(static::QTY_INCREMENTS); + $qtyIncrements = $this->getData(static::QTY_INCREMENTS); + $this->qtyIncrements = $this->getIsQtyDecimal() ? (float) $qtyIncrements : (int) $qtyIncrements; } } if ($this->qtyIncrements <= 0) { diff --git a/app/code/Magento/CatalogInventory/Model/Stock/Status.php b/app/code/Magento/CatalogInventory/Model/Stock/Status.php index 9a56c8e8804ec..899056d8f0835 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/Status.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/Status.php @@ -17,14 +17,6 @@ */ class Status extends AbstractExtensibleModel implements StockStatusInterface { - /**#@+ - * Stock Status values - */ - const STATUS_OUT_OF_STOCK = 0; - - const STATUS_IN_STOCK = 1; - /**#@-*/ - /**#@+ * Field name */ diff --git a/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php b/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php index e5154a10f0a19..0d0d42009315e 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php @@ -10,7 +10,7 @@ use Magento\CatalogInventory\Api\Data\StockItemInterface; use Magento\CatalogInventory\Api\Data\StockItemInterfaceFactory; use Magento\CatalogInventory\Api\StockConfigurationInterface; -use Magento\CatalogInventory\Api\StockItemRepositoryInterface as StockItemRepositoryInterface; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; use Magento\CatalogInventory\Model\Indexer\Stock\Processor; use Magento\CatalogInventory\Model\ResourceModel\Stock\Item as StockItemResource; use Magento\CatalogInventory\Model\Spi\StockStateProviderInterface; @@ -145,7 +145,7 @@ public function __construct( /** * @inheritdoc */ - public function save(\Magento\CatalogInventory\Api\Data\StockItemInterface $stockItem) + public function save(StockItemInterface $stockItem) { try { /** @var \Magento\Catalog\Model\Product $product */ @@ -161,10 +161,7 @@ public function save(\Magento\CatalogInventory\Api\Data\StockItemInterface $stoc $typeId = $product->getTypeId() ?: $product->getTypeInstance()->getTypeId(); $isQty = $this->stockConfiguration->isQty($typeId); if ($isQty) { - $isInStock = $this->stockStateProvider->verifyStock($stockItem); - if ($stockItem->getManageStock() && !$isInStock) { - $stockItem->setIsInStock(false)->setStockStatusChangedAutomaticallyFlag(true); - } + $this->changeIsInStockIfNecessary($stockItem); // if qty is below notify qty, update the low stock date to today date otherwise set null $stockItem->setLowStockDate(null); if ($this->stockStateProvider->verifyNotification($stockItem)) { @@ -260,4 +257,29 @@ private function getStockRegistryStorage() } return $this->stockRegistryStorage; } + + /** + * Change is_in_stock value if necessary. + * + * @param StockItemInterface $stockItem + * + * @return void + */ + private function changeIsInStockIfNecessary(StockItemInterface $stockItem) + { + $isInStock = $this->stockStateProvider->verifyStock($stockItem); + if ($stockItem->getManageStock() && !$isInStock) { + $stockItem->setIsInStock(false)->setStockStatusChangedAutomaticallyFlag(true); + } + + if ($stockItem->getManageStock() + && $isInStock + && !$stockItem->getIsInStock() + && $stockItem->getQty() > 0 + && $stockItem->getOrigData(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY) <= 0 + && $stockItem->getOrigData(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY) !== null + ) { + $stockItem->setIsInStock(true)->setStockStatusChangedAutomaticallyFlag(true); + } + } } diff --git a/app/code/Magento/CatalogInventory/Model/StockManagement.php b/app/code/Magento/CatalogInventory/Model/StockManagement.php index 06599446a9ea9..107645a45a390 100644 --- a/app/code/Magento/CatalogInventory/Model/StockManagement.php +++ b/app/code/Magento/CatalogInventory/Model/StockManagement.php @@ -48,6 +48,11 @@ class StockManagement implements StockManagementInterface */ private $qtyCounter; + /** + * @var StockRegistryStorage + */ + private $stockRegistryStorage; + /** * @param ResourceStock $stockResource * @param StockRegistryProviderInterface $stockRegistryProvider @@ -55,6 +60,7 @@ class StockManagement implements StockManagementInterface * @param StockConfigurationInterface $stockConfiguration * @param ProductRepositoryInterface $productRepository * @param QtyCounterInterface $qtyCounter + * @param StockRegistryStorage|null $stockRegistryStorage */ public function __construct( ResourceStock $stockResource, @@ -62,7 +68,8 @@ public function __construct( StockState $stockState, StockConfigurationInterface $stockConfiguration, ProductRepositoryInterface $productRepository, - QtyCounterInterface $qtyCounter + QtyCounterInterface $qtyCounter, + StockRegistryStorage $stockRegistryStorage = null ) { $this->stockRegistryProvider = $stockRegistryProvider; $this->stockState = $stockState; @@ -70,11 +77,13 @@ public function __construct( $this->productRepository = $productRepository; $this->qtyCounter = $qtyCounter; $this->resource = $stockResource; + $this->stockRegistryStorage = $stockRegistryStorage ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(StockRegistryStorage::class); } /** * Subtract product qtys from stock. - * Return array of items that require full save + * Return array of items that require full save. * * @param string[] $items * @param int $websiteId @@ -92,9 +101,12 @@ public function registerProductsSale($items, $websiteId = null) $fullSaveItems = $registeredItems = []; foreach ($lockedItems as $lockedItemRecord) { $productId = $lockedItemRecord['product_id']; + $this->stockRegistryStorage->removeStockItem($productId, $websiteId); + /** @var StockItemInterface $stockItem */ $orderedQty = $items[$productId]; $stockItem = $this->stockRegistryProvider->getStockItem($productId, $websiteId); + $stockItem->setQty($lockedItemRecord['qty']); // update data from locked item $canSubtractQty = $stockItem->getItemId() && $this->canSubtractQty($stockItem); if (!$canSubtractQty || !$this->stockConfiguration->isQty($lockedItemRecord['type_id'])) { continue; @@ -102,7 +114,7 @@ public function registerProductsSale($items, $websiteId = null) if (!$stockItem->hasAdminArea() && !$this->stockState->checkQty($productId, $orderedQty, $stockItem->getWebsiteId()) ) { - $this->getResource()->rollBack(); + $this->getResource()->commit(); throw new \Magento\Framework\Exception\LocalizedException( __('Not all of your products are available in the requested quantity.') ); @@ -122,6 +134,7 @@ public function registerProductsSale($items, $websiteId = null) } $this->qtyCounter->correctItemsQty($registeredItems, $websiteId, '-'); $this->getResource()->commit(); + return $fullSaveItems; } diff --git a/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php b/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php index e473f714bd21d..cea19c098b928 100644 --- a/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php +++ b/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php @@ -97,7 +97,7 @@ private function prepareQuantityAndStockStatus(StockItemInterface $stockItem, ar ) { unset($quantityAndStockStatus['is_in_stock']); } - if (isset($quantityAndStockStatus['qty']) + if (array_key_exists('qty', $quantityAndStockStatus) && $stockItem->getQty() == $quantityAndStockStatus['qty'] ) { unset($quantityAndStockStatus['qty']); diff --git a/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php b/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php index 6fbec08e4805b..5d776a488b65e 100644 --- a/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php +++ b/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php @@ -6,9 +6,11 @@ namespace Magento\CatalogInventory\Observer; +use Magento\CatalogInventory\Api\Data\StockItemInterface; use Magento\Framework\Event\ObserverInterface; use Magento\CatalogInventory\Api\StockManagementInterface; use Magento\Framework\Event\Observer as EventObserver; +use Magento\Sales\Api\Data\OrderInterface; /** * Catalog inventory module observer @@ -59,16 +61,20 @@ public function execute(EventObserver $observer) { /** @var \Magento\Quote\Model\Quote $quote */ $quote = $observer->getEvent()->getQuote(); + /** @var OrderInterface|null $order */ + $order = $observer->getEvent()->getOrder(); // Maybe we've already processed this quote in some event during order placement // e.g. call in event 'sales_model_service_quote_submit_before' and later in 'checkout_submit_all_after' if ($quote->getInventoryProcessed()) { return $this; } - $items = $this->productQty->getProductQty($quote->getAllItems()); + $items = $this->productQty->getProductQty($quote->getAllItems()); /** * Remember items + * + * @var StockItemInterface[] $itemsForReindex */ $itemsForReindex = $this->stockManagement->registerProductsSale( $items, @@ -76,6 +82,28 @@ public function execute(EventObserver $observer) ); $this->itemsForReindex->setItems($itemsForReindex); + if ($order) { + //Marking items as backordered if order is placed. + /** @var StockItemInterface[] $stockItems */ + $stockItems = []; + foreach ($itemsForReindex as $stockItem) { + $stockItems[$stockItem->getProductId()] = $stockItem; + } + foreach ($order->getItems() as $orderItem) { + if (!empty($stockItems[$orderItem->getProductId()])) { + $stock = $stockItems[$orderItem->getProductId()]; + //Found stock of ordered item, + //checking if the item was backordered. + if (($qty = $stock->getQty()) < 0) { + $orderItem->setQtyBackordered( + $orderItem->getQtyOrdered() > (-$qty) + ? (-$qty) : $orderItem->getQtyOrdered() + ); + } + } + } + } + $quote->setInventoryProcessed(true); return $this; } diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php index 4ef2e78e590fb..4ec795daf86aa 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php @@ -28,7 +28,7 @@ protected function setUp() $this->stockItem = $this->getMockBuilder(\Magento\CatalogInventory\Model\Stock\Item::class) ->disableOriginalConstructor() - ->setMethods(['getMinSaleQty', 'getQtyMaxAllowed', 'getQtyIncrements']) + ->setMethods(['getMinSaleQty', 'getMaxSaleQty', 'getQtyIncrements']) ->getMock(); $this->stockRegistry = $this->getMockBuilder(\Magento\CatalogInventory\Api\StockRegistryInterface::class) @@ -48,8 +48,8 @@ public function testAfterGetQuantityValidators() 'validate-item-quantity' => [ 'minAllowed' => 0.5, - 'maxAllowed' => 5, - 'qtyIncrements' => 3 + 'maxAllowed' => 5.0, + 'qtyIncrements' => 3.0 ] ]; $validators = []; @@ -74,7 +74,7 @@ public function testAfterGetQuantityValidators() ->with('productId', 'websiteId') ->willReturn($this->stockItem); $this->stockItem->expects($this->once())->method('getMinSaleQty')->willReturn(0.5); - $this->stockItem->expects($this->any())->method('getQtyMaxAllowed')->willReturn(5); + $this->stockItem->expects($this->any())->method('getMaxSaleQty')->willReturn(5); $this->stockItem->expects($this->any())->method('getQtyIncrements')->willReturn(3); $this->assertEquals($result, $this->block->afterGetQuantityValidators($productViewBlock, $validators)); diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php index 86a021768a6b3..11a04d26994ae 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php @@ -278,8 +278,11 @@ public function testValidateWithOptions() { $optionMock = $this->getMockBuilder(OptionItem::class) ->disableOriginalConstructor() - ->setMethods(['setHasError']) + ->setMethods(['setHasError', 'getStockStateResult']) ->getMock(); + $optionMock->expects($this->once()) + ->method('getStockStateResult') + ->willReturn($this->resultMock); $this->stockRegistryMock->expects($this->at(0)) ->method('getStockItem') ->willReturn($this->stockItemMock); @@ -316,7 +319,7 @@ public function testValidateWithOptionsAndError() { $optionMock = $this->getMockBuilder(OptionItem::class) ->disableOriginalConstructor() - ->setMethods(['setHasError']) + ->setMethods(['setHasError', 'getStockStateResult']) ->getMock(); $this->stockRegistryMock->expects($this->at(0)) ->method('getStockItem') @@ -324,6 +327,9 @@ public function testValidateWithOptionsAndError() $this->stockRegistryMock->expects($this->at(1)) ->method('getStockStatus') ->willReturn($this->stockStatusMock); + $optionMock->expects($this->once()) + ->method('getStockStateResult') + ->willReturn($this->resultMock); $options = [$optionMock]; $this->createInitialStub(1); $this->setUpStubForQuantity(1, true); @@ -354,12 +360,15 @@ public function testValidateAndRemoveErrorsFromQuote() { $optionMock = $this->getMockBuilder(OptionItem::class) ->disableOriginalConstructor() - ->setMethods(['setHasError']) + ->setMethods(['setHasError', 'getStockStateResult']) ->getMock(); $quoteItem = $this->getMockBuilder(Item::class) ->disableOriginalConstructor() ->setMethods(['getItemId', 'getErrorInfos']) ->getMock(); + $optionMock->expects($this->once()) + ->method('getStockStateResult') + ->willReturn($this->resultMock); $this->stockRegistryMock->expects($this->at(0)) ->method('getStockItem') ->willReturn($this->stockItemMock); diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php index d60a1f3e400dd..8c9a1aa7715ec 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php @@ -84,6 +84,7 @@ public function testInitializeWithSubitem() 'setMessage', 'setBackorders', '__wakeup', + 'setStockStateResult' ] ) ->disableOriginalConstructor() @@ -178,6 +179,7 @@ public function testInitializeWithSubitem() $quoteItem->expects($this->once())->method('setMessage')->with('message')->will($this->returnSelf()); $result->expects($this->exactly(2))->method('getItemBackorders')->will($this->returnValue('backorders')); $quoteItem->expects($this->once())->method('setBackorders')->with('backorders')->will($this->returnSelf()); + $quoteItem->expects($this->once())->method('setStockStateResult')->with($result)->will($this->returnSelf()); $this->model->initialize($stockItem, $quoteItem, $qty); } diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php new file mode 100644 index 0000000000000..003dffdbd4d38 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php @@ -0,0 +1,207 @@ +selectMock = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor() + ->getMock(); + $this->contextMock = $objectManager->getObject(Context::class); + $this->scopeConfigMock = $this->getMockBuilder(Config::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dateTimeMock = $this->getMockBuilder(DateTime::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockConfigurationMock = $this->getMockBuilder(StockConfiguration::class) + ->setMethods(['getIsQtyTypeIds', 'getDefaultScopeId']) + ->disableOriginalConstructor() + ->getMock(); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connectionMock = $this->getMockBuilder(Mysql::class) + ->disableOriginalConstructor() + ->getMock(); + $this->statementMock = $this->getMockForAbstractClass(\Zend_Db_Statement_Interface::class); + $this->stock = $this->getMockBuilder(Stock::class) + ->setMethods(['getTable', 'getConnection']) + ->setConstructorArgs( + [ + 'context' => $this->contextMock, + 'scopeConfig' => $this->scopeConfigMock, + 'dateTime' => $this->dateTimeMock, + 'stockConfiguration' => $this->stockConfigurationMock, + 'storeManager' => $this->storeManagerMock, + ] + )->getMock(); + } + + /** + * Test Save Product Status per website with product ids. + * + * @dataProvider productsDataProvider + * @param int $websiteId + * @param array $productIds + * @param array $products + * @param array $result + * + * @return void + */ + public function testLockProductsStock($websiteId, array $productIds, array $products, array $result) + { + $this->selectMock->expects($this->exactly(2)) + ->method('from') + ->withConsecutive( + [$this->identicalTo(['si' => self::ITEM_TABLE])], + [$this->identicalTo(['p' => self::PRODUCT_TABLE]), $this->identicalTo([])] + ) + ->willReturnSelf(); + $this->selectMock->expects($this->exactly(3)) + ->method('where') + ->withConsecutive( + [$this->identicalTo('website_id = ?'), $this->identicalTo($websiteId)], + [$this->identicalTo('product_id IN(?)'), $this->identicalTo($productIds)], + [$this->identicalTo('entity_id IN (?)'), $this->identicalTo($productIds)] + ) + ->willReturnSelf(); + $this->selectMock->expects($this->once()) + ->method('forUpdate') + ->with($this->identicalTo(true)) + ->willReturnSelf(); + $this->selectMock->expects($this->once()) + ->method('columns') + ->with($this->identicalTo(['product_id' => 'entity_id', 'type_id' => 'type_id'])) + ->willReturnSelf(); + $this->connectionMock->expects($this->exactly(2)) + ->method('select') + ->willReturn($this->selectMock); + $this->connectionMock->expects($this->once()) + ->method('query') + ->with($this->identicalTo($this->selectMock)) + ->willReturn($this->statementMock); + $this->statementMock->expects($this->once()) + ->method('fetchAll') + ->willReturn($products); + $this->connectionMock->expects($this->once()) + ->method('fetchAll') + ->with($this->identicalTo($this->selectMock)) + ->willReturn($result); + $this->stock->expects($this->exactly(2)) + ->method('getTable') + ->withConsecutive( + [$this->identicalTo('cataloginventory_stock_item')], + [$this->identicalTo('catalog_product_entity')] + )->will($this->onConsecutiveCalls( + self::ITEM_TABLE, + self::PRODUCT_TABLE + )); + $this->stock->expects($this->exactly(4)) + ->method('getConnection') + ->willReturn($this->connectionMock); + + $lockResult = $this->stock->lockProductsStock($productIds, $websiteId); + + $this->assertEquals($result, $lockResult); + } + + /** + * @return array + */ + public function productsDataProvider() + { + return [ + [ + 0, + [1, 2, 3], + [ + 1 => ['product_id' => 1], + 2 => ['product_id' => 2], + 3 => ['product_id' => 3], + ], + [ + 1 => [ + 'product_id' => 1, + 'type_id' => 'simple', + ], + 2 => [ + 'product_id' => 2, + 'type_id' => 'simple', + ], + 3 => [ + 'product_id' => 3, + 'type_id' => 'simple', + ], + ], + ], + ]; + } +} diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Source/StockTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Source/StockTest.php new file mode 100644 index 0000000000000..11f41fcaf6d01 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Source/StockTest.php @@ -0,0 +1,44 @@ +model = new \Magento\CatalogInventory\Model\Source\Stock(); + } + + public function testAddValueSortToCollection() + { + $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $collectionMock = $this->createMock(\Magento\Eav\Model\Entity\Collection\AbstractCollection::class); + $collectionMock->expects($this->atLeastOnce())->method('getSelect')->willReturn($selectMock); + + $selectMock->expects($this->once()) + ->method('joinLeft') + ->with( + ['stock_item_table' => 'cataloginventory_stock_item'], + "e.entity_id=stock_item_table.product_id", + [] + ) + ->willReturnSelf(); + $selectMock->expects($this->once()) + ->method('order') + ->with("stock_item_table.qty DESC") + ->willReturnSelf(); + + $this->model->addValueSortToCollection($collectionMock); + } +} diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php index bbc7823b13e01..d1d8e171bea16 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php @@ -394,6 +394,7 @@ public function testGetQtyIncrements($config, $expected) $this->setDataArrayValue('qty_increments', $config['qty_increments']); $this->setDataArrayValue('enable_qty_increments', $config['enable_qty_increments']); $this->setDataArrayValue('use_config_qty_increments', $config['use_config_qty_increments']); + $this->setDataArrayValue('is_qty_decimal', $config['is_qty_decimal']); if ($config['use_config_qty_increments']) { $this->stockConfiguration->expects($this->once()) ->method('getQtyIncrements') @@ -415,7 +416,8 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => 1, 'enable_qty_increments' => true, - 'use_config_qty_increments' => true + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false ], 1 ], @@ -423,7 +425,8 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => -2, 'enable_qty_increments' => true, - 'use_config_qty_increments' => true + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false ], false ], @@ -431,10 +434,20 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => 3, 'enable_qty_increments' => true, - 'use_config_qty_increments' => false + 'use_config_qty_increments' => false, + 'is_qty_decimal' => false ], 3 ], + [ + [ + 'qty_increments' => 0.5, + 'enable_qty_increments' => true, + 'use_config_qty_increments' => false, + 'is_qty_decimal' => true + ], + 0.5 + ], ]; } diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php index 293874bb32b9f..6b1770ff7d403 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php @@ -276,7 +276,7 @@ public function testSave() ->method('verifyStock') ->with($this->stockItemMock) ->willReturn(false); - $this->stockItemMock->expects($this->once())->method('getManageStock')->willReturn(true); + $this->stockItemMock->expects($this->exactly(2))->method('getManageStock')->willReturn(true); $this->stockItemMock->expects($this->once())->method('setIsInStock')->with(false)->willReturnSelf(); $this->stockItemMock->expects($this->once()) ->method('setStockStatusChangedAutomaticallyFlag') diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php new file mode 100644 index 0000000000000..8d3f310101d95 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php @@ -0,0 +1,290 @@ +stockResourceMock = $this->getMockBuilder(ResourceStock::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockRegistryProviderMock = $this->getMockBuilder(StockRegistryProviderInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockStateMock = $this->getMockBuilder(StockState::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockConfigurationMock = $this->getMockBuilder(StockConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->productRepositoryMock = $this->getMockBuilder(ProductRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->qtyCounterMock = $this->getMockBuilder(QtyCounterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockRegistryStorageMock = $this->getMockBuilder(StockRegistryStorage::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockItemInterfaceMock = $this->getMockBuilder(StockItemInterface::class) + ->setMethods(['hasAdminArea','getWebsiteId']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockManagement = $this->getMockBuilder(StockManagement::class) + ->setMethods(['getResource', 'canSubtractQty']) + ->setConstructorArgs( + [ + 'stockResource' => $this->stockResourceMock, + 'stockRegistryProvider' => $this->stockRegistryProviderMock, + 'stockState' => $this->stockStateMock, + 'stockConfiguration' => $this->stockConfigurationMock, + 'productRepository' => $this->productRepositoryMock, + 'qtyCounter' => $this->qtyCounterMock, + 'stockRegistryStorage' => $this->stockRegistryStorageMock, + ] + )->getMock(); + + $this->stockConfigurationMock + ->expects($this->once()) + ->method('getDefaultScopeId') + ->willReturn($this->websiteId); + $this->stockManagement + ->expects($this->any()) + ->method('getResource') + ->willReturn($this->stockResourceMock); + $this->stockRegistryProviderMock + ->expects($this->any()) + ->method('getStockItem') + ->willReturn($this->stockItemInterfaceMock); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('hasAdminArea') + ->willReturn(false); + } + + /** + * @dataProvider productsWithCorrectQtyDataProvider + * + * @param array $items + * @param array $lockedItems + * @param bool $canSubtract + * @param bool $isQty + * @param bool $verifyStock + * + * @return void + */ + public function testRegisterProductsSale( + array $items, + array $lockedItems, + $canSubtract, + $isQty, + $verifyStock = true + ) { + $this->stockResourceMock + ->expects($this->once()) + ->method('beginTransaction'); + $this->stockResourceMock + ->expects($this->once()) + ->method('lockProductsStock') + ->willReturn([$lockedItems]); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getItemId') + ->willReturn($lockedItems['product_id']); + $this->stockManagement + ->expects($this->any()) + ->method('canSubtractQty') + ->willReturn($canSubtract); + $this->stockConfigurationMock + ->expects($this->any()) + ->method('isQty') + ->willReturn($isQty); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($this->websiteId); + $this->stockStateMock + ->expects($this->any()) + ->method('checkQty') + ->willReturn(true); + $this->stockStateMock + ->expects($this->any()) + ->method('verifyStock') + ->willReturn($verifyStock); + $this->stockStateMock + ->expects($this->any()) + ->method('verifyNotification') + ->willReturn(false); + $this->stockResourceMock + ->expects($this->once()) + ->method('commit'); + + $this->stockManagement->registerProductsSale($items, $this->websiteId); + } + + /** + * @dataProvider productsWithIncorrectQtyDataProvider + * + * @param array $items + * @param array $lockedItems + * @expectedException \Magento\Framework\Exception\LocalizedException + * @expectedExceptionMessage Not all of your products are available in the requested quantity. + * + * @return void + */ + public function testRegisterProductsSaleException(array $items, array $lockedItems) + { + $this->stockResourceMock + ->expects($this->once()) + ->method('beginTransaction'); + $this->stockResourceMock + ->expects($this->once()) + ->method('lockProductsStock') + ->willReturn([$lockedItems]); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getItemId') + ->willReturn($lockedItems['product_id']); + $this->stockManagement + ->expects($this->any()) + ->method('canSubtractQty') + ->willReturn(true); + $this->stockConfigurationMock + ->expects($this->any()) + ->method('isQty') + ->willReturn(true); + $this->stockStateMock + ->expects($this->any()) + ->method('checkQty') + ->willReturn(false); + $this->stockResourceMock + ->expects($this->once()) + ->method('commit'); + + $this->stockManagement->registerProductsSale($items, $this->websiteId); + } + + /** + * @return array + */ + public function productsWithCorrectQtyDataProvider() + { + return [ + [ + [1 => 3], + [ + 'product_id' => 1, + 'qty' => 10, + 'type_id' => 'simple', + ], + false, + false, + ], + [ + [2 => 4], + [ + 'product_id' => 2, + 'qty' => 10, + 'type_id' => 'simple', + ], + true, + true, + ], + [ + [3 => 5], + [ + 'product_id' => 3, + 'qty' => 10, + 'type_id' => 'simple', + ], + true, + true, + false, + ], + ]; + } + + /** + * @return array + */ + public function productsWithIncorrectQtyDataProvider() + { + return [ + [ + [2 => 4], + [ + 'product_id' => 2, + 'qty' => 2, + 'type_id' => 'simple', + ], + ], + ]; + } +} diff --git a/app/code/Magento/CatalogInventory/composer.json b/app/code/Magento/CatalogInventory/composer.json index 71172e26698fe..949ab3c4e347b 100644 --- a/app/code/Magento/CatalogInventory/composer.json +++ b/app/code/Magento/CatalogInventory/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-inventory", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-config": "101.0.*", "magento/module-store": "100.2.*", "magento/module-catalog": "102.0.*", @@ -10,10 +10,11 @@ "magento/module-eav": "101.0.*", "magento/module-quote": "101.0.*", "magento/framework": "101.0.*", - "magento/module-ui": "101.0.*" + "magento/module-ui": "101.0.*", + "magento/module-sales": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogInventory/etc/events.xml b/app/code/Magento/CatalogInventory/etc/events.xml index 0a9f3c2d40dca..3197501e9b70b 100644 --- a/app/code/Magento/CatalogInventory/etc/events.xml +++ b/app/code/Magento/CatalogInventory/etc/events.xml @@ -27,9 +27,6 @@ - - - diff --git a/app/code/Magento/CatalogInventory/i18n/en_US.csv b/app/code/Magento/CatalogInventory/i18n/en_US.csv index 93406163cbe1b..19b73f847b46d 100644 --- a/app/code/Magento/CatalogInventory/i18n/en_US.csv +++ b/app/code/Magento/CatalogInventory/i18n/en_US.csv @@ -55,11 +55,7 @@ Inventory,Inventory "Only X left Threshold","Only X left Threshold" "Display Products Availability in Stock on Storefront","Display Products Availability in Stock on Storefront" "Product Stock Options","Product Stock Options" -" - Please note that these settings apply to individual items in the cart, not to the entire cart. - "," - Please note that these settings apply to individual items in the cart, not to the entire cart. - " +"Please note that these settings apply to individual items in the cart, not to the entire cart.","Please note that these settings apply to individual items in the cart, not to the entire cart." "Manage Stock","Manage Stock" Backorders,Backorders "Maximum Qty Allowed in Shopping Cart","Maximum Qty Allowed in Shopping Cart" diff --git a/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js b/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js index 75d684137a28b..23a33f51af6d4 100644 --- a/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js +++ b/app/code/Magento/CatalogInventory/view/adminhtml/web/js/components/qty-validator-changer.js @@ -20,6 +20,7 @@ define([ var isDigits = value !== 1; this.validation['validate-integer'] = isDigits; + this.validation['validate-digits'] = isDigits; this.validation['less-than-equals-to'] = isDigits ? 99999999 : 99999999.9999; this.validate(); } diff --git a/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php b/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php index 333ee845798ec..306d3b9a347b4 100644 --- a/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php +++ b/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php @@ -207,7 +207,7 @@ protected function _prepareColumns() public function getGridUrl() { return $this->getUrl( - 'catalog_rule/*/chooser', + '*/*/chooser', ['_current' => true, 'current_grid_id' => $this->getId(), 'collapse' => null] ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php index f2dd8968a903d..731cbe4531f42 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php @@ -12,6 +12,7 @@ use Magento\Framework\App\ObjectManager; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\CatalogRule\Model\Indexer\IndexBuilder\ProductLoader; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; /** * @api @@ -132,9 +133,9 @@ class IndexBuilder private $pricesPersistor; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var TableSwapper */ - private $activeTableSwitcher; + private $tableSwapper; /** * @var ProductLoader @@ -160,7 +161,9 @@ class IndexBuilder * @param RuleProductPricesPersistor|null $pricesPersistor * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|null $activeTableSwitcher * @param ProductLoader|null $productLoader + * @param TableSwapper|null $tableSwapper * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( RuleCollectionFactory $ruleCollectionFactory, @@ -180,7 +183,8 @@ public function __construct( ReindexRuleProductPrice $reindexRuleProductPrice = null, RuleProductPricesPersistor $pricesPersistor = null, \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher = null, - ProductLoader $productLoader = null + ProductLoader $productLoader = null, + TableSwapper $tableSwapper = null ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -212,12 +216,11 @@ public function __construct( $this->pricesPersistor = $pricesPersistor ?? ObjectManager::getInstance()->get( RuleProductPricesPersistor::class ); - $this->activeTableSwitcher = $activeTableSwitcher ?? ObjectManager::getInstance()->get( - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class - ); $this->productLoader = $productLoader ?? ObjectManager::getInstance()->get( ProductLoader::class ); + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -296,13 +299,6 @@ public function reindexFull() */ protected function doReindexFull() { - $this->connection->truncateTable( - $this->getTable($this->activeTableSwitcher->getAdditionalTableName('catalogrule_product')) - ); - $this->connection->truncateTable( - $this->getTable($this->activeTableSwitcher->getAdditionalTableName('catalogrule_product_price')) - ); - foreach ($this->getAllRules() as $rule) { $this->reindexRuleProduct->execute($rule, $this->batchCount, true); } @@ -310,8 +306,7 @@ protected function doReindexFull() $this->reindexRuleProductPrice->execute($this->batchCount, null, true); $this->reindexRuleGroupWebsite->execute(true); - $this->activeTableSwitcher->switchTable( - $this->connection, + $this->tableSwapper->swapIndexTables( [ $this->getTable('catalogrule_product'), $this->getTable('catalogrule_product_price'), diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php new file mode 100644 index 0000000000000..514c737598793 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php @@ -0,0 +1,126 @@ +resourceConnection = $resource; + } + + /** + * Create temporary table based on given table to use instead of original. + * + * @param string $originalTableName + * + * @return string Created table name. + * @throws \Throwable + */ + private function createTemporaryTable(string $originalTableName): string + { + $temporaryTableName = $this->resourceConnection->getTableName( + $originalTableName . '__temp' . $this->generateRandomSuffix() + ); + + $this->resourceConnection->getConnection()->query( + sprintf( + 'create table %s like %s', + $temporaryTableName, + $this->resourceConnection->getTableName($originalTableName) + ) + ); + + return $temporaryTableName; + } + + /** + * Random suffix for temporary tables not to conflict with each other. + * + * @return string + */ + private function generateRandomSuffix(): string + { + return bin2hex(random_bytes(4)); + } + + /** + * @inheritDoc + */ + public function getWorkingTableName(string $originalTable): string + { + $originalTable = $this->resourceConnection->getTableName($originalTable); + if (!array_key_exists($originalTable, $this->temporaryTables)) { + $this->temporaryTables[$originalTable] + = $this->createTemporaryTable($originalTable); + } + + return $this->temporaryTables[$originalTable]; + } + + /** + * @inheritDoc + */ + public function swapIndexTables(array $originalTablesNames) + { + $toRename = []; + /** @var string[] $toDrop */ + $toDrop = []; + /** @var string[] $temporaryTablesRenamed */ + $temporaryTablesRenamed = []; + //Renaming temporary tables to original tables' names, dropping old + //tables. + foreach ($originalTablesNames as $tableName) { + $tableName = $this->resourceConnection->getTableName($tableName); + $temporaryOriginalName = $this->resourceConnection->getTableName( + $tableName . $this->generateRandomSuffix() + ); + $temporaryTableName = $this->getWorkingTableName($tableName); + $toRename[] = [ + 'oldName' => $tableName, + 'newName' => $temporaryOriginalName + ]; + $toRename[] = [ + 'oldName' => $temporaryTableName, + 'newName' => $tableName + ]; + $toDrop[] = $temporaryOriginalName; + $temporaryTablesRenamed[] = $tableName; + } + + //Swapping tables. + $this->resourceConnection->getConnection()->renameTablesBatch($toRename); + //Cleaning up. + foreach ($temporaryTablesRenamed as $tableName) { + unset($this->temporaryTables[$tableName]); + } + //Removing old ones. + foreach ($toDrop as $tableName) { + $this->resourceConnection->getConnection()->dropTable($tableName); + } + } +} diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php new file mode 100644 index 0000000000000..dcb2bf4f96659 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php @@ -0,0 +1,32 @@ +priceResourceModel = $priceResourceModel; + } + + /** + * @inheritdoc + */ + public function modifyPrice(IndexTableStructure $priceTable, array $entityIds = []) + { + $connection = $this->priceResourceModel->getConnection(); + $select = $connection->select(); + + $select->join( + ['cpiw' => $this->priceResourceModel->getTable('catalog_product_index_website')], + 'cpiw.website_id = i.' . $priceTable->getWebsiteField(), + [] + ); + $select->join( + ['cpp' => $this->priceResourceModel->getMainTable()], + 'cpp.product_id = i.' . $priceTable->getEntityField() + . ' AND cpp.customer_group_id = i.' . $priceTable->getCustomerGroupField() + . ' AND cpp.website_id = i.' . $priceTable->getWebsiteField() + . ' AND cpp.rule_date = cpiw.website_date', + [] + ); + if ($entityIds) { + $select->where('i.entity_id IN (?)', $entityIds); + } + + $finalPrice = $priceTable->getFinalPriceField(); + $finalPriceExpr = $select->getConnection()->getLeastSql([ + $priceTable->getFinalPriceField(), + $select->getConnection()->getIfNullSql('cpp.rule_price', 'i.' . $finalPrice), + ]); + $minPrice = $priceTable->getMinPriceField(); + $minPriceExpr = $select->getConnection()->getLeastSql([ + $priceTable->getMinPriceField(), + $select->getConnection()->getIfNullSql('cpp.rule_price', 'i.' . $minPrice), + ]); + $select->columns([ + $finalPrice => $finalPriceExpr, + $minPrice => $minPriceExpr, + ]); + + $query = $connection->updateFromSelect($select, ['i' => $priceTable->getTableName()]); + $connection->query($query); + } +} diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php index cc5d07b18e0fa..249ed67ef2349 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleGroupWebsite.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Framework\App\ObjectManager; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; + /** * Reindex information about rule relations with customer groups and websites. */ @@ -27,23 +31,28 @@ class ReindexRuleGroupWebsite private $catalogRuleGroupWebsiteColumnsList = ['rule_id', 'customer_group_id', 'website_id']; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var TableSwapper */ - private $activeTableSwitcher; + private $tableSwapper; /** * @param \Magento\Framework\Stdlib\DateTime\DateTime $dateTime * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( \Magento\Framework\Stdlib\DateTime\DateTime $dateTime, \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->dateTime = $dateTime; $this->resource = $resource; - $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -61,10 +70,10 @@ public function execute($useAdditionalTable = false) $ruleProductTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_group_website') + $this->tableSwapper->getWorkingTableName('catalogrule_group_website') ); $ruleProductTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php index 534061d593123..28bbaf3c80e54 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Reindex rule relations with products. */ @@ -17,20 +21,25 @@ class ReindexRuleProduct private $resource; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var TableSwapper */ - private $activeTableSwitcher; + private $tableSwapper; /** * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->resource = $resource; - $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -65,7 +74,7 @@ public function execute( $indexTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php index 853be1888b5b9..537741024c5f9 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Persist product prices to index table. */ @@ -22,23 +26,28 @@ class RuleProductPricesPersistor private $dateFormat; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var TableSwapper */ - private $activeTableSwitcher; + private $tableSwapper; /** * @param \Magento\Framework\Stdlib\DateTime $dateFormat * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( \Magento\Framework\Stdlib\DateTime $dateFormat, \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->dateFormat = $dateFormat; $this->resource = $resource; - $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -59,7 +68,7 @@ public function execute(array $priceData, $useAdditionalTable = false) $indexTable = $this->resource->getTableName('catalogrule_product_price'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product_price') + $this->tableSwapper->getWorkingTableName('catalogrule_product_price') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php index 25d164aeee5c3..f7e3f8c3654e6 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Build select for rule relation with product. */ @@ -32,29 +36,34 @@ class RuleProductsSelectBuilder private $metadataPool; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var TableSwapper */ - private $activeTableSwitcher; + private $tableSwapper; /** * @param \Magento\Framework\App\ResourceConnection $resource * @param \Magento\Eav\Model\Config $eavConfig * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, \Magento\Eav\Model\Config $eavConfig, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Framework\EntityManager\MetadataPool $metadataPool, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->eavConfig = $eavConfig; $this->storeManager = $storeManager; $this->metadataPool = $metadataPool; $this->resource = $resource; - $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -74,7 +83,7 @@ public function build( $indexTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php new file mode 100644 index 0000000000000..13809a381ecb9 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php @@ -0,0 +1,78 @@ +conditionsToSearchCriteriaMapper = $conditionsToSearchCriteriaMapper; + $this->searchCriteriaProcessor = $searchCriteriaProcessor; + $this->mappableConditionsProcessor = $mappableConditionsProcessor; + } + + /** + * Transforms catalog rule conditions to search criteria + * and applies them on product collection + * + * @param Combine $conditions + * @param ProductCollection $productCollection + * @return ProductCollection + * @throws InputException + */ + public function applyConditionsToCollection( + Combine $conditions, + ProductCollection $productCollection + ): ProductCollection { + // rebuild conditions to have only those that we know how to map them to product collection + $mappableConditions = $this->mappableConditionsProcessor->rebuildConditionsTree($conditions); + + // transform conditions to search criteria + $searchCriteria = $this->conditionsToSearchCriteriaMapper->mapConditionsToSearchCriteria($mappableConditions); + + $mappedProductCollection = clone $productCollection; + + // apply search criteria to new version of product collection + $this->searchCriteriaProcessor->process($searchCriteria, $mappedProductCollection); + + return $mappedProductCollection; + } +} diff --git a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php index 00808f38c9132..3f396cacd37da 100644 --- a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php +++ b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php @@ -105,6 +105,7 @@ public function build($productId) ->where('t.customer_group_id = ?', $this->customerSession->getCustomerGroupId()) ->where('t.rule_date = ?', $currentDate) ->order('t.rule_price ' . Select::SQL_ASC) + ->order(BaseSelectProcessorInterface::PRODUCT_TABLE_ALIAS . '.' . $linkField . ' ' . Select::SQL_ASC) ->limit(1); $priceSelect = $this->baseSelectProcessor->process($priceSelect); diff --git a/app/code/Magento/CatalogRule/Model/Rule.php b/app/code/Magento/CatalogRule/Model/Rule.php index 715b7a2f3903b..ae016d3170255 100644 --- a/app/code/Magento/CatalogRule/Model/Rule.php +++ b/app/code/Magento/CatalogRule/Model/Rule.php @@ -6,10 +6,34 @@ namespace Magento\CatalogRule\Model; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ProductFactory; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\CatalogRule\Api\Data\RuleExtensionInterface; use Magento\CatalogRule\Api\Data\RuleInterface; +use Magento\CatalogRule\Helper\Data; +use Magento\CatalogRule\Model\Data\Condition\Converter; +use Magento\CatalogRule\Model\Indexer\Rule\RuleProductProcessor; +use Magento\CatalogRule\Model\ResourceModel\Rule as RuleResourceModel; +use Magento\CatalogRule\Model\Rule\Action\CollectionFactory as RuleCollectionFactory; +use Magento\CatalogRule\Model\Rule\Condition\CombineFactory; +use Magento\Customer\Model\Session; use Magento\Framework\Api\AttributeValueFactory; use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DataObject\IdentityInterface; +use Magento\CatalogRule\Model\ResourceModel\Product\ConditionsToCollectionApplier; +use Magento\Framework\App\Cache\TypeListInterface; +use Magento\Framework\DataObject; +use Magento\Framework\Data\Collection\AbstractDb; +use Magento\Framework\Data\FormFactory; +use Magento\Framework\Model\Context; +use Magento\Framework\Model\ResourceModel\AbstractResource; +use Magento\Framework\Model\ResourceModel\Iterator; +use Magento\Framework\Registry; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Stdlib\DateTime; +use Magento\Framework\Stdlib\DateTime\TimezoneInterface; +use Magento\Store\Model\StoreManagerInterface; /** * Catalog Rule data model @@ -136,6 +160,21 @@ class Rule extends \Magento\Rule\Model\AbstractModel implements RuleInterface, I */ protected $ruleConditionConverter; + /** + * @var ConditionsToCollectionApplier + */ + protected $conditionsToCollectionApplier; + + /** + * @var array + */ + private $websitesMap; + + /** + * @var RuleResourceModel + */ + private $ruleResourceModel; + /** * Rule constructor * @@ -161,32 +200,35 @@ class Rule extends \Magento\Rule\Model\AbstractModel implements RuleInterface, I * @param ExtensionAttributesFactory|null $extensionFactory * @param AttributeValueFactory|null $customAttributeFactory * @param \Magento\Framework\Serialize\Serializer\Json $serializer - * + * @param ConditionsToCollectionApplier $conditionsToCollectionApplier + * @param RuleResourceModel|null $ruleResourceModel * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( - \Magento\Framework\Model\Context $context, - \Magento\Framework\Registry $registry, - \Magento\Framework\Data\FormFactory $formFactory, - \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, - \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory, - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\CatalogRule\Model\Rule\Condition\CombineFactory $combineFactory, - \Magento\CatalogRule\Model\Rule\Action\CollectionFactory $actionCollectionFactory, - \Magento\Catalog\Model\ProductFactory $productFactory, - \Magento\Framework\Model\ResourceModel\Iterator $resourceIterator, - \Magento\Customer\Model\Session $customerSession, - \Magento\CatalogRule\Helper\Data $catalogRuleData, - \Magento\Framework\App\Cache\TypeListInterface $cacheTypesList, - \Magento\Framework\Stdlib\DateTime $dateTime, - \Magento\CatalogRule\Model\Indexer\Rule\RuleProductProcessor $ruleProductProcessor, - \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, - \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, + Context $context, + Registry $registry, + FormFactory $formFactory, + TimezoneInterface $localeDate, + CollectionFactory $productCollectionFactory, + StoreManagerInterface $storeManager, + CombineFactory $combineFactory, + RuleCollectionFactory $actionCollectionFactory, + ProductFactory $productFactory, + Iterator $resourceIterator, + Session $customerSession, + Data $catalogRuleData, + TypeListInterface $cacheTypesList, + DateTime $dateTime, + RuleProductProcessor $ruleProductProcessor, + AbstractResource $resource = null, + AbstractDb $resourceCollection = null, array $relatedCacheTypes = [], array $data = [], ExtensionAttributesFactory $extensionFactory = null, AttributeValueFactory $customAttributeFactory = null, - \Magento\Framework\Serialize\Serializer\Json $serializer = null + Json $serializer = null, + ConditionsToCollectionApplier $conditionsToCollectionApplier = null, + RuleResourceModel $ruleResourceModel = null ) { $this->_productCollectionFactory = $productCollectionFactory; $this->_storeManager = $storeManager; @@ -200,6 +242,9 @@ public function __construct( $this->_relatedCacheTypes = $relatedCacheTypes; $this->dateTime = $dateTime; $this->_ruleProductProcessor = $ruleProductProcessor; + $this->ruleResourceModel = $ruleResourceModel ?: ObjectManager::getInstance()->get(RuleResourceModel::class); + $this->conditionsToCollectionApplier = $conditionsToCollectionApplier + ?? ObjectManager::getInstance()->get(ConditionsToCollectionApplier::class); parent::__construct( $context, @@ -223,7 +268,7 @@ public function __construct( protected function _construct() { parent::_construct(); - $this->_init(\Magento\CatalogRule\Model\ResourceModel\Rule::class); + $this->_init(RuleResourceModel::class); $this->setIdFieldName('rule_id'); } @@ -255,7 +300,7 @@ public function getActionsInstance() public function getCustomerGroupIds() { if (!$this->hasCustomerGroupIds()) { - $customerGroupIds = $this->_getResource()->getCustomerGroupIds($this->getId()); + $customerGroupIds = $this->ruleResourceModel->getCustomerGroupIds($this->getId()); $this->setData('customer_group_ids', (array)$customerGroupIds); } return $this->_getData('customer_group_ids'); @@ -269,7 +314,7 @@ public function getCustomerGroupIds() public function getNow() { if (!$this->_now) { - return (new \DateTime())->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT); + return (new \DateTime())->format(DateTime::DATETIME_PHP_FORMAT); } return $this->_now; } @@ -306,6 +351,11 @@ public function getMatchingProductIds() } $this->getConditions()->collectValidatedAttributes($productCollection); + if ($this->canPreMapProducts()) { + $productCollection = $this->conditionsToCollectionApplier + ->applyConditionsToCollection($this->getConditions(), $productCollection); + } + $this->_resourceIterator->walk( $productCollection->getSelect(), [[$this, 'callbackValidateProduct']], @@ -320,6 +370,21 @@ public function getMatchingProductIds() return $this->_productIds; } + /** + * @return bool + */ + private function canPreMapProducts() + { + $conditions = $this->getConditions(); + + // No need to map products if there is no conditions in rule + if (!$conditions || !$conditions->getConditions()) { + return false; + } + + return true; + } + /** * Callback function for product matching * @@ -348,22 +413,25 @@ public function callbackValidateProduct($args) */ protected function _getWebsitesMap() { - $map = []; - $websites = $this->_storeManager->getWebsites(); - foreach ($websites as $website) { - // Continue if website has no store to be able to create catalog rule for website without store - if ($website->getDefaultStore() === null) { - continue; + if ($this->websitesMap === null) { + $this->websitesMap = []; + $websites = $this->_storeManager->getWebsites(); + foreach ($websites as $website) { + // Continue if website has no store to be able to create catalog rule for website without store + if ($website->getDefaultStore() === null) { + continue; + } + $this->websitesMap[$website->getId()] = $website->getDefaultStore()->getId(); } - $map[$website->getId()] = $website->getDefaultStore()->getId(); } - return $map; + + return $this->websitesMap; } /** * {@inheritdoc} */ - public function validateData(\Magento\Framework\DataObject $dataObject) + public function validateData(DataObject $dataObject) { $result = parent::validateData($dataObject); if ($result === true) { @@ -470,7 +538,7 @@ public function calcProductPriceRule(Product $product, $price) */ protected function _getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId) { - return $this->_getResource()->getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId); + return $this->ruleResourceModel->getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId); } /** @@ -516,10 +584,10 @@ protected function _invalidateCache() */ public function afterSave() { - if ($this->isObjectNew()) { - $this->getMatchingProductIds(); - if (!empty($this->_productIds) && is_array($this->_productIds)) { - $this->_getResource()->addCommitCallback([$this, 'reindex']); + if ($this->isObjectNew() && !$this->_ruleProductProcessor->isIndexerScheduled()) { + $productIds = $this->getMatchingProductIds(); + if (!empty($productIds) && is_array($productIds)) { + $this->ruleResourceModel->addCommitCallback([$this, 'reindex']); } } else { $this->_ruleProductProcessor->getIndexer()->invalidate(); @@ -534,7 +602,10 @@ public function afterSave() */ public function reindex() { - $this->_ruleProductProcessor->reindexList($this->_productIds); + $productIds = $this->_productIds ? array_keys(array_filter($this->_productIds, function (array $data) { + return array_filter($data); + })) : []; + $this->_ruleProductProcessor->reindexList($productIds); } /** @@ -765,7 +836,7 @@ public function getToDate() /** * {@inheritdoc} * - * @return \Magento\CatalogRule\Api\Data\RuleExtensionInterface|null + * @return RuleExtensionInterface|null */ public function getExtensionAttributes() { @@ -775,10 +846,10 @@ public function getExtensionAttributes() /** * {@inheritdoc} * - * @param \Magento\CatalogRule\Api\Data\RuleExtensionInterface $extensionAttributes + * @param RuleExtensionInterface $extensionAttributes * @return $this */ - public function setExtensionAttributes(\Magento\CatalogRule\Api\Data\RuleExtensionInterface $extensionAttributes) + public function setExtensionAttributes(RuleExtensionInterface $extensionAttributes) { return $this->_setExtensionAttributes($extensionAttributes); } @@ -790,8 +861,8 @@ public function setExtensionAttributes(\Magento\CatalogRule\Api\Data\RuleExtensi private function getRuleConditionConverter() { if (null === $this->ruleConditionConverter) { - $this->ruleConditionConverter = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\CatalogRule\Model\Data\Condition\Converter::class); + $this->ruleConditionConverter = ObjectManager::getInstance() + ->get(Converter::class); } return $this->ruleConditionConverter; } diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php new file mode 100644 index 0000000000000..7dc832a28ac0e --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php @@ -0,0 +1,308 @@ +searchCriteriaBuilderFactory = $searchCriteriaBuilderFactory; + $this->combinedFilterGroupFactory = $combinedFilterGroupFactory; + $this->filterFactory = $filterFactory; + } + + /** + * Maps catalog price rule conditions to search criteria + * + * @param CombinedCondition $conditions + * @return SearchCriteria + * @throws InputException + */ + public function mapConditionsToSearchCriteria(CombinedCondition $conditions): SearchCriteria + { + $filterGroup = $this->mapCombinedConditionToFilterGroup($conditions); + + $searchCriteriaBuilder = $this->searchCriteriaBuilderFactory->create(); + + if ($filterGroup !== null) { + $searchCriteriaBuilder->setFilterGroups([$filterGroup]); + } + + return $searchCriteriaBuilder->create(); + } + + /** + * @param ConditionInterface $condition + * @return null|\Magento\Framework\Api\CombinedFilterGroup|\Magento\Framework\Api\Filter + * @throws InputException + */ + private function mapConditionToFilterGroup(ConditionInterface $condition) + { + if ($condition->getType() === CombinedCondition::class) { + return $this->mapCombinedConditionToFilterGroup($condition); + } elseif ($condition->getType() === SimpleCondition::class) { + return $this->mapSimpleConditionToFilterGroup($condition); + } + + throw new InputException( + __('Undefined condition type "%1" passed in.', $condition->getType()) + ); + } + + /** + * @param Combine $combinedCondition + * @return null|\Magento\Framework\Api\CombinedFilterGroup + * @throws InputException + */ + private function mapCombinedConditionToFilterGroup(CombinedCondition $combinedCondition) + { + $filters = []; + + foreach ($combinedCondition->getConditions() as $condition) { + $filter = $this->mapConditionToFilterGroup($condition); + + if ($filter === null) { + continue; + } + + // This required to solve cases when condition is configured like: + // "If ALL/ANY of these conditions are FALSE" - we need to reverse SQL operator for this "FALSE" + if ((bool)$combinedCondition->getValue() === false) { + $this->reverseSqlOperatorInFilter($filter); + } + + $filters[] = $filter; + } + + if (count($filters) === 0) { + return null; + } + + return $this->createCombinedFilterGroup($filters, $combinedCondition->getAggregator()); + } + + /** + * @param ConditionInterface $productCondition + * @return FilterGroup|Filter + * @throws InputException + */ + private function mapSimpleConditionToFilterGroup(ConditionInterface $productCondition) + { + if (is_array($productCondition->getValue())) { + return $this->processSimpleConditionWithArrayValue($productCondition); + } + + return $this->createFilter( + $productCondition->getAttribute(), + (string) $productCondition->getValue(), + $productCondition->getOperator() + ); + } + + /** + * @param ConditionInterface $productCondition + * @return FilterGroup + * @throws InputException + */ + private function processSimpleConditionWithArrayValue(ConditionInterface $productCondition): FilterGroup + { + $filters = []; + + foreach ($productCondition->getValue() as $subValue) { + $filters[] = $this->createFilter( + $productCondition->getAttribute(), + (string) $subValue, + $productCondition->getOperator() + ); + } + + $combinationMode = $this->getGlueForArrayValues($productCondition->getOperator()); + + return $this->createCombinedFilterGroup($filters, $combinationMode); + } + + /** + * @param string $operator + * @return string + */ + private function getGlueForArrayValues(string $operator): string + { + if (in_array($operator, ['!=', '!{}', '!()'], true)) { + return 'all'; + } + + return 'any'; + } + + /** + * Reverse sql conditions to their corresponding negative analog + * + * @param Filter $filter + * @return void + * @throws InputException + */ + private function reverseSqlOperatorInFilter(Filter $filter) + { + $operatorsMap = [ + 'eq' => 'neq', + 'neq' => 'eq', + 'gteq' => 'lt', + 'lteq' => 'gt', + 'gt' => 'lteq', + 'lt' => 'gteq', + 'like' => 'nlike', + 'nlike' => 'like', + 'in' => 'nin', + 'nin' => 'in', + ]; + + if (!array_key_exists($filter->getConditionType(), $operatorsMap)) { + throw new InputException( + __( + 'Undefined SQL operator "%1" passed in. Valid operators are: %2', + $filter->getConditionType(), + implode(',', array_keys($operatorsMap)) + ) + ); + } + + $filter->setConditionType( + $operatorsMap[$filter->getConditionType()] + ); + } + + /** + * @param array $filters + * @param string $combinationMode + * @return FilterGroup + * @throws InputException + */ + private function createCombinedFilterGroup(array $filters, string $combinationMode): FilterGroup + { + return $this->combinedFilterGroupFactory->create([ + 'data' => [ + FilterGroup::FILTERS => $filters, + FilterGroup::COMBINATION_MODE => $this->mapRuleAggregatorToSQLAggregator($combinationMode) + ] + ]); + } + + /** + * @param string $field + * @param string $value + * @param string $conditionType + * @return Filter + * @throws InputException + */ + private function createFilter(string $field, string $value, string $conditionType): Filter + { + return $this->filterFactory->create([ + 'data' => [ + Filter::KEY_FIELD => $field, + Filter::KEY_VALUE => $value, + Filter::KEY_CONDITION_TYPE => $this->mapRuleOperatorToSQLCondition($conditionType) + ] + ]); + } + + /** + * Maps catalog price rule operators to their corresponding operators in SQL + * + * @param string $ruleOperator + * @return string + * @throws InputException + */ + private function mapRuleOperatorToSQLCondition(string $ruleOperator): string + { + $operatorsMap = [ + '==' => 'eq', // is + '!=' => 'neq', // is not + '>=' => 'gteq', // equals or greater than + '<=' => 'lteq', // equals or less than + '>' => 'gt', // greater than + '<' => 'lt', // less than + '{}' => 'like', // contains + '!{}' => 'nlike', // does not contains + '()' => 'in', // is one of + '!()' => 'nin', // is not one of + ]; + + if (!array_key_exists($ruleOperator, $operatorsMap)) { + throw new InputException( + __( + 'Undefined rule operator "%1" passed in. Valid operators are: %2', + $ruleOperator, + implode(',', array_keys($operatorsMap)) + ) + ); + } + + return $operatorsMap[$ruleOperator]; + } + + /** + * Map rule combine aggregations to corresponding SQL operator + * + * @param string $ruleAggregator + * @return string + * @throws InputException + */ + private function mapRuleAggregatorToSQLAggregator(string $ruleAggregator): string + { + $operatorsMap = [ + 'all' => 'AND', + 'any' => 'OR', + ]; + + if (!array_key_exists(strtolower($ruleAggregator), $operatorsMap)) { + throw new InputException( + __( + 'Undefined rule aggregator "%1" passed in. Valid operators are: %2', + $ruleAggregator, + implode(',', array_keys($operatorsMap)) + ) + ); + } + + return $operatorsMap[$ruleAggregator]; + } +} diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php new file mode 100644 index 0000000000000..63c3f62ad0590 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php @@ -0,0 +1,139 @@ +customConditionProvider = $customConditionProvider; + $this->eavConfig = $eavConfig; + } + + /** + * @param Combine $conditions + * @return Combine + */ + public function rebuildConditionsTree(CombinedCondition $conditions): CombinedCondition + { + return $this->rebuildCombinedCondition($conditions); + } + + /** + * @param Combine $originalConditions + * @return Combine + * @throws InputException + */ + private function rebuildCombinedCondition(CombinedCondition $originalConditions): CombinedCondition + { + $validConditions = []; + $invalidConditions = []; + + foreach ($originalConditions->getConditions() as $condition) { + if ($condition->getType() === CombinedCondition::class) { + $rebuildSubCondition = $this->rebuildCombinedCondition($condition); + + if (count($rebuildSubCondition->getConditions()) > 0) { + $validConditions[] = $rebuildSubCondition; + } else { + $invalidConditions[] = $rebuildSubCondition; + } + + continue; + } + + if ($condition->getType() === SimpleCondition::class) { + if ($this->validateSimpleCondition($condition)) { + $validConditions[] = $condition; + } else { + $invalidConditions[] = $condition; + } + + continue; + } + + throw new InputException( + __('Undefined condition type "%1" passed in.', $condition->getType()) + ); + } + + // if resulted condition group has left no mappable conditions - we can remove it at all + if (count($invalidConditions) > 0 && strtolower($originalConditions->getAggregator()) === 'any') { + $validConditions = []; + } + + $rebuildCondition = clone $originalConditions; + $rebuildCondition->setConditions($validConditions); + + return $rebuildCondition; + } + + /** + * @param Product $originalConditions + * @return bool + */ + private function validateSimpleCondition(SimpleCondition $originalConditions): bool + { + return $this->canUseFieldForMapping($originalConditions->getAttribute()); + } + + /** + * Checks if condition field is mappable + * + * @param string $fieldName + * @return bool + */ + private function canUseFieldForMapping(string $fieldName): bool + { + // We can map field to search criteria if we have custom processor for it + if ($this->customConditionProvider->hasProcessorForField($fieldName)) { + return true; + } + + // Also we can map field to search criteria if it is an EAV attribute + $attribute = $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $fieldName); + + // We have this weird check for getBackendType() to verify that attribute really exists + // because due to eavConfig behavior even if pass non existing attribute code we still receive AbstractAttribute + // getAttributeId() is not sufficient too because some attributes don't have it - e.g. attribute_set_id + if ($attribute && $attribute->getBackendType() !== null) { + return true; + } + + // In any other cases we can't map field to search criteria + return false; + } +} diff --git a/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php b/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php index 0ea0fdda31958..50e3703087680 100644 --- a/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php +++ b/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php @@ -35,8 +35,8 @@ public function afterSave( \Magento\Catalog\Model\Category $result ) { /** @var \Magento\Catalog\Model\Category $result */ - $productIds = $result->getAffectedProductIds(); - if ($productIds && !$this->productRuleProcessor->isIndexerScheduled()) { + $productIds = $result->getChangedProductIds(); + if (!empty($productIds) && !$this->productRuleProcessor->isIndexerScheduled()) { $this->productRuleProcessor->reindexList($productIds); } return $result; diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php index d60a662193e54..f4a78c16a2792 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class ReindexRuleGroupWebsiteTest extends \PHPUnit\Framework\TestCase { /** @@ -24,9 +27,9 @@ class ReindexRuleGroupWebsiteTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject */ - private $activeTableSwitcherMock; + private $tableSwapperMock; protected function setUp() { @@ -36,14 +39,19 @@ protected function setUp() $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) + /** @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject $activeTableSwitcherMock */ + $activeTableSwitcherMock = + $this->getMockBuilder(ActiveTableSwitcher::class) ->disableOriginalConstructor() ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite( $this->dateTimeMock, $this->resourceMock, - $this->activeTableSwitcherMock + $activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -55,12 +63,12 @@ public function testExecute() $this->resourceMock->expects($this->at(0))->method('getConnection')->willReturn($connectionMock); $this->dateTimeMock->expects($this->once())->method('gmtTimestamp')->willReturn($timeStamp); - $this->activeTableSwitcherMock->expects($this->at(0)) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->at(0)) + ->method('getWorkingTableName') ->with('catalogrule_group_website') ->willReturn('catalogrule_group_website_replica'); - $this->activeTableSwitcherMock->expects($this->at(1)) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->at(1)) + ->method('getWorkingTableName') ->with('catalogrule_product') ->willReturn('catalogrule_product_replica'); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php index b829468396bf0..ddef2ba3134f3 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class ReindexRuleProductTest extends \PHPUnit\Framework\TestCase { /** @@ -19,22 +22,27 @@ class ReindexRuleProductTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject */ - private $activeTableSwitcherMock; + private $tableSwapperMock; protected function setUp() { $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) - ->disableOriginalConstructor() - ->getMock(); + /** @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject $activeTableSwitcherMock */ + $activeTableSwitcherMock = + $this->getMockBuilder(ActiveTableSwitcher::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct( $this->resourceMock, - $this->activeTableSwitcherMock + $activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -71,8 +79,8 @@ public function testExecute() $ruleMock->expects($this->exactly(2))->method('getWebsiteIds')->willReturn(1); $ruleMock->expects($this->once())->method('getMatchingProductIds')->willReturn($productIds); - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product') ->willReturn('catalogrule_product_replica'); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php index 3efe26971627e..0b15352159e74 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class RuleProductPricesPersistorTest extends \PHPUnit\Framework\TestCase { /** @@ -24,9 +27,9 @@ class RuleProductPricesPersistorTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject */ - private $activeTableSwitcherMock; + private $tableSwapperMock; protected function setUp() { @@ -36,14 +39,19 @@ protected function setUp() $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) - ->disableOriginalConstructor() - ->getMock(); + /** @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject $activeTableSwitcherMock */ + $activeTableSwitcherMock = + $this->getMockBuilder(ActiveTableSwitcher::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor( $this->dateTimeMock, $this->resourceMock, - $this->activeTableSwitcherMock + $activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -64,8 +72,8 @@ public function testExecute() ]; $tableName = 'catalogrule_product_price_replica'; - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product_price') ->willReturn($tableName); @@ -120,8 +128,8 @@ public function testExecuteWithException() ]; $tableName = 'catalogrule_product_price_replica'; - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product_price') ->willReturn($tableName); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php index 92b4bb353f046..3584ae466354d 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php @@ -7,6 +7,8 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Select; use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; @@ -34,11 +36,6 @@ class RuleProductsSelectBuilderTest extends \PHPUnit\Framework\TestCase */ private $resourceMock; - /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject - */ - private $activeTableSwitcherMock; - /** * @var \Magento\Eav\Model\Config|\PHPUnit_Framework_MockObject_MockObject */ @@ -49,6 +46,11 @@ class RuleProductsSelectBuilderTest extends \PHPUnit\Framework\TestCase */ private $metadataPoolMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->storeManagerMock = $this->getMockBuilder(\Magento\Store\Model\StoreManagerInterface::class) @@ -56,8 +58,9 @@ protected function setUp() $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) + /** @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject $activeTableSwitcherMock */ + $activeTableSwitcherMock = + $this->getMockBuilder(ActiveTableSwitcher::class) ->disableOriginalConstructor() ->getMock(); $this->eavConfigMock = $this->getMockBuilder(\Magento\Eav\Model\Config::class) @@ -66,13 +69,17 @@ protected function setUp() $this->metadataPoolMock = $this->getMockBuilder(\Magento\Framework\EntityManager\MetadataPool::class) ->disableOriginalConstructor() ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder( $this->resourceMock, $this->eavConfigMock, $this->storeManagerMock, $this->metadataPoolMock, - $this->activeTableSwitcherMock + $activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -92,8 +99,8 @@ public function testBuild() $connectionMock = $this->getMockBuilder(AdapterInterface::class)->disableOriginalConstructor()->getMock(); $this->resourceMock->expects($this->at(0))->method('getConnection')->willReturn($connectionMock); - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with($ruleTable) ->willReturn($rplTable); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php new file mode 100644 index 0000000000000..1643b3473ae27 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php @@ -0,0 +1,1035 @@ +eavConfigMock = $this->getMockBuilder(EavConfig::class) + ->disableOriginalConstructor() + ->setMethods(['getAttribute']) + ->getMock(); + + $this->customConditionProcessorBuilderMock = $this->getMockBuilder( + CustomConditionProviderInterface::class + )->disableOriginalConstructor() + ->setMethods(['hasProcessorForField']) + ->getMockForAbstractClass(); + + $this->objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + + $this->mappableConditionProcessor = $this->objectManagerHelper->getObject( + MappableConditionsProcessor::class, + [ + 'customConditionProvider' => $this->customConditionProcessorBuilderMock, + 'eavConfig' => $this->eavConfigMock, + ] + ); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-2 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + * ] + */ + public function testConditionV1() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-2 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * ] + * ] + * ] + */ + public function testConditionV2() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition( + [ + $simpleCondition1 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-2 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => [] + * ] + * ] + */ + public function testConditionV3() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition([], 'all'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + */ + public function testConditionV4() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'any' + ); + + $validSubCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition2 + ], + 'all' + ); + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition1, + $validSubCondition2 + ], + 'any' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + */ + public function testConditionV5() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'all' + ); + + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition2 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when all condition are mappable there must not be any changes to input + */ + public function testConditionV6() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2, + $subCondition1 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($inputCondition, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-2 => [ attribute => field-2 ] + * condition-3 => [ attribute => field-3 ] + * ] + * ] + * ] + * ] + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-4 => [ attribute => field-4 ] + * condition-5 => [ attribute => field-5 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-6 => [ attribute => field-6 ] + * condition-7 => [ attribute => field-7 ] + * ] + * ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-3 and condition-5 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-6 => [ attribute => field-6 ] + * condition-7 => [ attribute => field-7 ] + * ] + * ] + * ] + * ] + * ] + * ] + * ] + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testConditionV7() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + $field3 = 'field-3'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition2, + $simpleCondition3 + ], + 'any' + ); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $subCondition1 + ], + 'all' + ); + + $field4 = 'field-4'; + $field5 = 'field-5'; + $field6 = 'field-6'; + $field7 = 'field-7'; + + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $simpleCondition5 = $this->getMockForSimpleCondition($field5); + $simpleCondition6 = $this->getMockForSimpleCondition($field6); + $simpleCondition7 = $this->getMockForSimpleCondition($field7); + + $subCondition3 = $this->getMockForCombinedCondition( + [ + $simpleCondition4, + $simpleCondition5 + ], + 'any' + ); + $subCondition4 = $this->getMockForCombinedCondition( + [ + $simpleCondition6, + $simpleCondition7 + ], + 'any' + ); + $subCondition5 = $this->getMockForCombinedCondition( + [ + $subCondition3, + $subCondition4 + ], + 'all' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition2, + $subCondition5 + ], + 'any' + ); + + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition1 + ], + 'all' + ); + $validSubCondition4 = $this->getMockForCombinedCondition( + [ + $subCondition4 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition2, + $validSubCondition4 + ], + 'any' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, true], + [$field3, false], + [$field4, true], + [$field5, false], + [$field6, true], + [$field7, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-4 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + */ + public function testConditionV8() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * condition-5 => [ attribute => field-5 ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-4 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + */ + public function testConditionV9() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $field5 = 'field-5'; + $simpleCondition5 = $this->getMockForSimpleCondition($field5); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2, + $simpleCondition5 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, false], + [$field5, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * @expectedException \Magento\Framework\Exception\InputException + * @expectedExceptionMessage Undefined condition type "olo-lo" passed in. + */ + public function testException() + { + $simpleCondition = $this->getMockForSimpleCondition('field'); + $simpleCondition->setType('olo-lo'); + $inputCondition = $this->getMockForCombinedCondition([$simpleCondition], 'any'); + + $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + } + + protected function getMockForCombinedCondition($subConditions, $aggregator) + { + $mock = $this->getMockBuilder(CombinedCondition::class) + ->disableOriginalConstructor() + ->setMethods() + ->getMock(); + + $mock->setConditions($subConditions); + $mock->setAggregator($aggregator); + $mock->setType(CombinedCondition::class); + + return $mock; + } + + protected function getMockForSimpleCondition($attribute) + { + $mock = $this->getMockBuilder(SimpleCondition::class) + ->disableOriginalConstructor() + ->setMethods() + ->getMock(); + + $mock->setAttribute($attribute); + $mock->setType(SimpleCondition::class); + + return $mock; + } +} diff --git a/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php b/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php index 5822e01853deb..71e2093b0e325 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php @@ -32,7 +32,7 @@ protected function setUp() ); $this->subject = $this->createPartialMock( \Magento\Catalog\Model\Category::class, - ['getAffectedProductIds', '__wakeUp'] + ['getChangedProductIds', '__wakeUp'] ); $this->plugin = (new ObjectManager($this))->getObject( @@ -46,7 +46,7 @@ protected function setUp() public function testAfterSaveWithoutAffectedProductIds() { $this->subject->expects($this->any()) - ->method('getAffectedProductIds') + ->method('getChangedProductIds') ->will($this->returnValue([])); $this->productRuleProcessor->expects($this->never()) @@ -60,7 +60,7 @@ public function testAfterSave() $productIds = [1, 2, 3]; $this->subject->expects($this->any()) - ->method('getAffectedProductIds') + ->method('getChangedProductIds') ->will($this->returnValue($productIds)); $this->productRuleProcessor->expects($this->once()) diff --git a/app/code/Magento/CatalogRule/composer.json b/app/code/Magento/CatalogRule/composer.json index b2b3a80183ae6..a7f08b08e4787 100644 --- a/app/code/Magento/CatalogRule/composer.json +++ b/app/code/Magento/CatalogRule/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-rule", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-rule": "100.2.*", "magento/module-catalog": "102.0.*", @@ -17,7 +17,7 @@ "magento/module-catalog-rule-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.2", + "version": "101.0.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogRule/etc/di.xml b/app/code/Magento/CatalogRule/etc/di.xml index 4b368b1cef89a..49f312d25dd0a 100644 --- a/app/code/Magento/CatalogRule/etc/di.xml +++ b/app/code/Magento/CatalogRule/etc/di.xml @@ -126,4 +126,35 @@ + + + + + Magento\CatalogRule\Model\Indexer\ProductPriceIndexModifier + + + + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\ProductCategoryCondition + + + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\DefaultCondition + CatalogRuleCustomConditionProvider + + + + + CatalogRuleAdvancedFilterProcessor + + + + + CatalogRuleCustomConditionProvider + + diff --git a/app/code/Magento/CatalogRule/etc/indexer.xml b/app/code/Magento/CatalogRule/etc/indexer.xml index 08ed456457bfe..e648ea567631c 100644 --- a/app/code/Magento/CatalogRule/etc/indexer.xml +++ b/app/code/Magento/CatalogRule/etc/indexer.xml @@ -14,4 +14,9 @@ Catalog Product Rule Indexed product/rule association + + + + + diff --git a/app/code/Magento/CatalogRule/etc/mview.xml b/app/code/Magento/CatalogRule/etc/mview.xml index 2990c03ae0159..4b1166941bdc8 100644 --- a/app/code/Magento/CatalogRule/etc/mview.xml +++ b/app/code/Magento/CatalogRule/etc/mview.xml @@ -18,7 +18,6 @@
-
diff --git a/app/code/Magento/CatalogRuleConfigurable/composer.json b/app/code/Magento/CatalogRuleConfigurable/composer.json index 71d03c535ae5d..5ee5245e9eb78 100644 --- a/app/code/Magento/CatalogRuleConfigurable/composer.json +++ b/app/code/Magento/CatalogRuleConfigurable/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-rule-configurable", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-configurable-product": "100.2.*", "magento/framework": "101.0.*", "magento/module-catalog": "102.0.*", diff --git a/app/code/Magento/CatalogSearch/Block/Advanced/Form.php b/app/code/Magento/CatalogSearch/Block/Advanced/Form.php index 59d33ddbfd60b..68e4233e8deaf 100644 --- a/app/code/Magento/CatalogSearch/Block/Advanced/Form.php +++ b/app/code/Magento/CatalogSearch/Block/Advanced/Form.php @@ -201,16 +201,16 @@ public function getCurrency($attribute) public function getAttributeInputType($attribute) { $dataType = $attribute->getBackend()->getType(); - $imputType = $attribute->getFrontend()->getInputType(); - if ($imputType == 'select' || $imputType == 'multiselect') { + $inputType = $attribute->getFrontend()->getInputType(); + if ($inputType == 'select' || $inputType == 'multiselect') { return 'select'; } - if ($imputType == 'boolean') { + if ($inputType == 'boolean') { return 'yesno'; } - if ($imputType == 'price') { + if ($inputType == 'price') { return 'price'; } diff --git a/app/code/Magento/CatalogSearch/Block/SearchTermsLog.php b/app/code/Magento/CatalogSearch/Block/SearchTermsLog.php new file mode 100644 index 0000000000000..0be43ce6ff1fb --- /dev/null +++ b/app/code/Magento/CatalogSearch/Block/SearchTermsLog.php @@ -0,0 +1,40 @@ +response = $response; + } + + /** + * Check is current page cacheable + * + * @return bool + */ + public function isPageCacheable() + { + $pragma = $this->response->getHeader('pragma')->getFieldValue(); + return ($pragma == 'cache'); + } +} diff --git a/app/code/Magento/CatalogSearch/Controller/Result/Index.php b/app/code/Magento/CatalogSearch/Controller/Result/Index.php index f3990da3a325e..22958b64d444d 100644 --- a/app/code/Magento/CatalogSearch/Controller/Result/Index.php +++ b/app/code/Magento/CatalogSearch/Controller/Result/Index.php @@ -9,9 +9,9 @@ use Magento\Catalog\Model\Layer\Resolver; use Magento\Catalog\Model\Session; use Magento\Framework\App\Action\Context; -use Magento\Framework\App\ResourceConnection; use Magento\Store\Model\StoreManagerInterface; use Magento\Search\Model\QueryFactory; +use Magento\Search\Model\PopularSearchTerms; class Index extends \Magento\Framework\App\Action\Action { @@ -64,34 +64,88 @@ public function __construct( * Display search result * * @return void + * + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { $this->layerResolver->create(Resolver::CATALOG_LAYER_SEARCH); + /* @var $query \Magento\Search\Model\Query */ $query = $this->_queryFactory->get(); - $query->setStoreId($this->_storeManager->getStore()->getId()); + $storeId = $this->_storeManager->getStore()->getId(); + $query->setStoreId($storeId); + + $queryText = $query->getQueryText(); + + if ($queryText != '') { + $catalogSearchHelper = $this->_objectManager->get(\Magento\CatalogSearch\Helper\Data::class); - if ($query->getQueryText() != '') { - if ($this->_objectManager->get(\Magento\CatalogSearch\Helper\Data::class)->isMinQueryLength()) { - $query->setId(0)->setIsActive(1)->setIsProcessed(1); + $getAdditionalRequestParameters = $this->getRequest()->getParams(); + unset($getAdditionalRequestParameters[QueryFactory::QUERY_VAR_NAME]); + + if (empty($getAdditionalRequestParameters) && + $this->_objectManager->get(PopularSearchTerms::class)->isCacheable($queryText, $storeId) + ) { + $this->getCacheableResult($catalogSearchHelper, $query); } else { - $query->saveIncrementalPopularity(); + $this->getNotCacheableResult($catalogSearchHelper, $query); + } + } else { + $this->getResponse()->setRedirect($this->_redirect->getRedirectUrl()); + } + } - $redirect = $query->getRedirect(); - if ($redirect && $this->_url->getCurrentUrl() !== $redirect) { - $this->getResponse()->setRedirect($redirect); - return; - } + /** + * Return cacheable result + * + * @param \Magento\CatalogSearch\Helper\Data $catalogSearchHelper + * @param \Magento\Search\Model\Query $query + * @return void + */ + private function getCacheableResult($catalogSearchHelper, $query) + { + if (!$catalogSearchHelper->isMinQueryLength()) { + $redirect = $query->getRedirect(); + if ($redirect && $this->_url->getCurrentUrl() !== $redirect) { + $this->getResponse()->setRedirect($redirect); + return; } + } - $this->_objectManager->get(\Magento\CatalogSearch\Helper\Data::class)->checkNotes(); + $catalogSearchHelper->checkNotes(); + + $this->_view->loadLayout(); + $this->_view->renderLayout(); + } - $this->_view->loadLayout(); - $this->_view->renderLayout(); + /** + * Return not cacheable result + * + * @param \Magento\CatalogSearch\Helper\Data $catalogSearchHelper + * @param \Magento\Search\Model\Query $query + * @return void + * + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getNotCacheableResult($catalogSearchHelper, $query) + { + if ($catalogSearchHelper->isMinQueryLength()) { + $query->setId(0)->setIsActive(1)->setIsProcessed(1); } else { - $this->getResponse()->setRedirect($this->_redirect->getRedirectUrl()); + $query->saveIncrementalPopularity(); + $redirect = $query->getRedirect(); + if ($redirect && $this->_url->getCurrentUrl() !== $redirect) { + $this->getResponse()->setRedirect($redirect); + return; + } } + + $catalogSearchHelper->checkNotes(); + + $this->_view->loadLayout(); + $this->getResponse()->setNoCacheHeaders(); + $this->_view->renderLayout(); } } diff --git a/app/code/Magento/CatalogSearch/Controller/SearchTermsLog/Save.php b/app/code/Magento/CatalogSearch/Controller/SearchTermsLog/Save.php new file mode 100644 index 0000000000000..a4a843c636cd0 --- /dev/null +++ b/app/code/Magento/CatalogSearch/Controller/SearchTermsLog/Save.php @@ -0,0 +1,95 @@ +storeManager = $storeManager; + $this->catalogSearchHelper = $catalogSearchHelper; + $this->queryFactory = $queryFactory; + $this->resultJsonFactory = $resultJsonFactory; + } + + /** + * Save search term + * + * @return Json + */ + public function execute() + { + /* @var $query \Magento\Search\Model\Query */ + $query = $this->queryFactory->get(); + + $query->setStoreId($this->storeManager->getStore()->getId()); + + if ($query->getQueryText() != '') { + try { + if ($this->catalogSearchHelper->isMinQueryLength()) { + $query->setId(0)->setIsActive(1)->setIsProcessed(1); + } else { + $query->saveIncrementalPopularity(); + } + $responseContent = ['success' => true, 'error_message' => '']; + } catch (\Magento\Framework\Exception\LocalizedException $e) { + $responseContent = ['success' => false, 'error_message' => $e]; + } + } else { + $responseContent = ['success' => false, 'error_message' => __('Search term is empty')]; + } + + /** @var Json $resultJson */ + $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($responseContent); + } +} diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php index 6bf735e2141cc..182ecf873d77a 100644 --- a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php @@ -12,7 +12,13 @@ use Magento\Framework\DB\Select; use Magento\Framework\Search\Request\BucketInterface; use Magento\Framework\Search\Request\Dimension; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver as TableResolver; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class DataProvider { /** @@ -32,20 +38,28 @@ class DataProvider */ protected $categoryFactory; + /** + * @var TableResolver + */ + private $tableResolver; + /** * DataProvider constructor. * @param ResourceConnection $resource * @param ScopeResolverInterface $scopeResolver * @param Resolver $layerResolver + * @param TableResolver|null $tableResolver */ public function __construct( ResourceConnection $resource, ScopeResolverInterface $scopeResolver, - Resolver $layerResolver + Resolver $layerResolver, + TableResolver $tableResolver = null ) { $this->resource = $resource; $this->scopeResolver = $scopeResolver; $this->layer = $layerResolver->get(); + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); } /** @@ -69,9 +83,18 @@ public function aroundGetDataSet( $currentScopeId = $this->scopeResolver->getScope($dimensions['scope']->getValue())->getId(); $currentCategory = $this->layer->getCurrentCategory(); + $catalogCategoryProductDimension = new Dimension(\Magento\Store\Model\Store::ENTITY, $currentScopeId); + + $catalogCategoryProductTableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); + $derivedTable = $this->resource->getConnection()->select(); $derivedTable->from( - ['main_table' => $this->resource->getTableName('catalog_category_product_index')], + ['main_table' => $catalogCategoryProductTableName], [ 'value' => 'category_id' ] diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php index 1aa3dba07fc78..cba34cd40132f 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php @@ -12,6 +12,7 @@ use Magento\Framework\Search\Request\Config as SearchRequestConfig; use Magento\Framework\Search\Request\DimensionFactory; use Magento\Store\Model\StoreManagerInterface; +use Magento\Indexer\Model\ProcessManager; /** * Provide functionality for Fulltext Search indexing. @@ -71,6 +72,11 @@ class Fulltext implements \Magento\Framework\Indexer\ActionInterface, \Magento\F */ private $indexScopeState; + /** + * @var ProcessManager + */ + private $processManager; + /** * @param FullFactory $fullActionFactory * @param IndexerHandlerFactory $indexerHandlerFactory @@ -81,6 +87,8 @@ class Fulltext implements \Magento\Framework\Indexer\ActionInterface, \Magento\F * @param array $data * @param IndexSwitcherInterface $indexSwitcher * @param Scope\State $indexScopeState + * @param ProcessManager $processManager + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( FullFactory $fullActionFactory, @@ -91,7 +99,8 @@ public function __construct( SearchRequestConfig $searchRequestConfig, array $data, IndexSwitcherInterface $indexSwitcher = null, - State $indexScopeState = null + State $indexScopeState = null, + ProcessManager $processManager = null ) { $this->fullAction = $fullActionFactory->create(['data' => $data]); $this->indexerHandlerFactory = $indexerHandlerFactory; @@ -106,8 +115,12 @@ public function __construct( if (null === $indexScopeState) { $indexScopeState = ObjectManager::getInstance()->get(State::class); } + if (null === $processManager) { + $processManager = ObjectManager::getInstance()->get(ProcessManager::class); + } $this->indexSwitcher = $indexSwitcher; $this->indexScopeState = $indexScopeState; + $this->processManager = $processManager; } /** @@ -140,20 +153,16 @@ public function execute($ids) public function executeFull() { $storeIds = array_keys($this->storeManager->getStores()); - /** @var IndexerHandler $saveHandler */ - $saveHandler = $this->indexerHandlerFactory->create([ - 'data' => $this->data - ]); + + $userFunctions = []; foreach ($storeIds as $storeId) { - $dimensions = [$this->dimensionFactory->create(['name' => 'scope', 'value' => $storeId])]; - $this->indexScopeState->useTemporaryIndex(); + $userFunctions[$storeId] = function () use ($storeId) { + return $this->executeFullByStore($storeId); + }; + } - $saveHandler->cleanIndex($dimensions); - $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId)); + $this->processManager->execute($userFunctions); - $this->indexSwitcher->switchIndex($dimensions); - $this->indexScopeState->useRegularIndex(); - } $this->fulltextResource->resetSearchResults(); $this->searchRequestConfig->reset(); } @@ -179,4 +188,26 @@ public function executeRow($id) { $this->execute([$id]); } + + /** + * Execute full indexation by storeID + * + * @param int $storeId + */ + private function executeFullByStore($storeId) + { + /** @var IndexerHandler $saveHandler */ + $saveHandler = $this->indexerHandlerFactory->create([ + 'data' => $this->data + ]); + + $dimensions = [$this->dimensionFactory->create(['name' => 'scope', 'value' => $storeId])]; + $this->indexScopeState->useTemporaryIndex(); + + $saveHandler->cleanIndex($dimensions); + $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId)); + + $this->indexSwitcher->switchIndex($dimensions); + $this->indexScopeState->useRegularIndex(); + } } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php index 98fb2528593e7..3846db93dbd5a 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php @@ -6,10 +6,14 @@ namespace Magento\CatalogSearch\Model\Indexer\Fulltext\Action; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Select; +use Magento\Store\Model\Store; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) * @api * @since 100.0.3 */ @@ -101,6 +105,23 @@ class DataProvider */ private $attributeOptions = []; + /** + * Cache searchable attributes by backend type + * + * @var array + */ + private $searchableAttributesByBackendType = []; + + /** + * Adjusts a size of filtered rows for searchable products. Filtered rows counts by the following condition: + * entity_id > X AND entity_id < X + BatchSize * antiGapMultiplier + * It will help in case a lot of gaps between entity_id in product table, when selected amount of products will be + * less than batch size + * + * @var int + */ + private $antiGapMultiplier; + /** * @param ResourceConnection $resource * @param \Magento\Catalog\Model\Product\Type $catalogProductType @@ -110,6 +131,7 @@ class DataProvider * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool + * @param int $antiGapMultiplier */ public function __construct( ResourceConnection $resource, @@ -119,7 +141,8 @@ public function __construct( \Magento\CatalogSearch\Model\ResourceModel\EngineProvider $engineProvider, \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\Framework\EntityManager\MetadataPool $metadataPool + \Magento\Framework\EntityManager\MetadataPool $metadataPool, + int $antiGapMultiplier = 5 ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -130,6 +153,7 @@ public function __construct( $this->storeManager = $storeManager; $this->engine = $engineProvider->get(); $this->metadata = $metadataPool->getMetadata(ProductInterface::class); + $this->antiGapMultiplier = $antiGapMultiplier; } /** @@ -150,7 +174,7 @@ private function getTable($table) * @param array $staticFields * @param array|int $productIds * @param int $lastProductId - * @param int $limit + * @param int $batch * @return array * @since 100.0.3 */ @@ -159,9 +183,47 @@ public function getSearchableProducts( array $staticFields, $productIds = null, $lastProductId = 0, - $limit = 100 + $batch = 100 ) { - $websiteId = $this->storeManager->getStore($storeId)->getWebsiteId(); + + $select = $this->getSelectForSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $batch); + if ($productIds === null) { + $select->where( + 'e.entity_id < ?', + $lastProductId ? $this->antiGapMultiplier * $batch + $lastProductId + 1 : $batch + 1 + ); + } + $products = $this->connection->fetchAll($select); + if ($productIds === null && !$products) { + // try to search without limit entity_id by batch size for cover case with a big gap between entity ids + $products = $this->connection->fetchAll( + $this->getSelectForSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $batch) + ); + } + + return $products; + } + + /** + * Get Select object for searchable products + * + * @param int $storeId + * @param array $staticFields + * @param array|int $productIds + * @param int $lastProductId + * @param int $batch + * @return Select + */ + private function getSelectForSearchableProducts( + $storeId, + array $staticFields, + $productIds, + $lastProductId, + $batch + ) { + $websiteId = (int)$this->storeManager->getStore($storeId)->getWebsiteId(); + $lastProductId = (int)$lastProductId; + $select = $this->connection->select() ->useStraightJoin(true) ->from( @@ -174,15 +236,65 @@ public function getSearchableProducts( [] ); + $this->joinAttribute($select, 'visibility', $storeId, $this->engine->getAllowedVisibility()); + $this->joinAttribute($select, 'status', $storeId, [Status::STATUS_ENABLED]); + if ($productIds !== null) { $select->where('e.entity_id IN (?)', $productIds); } + $select->where('e.entity_id > ?', $lastProductId); + $select->order('e.entity_id'); + $select->limit($batch); - $select->where('e.entity_id > ?', $lastProductId)->limit($limit)->order('e.entity_id'); + return $select; + } - $result = $this->connection->fetchAll($select); + /** + * Join attribute to searchable product for filtration + * + * @param Select $select + * @param string $attributeCode + * @param int $storeId + * @param array $whereValue + */ + private function joinAttribute(Select $select, $attributeCode, $storeId, array $whereValue) + { + $linkField = $this->metadata->getLinkField(); + $attribute = $this->getSearchableAttribute($attributeCode); + $attributeTable = $this->getTable('catalog_product_entity_' . $attribute->getBackendType()); + $defaultAlias = $attributeCode . '_default'; + $storeAlias = $attributeCode . '_store'; + + $whereCondition = $this->connection->getCheckSql( + $storeAlias . '.value_id > 0', + $storeAlias . '.value', + $defaultAlias . '.value' + ); - return $result; + $select->join( + [$defaultAlias => $attributeTable], + $this->connection->quoteInto( + $defaultAlias . '.' . $linkField . '= e.' . $linkField . ' AND ' . $defaultAlias . '.attribute_id = ?', + $attribute->getAttributeId() + ) . $this->connection->quoteInto( + ' AND ' . $defaultAlias . '.store_id = ?', + Store::DEFAULT_STORE_ID + ), + [] + )->joinLeft( + [$storeAlias => $attributeTable], + $this->connection->quoteInto( + $storeAlias . '.' . $linkField . '= e.' . $linkField . ' AND ' . $storeAlias . '.attribute_id = ?', + $attribute->getAttributeId() + ) . $this->connection->quoteInto( + ' AND ' . $storeAlias . '.store_id = ?', + $storeId + ), + [] + )->where( + $whereCondition . ' IN (?)', + $whereValue + ); } /** @@ -213,20 +325,23 @@ public function getSearchableAttributes($backendType = null) foreach ($attributes as $attribute) { $attribute->setEntity($entity); + $this->searchableAttributes[$attribute->getAttributeId()] = $attribute; + $this->searchableAttributes[$attribute->getAttributeCode()] = $attribute; } - - $this->searchableAttributes = $attributes; } if ($backendType !== null) { - $attributes = []; - foreach ($this->searchableAttributes as $attributeId => $attribute) { + if (isset($this->searchableAttributesByBackendType[$backendType])) { + return $this->searchableAttributesByBackendType[$backendType]; + } + $this->searchableAttributesByBackendType[$backendType] = []; + foreach ($this->searchableAttributes as $attribute) { if ($attribute->getBackendType() == $backendType) { - $attributes[$attributeId] = $attribute; + $this->searchableAttributesByBackendType[$backendType][$attribute->getAttributeId()] = $attribute; } } - return $attributes; + return $this->searchableAttributesByBackendType[$backendType]; } return $this->searchableAttributes; @@ -242,16 +357,8 @@ public function getSearchableAttributes($backendType = null) public function getSearchableAttribute($attribute) { $attributes = $this->getSearchableAttributes(); - if (is_numeric($attribute)) { - if (isset($attributes[$attribute])) { - return $attributes[$attribute]; - } - } elseif (is_string($attribute)) { - foreach ($attributes as $attributeModel) { - if ($attributeModel->getAttributeCode() == $attribute) { - return $attributeModel; - } - } + if (isset($attributes[$attribute])) { + return $attributes[$attribute]; } return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $attribute); @@ -339,7 +446,7 @@ public function getProductAttributes($storeId, array $productIds, array $attribu } if ($selects) { - $select = $this->connection->select()->union($selects, \Magento\Framework\DB\Select::SQL_UNION_ALL); + $select = $this->connection->select()->union($selects, Select::SQL_UNION_ALL); $query = $this->connection->query($select); while ($row = $query->fetch()) { $entityId = $productLinkFieldsToEntityIdMap[$row[$linkField]]; @@ -454,7 +561,6 @@ public function prepareProductIndex($indexData, $productData, $storeId) } } } - foreach ($indexData as $entityId => $attributeData) { foreach ($attributeData as $attributeId => $attributeValue) { $value = $this->getAttributeValue($attributeId, $attributeValue, $storeId); @@ -496,33 +602,44 @@ private function getAttributeValue($attributeId, $valueId, $storeId) { $attribute = $this->getSearchableAttribute($attributeId); $value = $this->engine->processAttributeValue($attribute, $valueId); + if (false !== $value) { + $optionValue = $this->getAttributeOptionValue($attributeId, $valueId, $storeId); + if (null === $optionValue) { + $value = preg_replace('/\s+/iu', ' ', trim(strip_tags($value))); + } else { + $value = implode($this->separator, array_filter([$value, $optionValue])); + } + } - if (false !== $value - && $attribute->getIsSearchable() - && $attribute->usesSource() - && $this->engine->allowAdvancedIndex() + return $value; + } + + /** + * Get attribute option value + * + * @param int $attributeId + * @param int $valueId + * @param int $storeId + * @return null|string + */ + private function getAttributeOptionValue($attributeId, $valueId, $storeId) + { + $optionKey = $attributeId . '-' . $storeId; + if (!array_key_exists($optionKey, $this->attributeOptions) ) { - if (!isset($this->attributeOptions[$attributeId][$storeId])) { + $attribute = $this->getSearchableAttribute($attributeId); + if ($this->engine->allowAdvancedIndex() + && $attribute->getIsSearchable() + && $attribute->usesSource() + ) { $attribute->setStoreId($storeId); $options = $attribute->getSource()->toOptionArray(); - $this->attributeOptions[$attributeId][$storeId] = array_combine( - array_column($options, 'value'), - array_column($options, 'label') - ); - } - - $valueText = ''; - if (isset($this->attributeOptions[$attributeId][$storeId][$valueId])) { - $valueText = $this->attributeOptions[$attributeId][$storeId][$valueId]; + $this->attributeOptions[$optionKey] = array_column($options, 'label', 'value'); + } else { + $this->attributeOptions[$optionKey] = null; } - - $pieces = array_filter(array_merge([$value], [$valueText])); - - $value = implode($this->separator, $pieces); } - $value = preg_replace('/\\s+/siu', ' ', trim(strip_tags($value))); - - return $value; + return $this->attributeOptions[$optionKey][$valueId] ?? null; } } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php index 5abcd5e7592e1..8eccc93ef303e 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php @@ -198,6 +198,13 @@ class Full */ private $dataProvider; + /** + * Batch size for searchable product ids + * + * @var int + */ + private $batchSize; + /** * @param ResourceConnection $resource * @param \Magento\Catalog\Model\Product\Type $catalogProductType @@ -219,6 +226,7 @@ class Full * @param \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\IndexIteratorFactory $indexIteratorFactory * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool * @param DataProvider $dataProvider + * @param int $batchSize * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -241,7 +249,8 @@ public function __construct( \Magento\Framework\Indexer\ConfigInterface $indexerConfig, \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\IndexIteratorFactory $indexIteratorFactory, \Magento\Framework\EntityManager\MetadataPool $metadataPool = null, - DataProvider $dataProvider = null + DataProvider $dataProvider = null, + $batchSize = 500 ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -265,6 +274,7 @@ public function __construct( $this->metadataPool = $metadataPool ?: ObjectManager::getInstance() ->get(\Magento\Framework\EntityManager\MetadataPool::class); $this->dataProvider = $dataProvider ?: ObjectManager::getInstance()->get(DataProvider::class); + $this->batchSize = $batchSize; } /** @@ -360,7 +370,7 @@ public function rebuildStoreIndex($storeId, $productIds = null) $lastProductId = 0; $products = $this->dataProvider - ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId); + ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $this->batchSize); while (count($products) > 0) { $productsIds = array_column($products, 'entity_id'); $relatedProducts = $this->getRelatedProducts($products); @@ -371,12 +381,6 @@ public function rebuildStoreIndex($storeId, $productIds = null) foreach ($products as $productData) { $lastProductId = $productData['entity_id']; - if (!$this->isProductVisible($productData['entity_id'], $productsAttributes) || - !$this->isProductEnabled($productData['entity_id'], $productsAttributes) - ) { - continue; - } - $productIndex = [$productData['entity_id'] => $productsAttributes[$productData['entity_id']]]; if (isset($relatedProducts[$productData['entity_id']])) { $childProductsIndex = $this->getChildProductsIndex( @@ -394,7 +398,7 @@ public function rebuildStoreIndex($storeId, $productIds = null) yield $productData['entity_id'] => $index; } $products = $this->dataProvider - ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId); + ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $this->batchSize); }; } @@ -418,25 +422,6 @@ private function getRelatedProducts($products) return array_filter($relatedProducts); } - /** - * Performs check that product is visible on Store Front - * - * Check that product is visible on Store Front using visibility attribute - * and allowed visibility values. - * - * @param int $productId - * @param array $productsAttributes - * @return bool - */ - private function isProductVisible($productId, array $productsAttributes) - { - $visibility = $this->dataProvider->getSearchableAttribute('visibility'); - $allowedVisibility = $this->engine->getAllowedVisibility(); - return isset($productsAttributes[$productId]) && - isset($productsAttributes[$productId][$visibility->getId()]) && - in_array($productsAttributes[$productId][$visibility->getId()], $allowedVisibility); - } - /** * Performs check that product is enabled on Store Front * @@ -451,8 +436,7 @@ private function isProductEnabled($productId, array $productsAttributes) { $status = $this->dataProvider->getSearchableAttribute('status'); $allowedStatuses = $this->catalogProductStatus->getVisibleStatusIds(); - return isset($productsAttributes[$productId]) && - isset($productsAttributes[$productId][$status->getId()]) && + return isset($productsAttributes[$productId][$status->getId()]) && in_array($productsAttributes[$productId][$status->getId()], $allowedStatuses); } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php b/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php index ea5bb8be17c74..931d7571a9014 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php @@ -75,7 +75,7 @@ public function __construct( Batch $batch, IndexScopeResolverInterface $indexScopeResolver, array $data, - $batchSize = 100 + $batchSize = 500 ) { $this->indexScopeResolver = $indexScopeResolver; $this->indexStructure = $indexStructure; diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php index 4212912af9930..ffba417eb3ac7 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php @@ -70,6 +70,13 @@ public function allowAdvancedIndex() return true; } + /** + * Is attribute filterable as term cache + * + * @var array + */ + private $termFilterableAttributeAttributeCache = []; + /** * Is Attribute Filterable as Term * @@ -78,10 +85,16 @@ public function allowAdvancedIndex() */ private function isTermFilterableAttribute($attribute) { - return ($attribute->getIsVisibleInAdvancedSearch() - || $attribute->getIsFilterable() - || $attribute->getIsFilterableInSearch()) - && in_array($attribute->getFrontendInput(), ['select', 'multiselect']); + $attributeId = $attribute->getAttributeId(); + if (!isset($this->termFilterableAttributeAttributeCache[$attributeId])) { + $this->termFilterableAttributeAttributeCache[$attributeId] = + in_array($attribute->getFrontendInput(), ['select', 'multiselect'], true) + && ($attribute->getIsVisibleInAdvancedSearch() + || $attribute->getIsFilterable() + || $attribute->getIsFilterableInSearch()); + } + + return $this->termFilterableAttributeAttributeCache[$attributeId]; } /** diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php index 379b21813860a..68274ee5043f5 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php @@ -366,15 +366,21 @@ protected function _renderFiltersBefore() 'search_result.'. TemporaryStorage::FIELD_SCORE . ' ' . $this->relevanceOrderDirection ); } + return parent::_renderFiltersBefore(); + } + /** + * @inheritdoc + */ + protected function _beforeLoad() + { /* * This order is required to force search results be the same * for the same requests and products with the same relevance * NOTE: this does not replace existing orders but ADDs one more */ $this->setOrder('entity_id'); - - return parent::_renderFiltersBefore(); + return parent::_beforeLoad(); } /** diff --git a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php index dadce2ed0240c..66e0457e7fadd 100644 --- a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php +++ b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php @@ -7,6 +7,10 @@ namespace Magento\CatalogSearch\Model\Search\FilterMapper; use Magento\CatalogSearch\Model\Adapter\Mysql\Filter\AliasResolver; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver as TableResolver; +use Magento\Framework\Search\Request\Dimension; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; /** * Strategy which processes exclusions from general rules @@ -34,19 +38,27 @@ class ExclusionStrategy implements FilterStrategyInterface */ private $validFields = ['price', 'category_ids']; + /** + * @var TableResolver + */ + private $tableResolver; + /** * @param \Magento\Framework\App\ResourceConnection $resourceConnection * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param AliasResolver $aliasResolver + * @param TableResolver|null $tableResolver */ public function __construct( \Magento\Framework\App\ResourceConnection $resourceConnection, \Magento\Store\Model\StoreManagerInterface $storeManager, - AliasResolver $aliasResolver + AliasResolver $aliasResolver, + TableResolver $tableResolver = null ) { $this->resourceConnection = $resourceConnection; $this->storeManager = $storeManager; $this->aliasResolver = $aliasResolver; + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); } /** @@ -112,7 +124,18 @@ private function applyCategoryFilter( \Magento\Framework\DB\Select $select ) { $alias = $this->aliasResolver->getAlias($filter); - $tableName = $this->resourceConnection->getTableName('catalog_category_product_index'); + + $catalogCategoryProductDimension = new Dimension( + \Magento\Store\Model\Store::ENTITY, + $this->storeManager->getStore()->getId() + ); + + $tableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); $mainTableAlias = $this->extractTableAliasFromSelect($select); $select->joinInner( diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php index 60d4ef5f55f02..0a3b6a6ed5b13 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php @@ -54,6 +54,11 @@ class FulltextTest extends \PHPUnit\Framework\TestCase */ private $indexSwitcher; + /** + * @var \Magento\Indexer\Model\ProcessManager + */ + private $processManager; + protected function setUp() { $this->fullAction = $this->getClassMock(\Magento\CatalogSearch\Model\Indexer\Fulltext\Action\Full::class); @@ -89,6 +94,8 @@ protected function setUp() ->setMethods(['switchIndex']) ->getMock(); + $this->processManager = new \Magento\Indexer\Model\ProcessManager(); + $objectManagerHelper = new ObjectManagerHelper($this); $this->model = $objectManagerHelper->getObject( \Magento\CatalogSearch\Model\Indexer\Fulltext::class, @@ -101,6 +108,7 @@ protected function setUp() 'searchRequestConfig' => $this->searchRequestConfig, 'data' => [], 'indexSwitcher' => $this->indexSwitcher, + 'processManager' => $this->processManager, ] ); } diff --git a/app/code/Magento/CatalogSearch/Ui/DataProvider/Product/AddFulltextFilterToCollection.php b/app/code/Magento/CatalogSearch/Ui/DataProvider/Product/AddFulltextFilterToCollection.php new file mode 100644 index 0000000000000..eb05b343f3c7a --- /dev/null +++ b/app/code/Magento/CatalogSearch/Ui/DataProvider/Product/AddFulltextFilterToCollection.php @@ -0,0 +1,47 @@ +searchCollection = $searchCollection; + } + + /** + * {@inheritdoc} + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function addFilter(Collection $collection, $field, $condition = null) + { + /** @var $collection \Magento\Catalog\Model\ResourceModel\Product\Collection */ + if (isset($condition['fulltext']) && !empty($condition['fulltext'])) { + $this->searchCollection->addBackendSearchFilter($condition['fulltext']); + $productIds = $this->searchCollection->load()->getAllIds(); + $collection->addIdFilter($productIds); + } + } +} diff --git a/app/code/Magento/CatalogSearch/composer.json b/app/code/Magento/CatalogSearch/composer.json index 85c9073160330..6c424c7eaa3da 100644 --- a/app/code/Magento/CatalogSearch/composer.json +++ b/app/code/Magento/CatalogSearch/composer.json @@ -2,20 +2,22 @@ "name": "magento/module-catalog-search", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-catalog": "102.0.*", + "magento/module-indexer": "100.2.*", "magento/module-search": "100.2.*", "magento/module-customer": "101.0.*", "magento/module-directory": "100.2.*", "magento/module-eav": "101.0.*", "magento/module-backend": "100.2.*", "magento/module-theme": "100.2.*", + "magento/module-ui": "101.0.*", "magento/module-catalog-inventory": "100.2.*", "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml b/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml index d323f0e95d9de..7877ff04b24fd 100644 --- a/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml +++ b/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml @@ -19,4 +19,11 @@ + + + + Magento\CatalogSearch\Ui\DataProvider\Product\AddFulltextFilterToCollection + + + diff --git a/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml b/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml index 0eeb6ab33871e..f5ebd3c6c9dc4 100644 --- a/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml +++ b/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml @@ -27,6 +27,11 @@ validate-digits + + + Number of popular search terms to be cached for faster response. Use “0” to cache all results after a term is searched for the second time. + validate-digits + diff --git a/app/code/Magento/CatalogSearch/etc/config.xml b/app/code/Magento/CatalogSearch/etc/config.xml index d5ff194813b9c..9fb0118701d10 100644 --- a/app/code/Magento/CatalogSearch/etc/config.xml +++ b/app/code/Magento/CatalogSearch/etc/config.xml @@ -15,6 +15,7 @@ mysql1128 + 100 diff --git a/app/code/Magento/CatalogSearch/etc/module.xml b/app/code/Magento/CatalogSearch/etc/module.xml index fd31faa083926..55d1cacf1a9f3 100644 --- a/app/code/Magento/CatalogSearch/etc/module.xml +++ b/app/code/Magento/CatalogSearch/etc/module.xml @@ -10,6 +10,7 @@ + diff --git a/app/code/Magento/CatalogSearch/view/adminhtml/ui_component/product_listing.xml b/app/code/Magento/CatalogSearch/view/adminhtml/ui_component/product_listing.xml new file mode 100644 index 0000000000000..24aa4a8919db8 --- /dev/null +++ b/app/code/Magento/CatalogSearch/view/adminhtml/ui_component/product_listing.xml @@ -0,0 +1,12 @@ + + ++ + + + diff --git a/app/code/Magento/CatalogSearch/view/frontend/layout/catalogsearch_result_index.xml b/app/code/Magento/CatalogSearch/view/frontend/layout/catalogsearch_result_index.xml index e54b45093589a..043e48c085e59 100644 --- a/app/code/Magento/CatalogSearch/view/frontend/layout/catalogsearch_result_index.xml +++ b/app/code/Magento/CatalogSearch/view/frontend/layout/catalogsearch_result_index.xml @@ -9,16 +9,16 @@ - - + + positions:list-secondary - - + + product_list_toolbar @@ -36,6 +36,11 @@ + + + Magento\CatalogSearch\Block\SearchTermsLog + + diff --git a/app/code/Magento/CatalogSearch/view/frontend/templates/search_terms_log.phtml b/app/code/Magento/CatalogSearch/view/frontend/templates/search_terms_log.phtml new file mode 100644 index 0000000000000..61609bdf66bda --- /dev/null +++ b/app/code/Magento/CatalogSearch/view/frontend/templates/search_terms_log.phtml @@ -0,0 +1,18 @@ + +getSearchTermsLog()->isPageCacheable()): ?> + + diff --git a/app/code/Magento/CatalogSearch/view/frontend/web/js/search-terms-log.js b/app/code/Magento/CatalogSearch/view/frontend/web/js/search-terms-log.js new file mode 100644 index 0000000000000..8638a837f56b9 --- /dev/null +++ b/app/code/Magento/CatalogSearch/view/frontend/web/js/search-terms-log.js @@ -0,0 +1,21 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'mageUtils' +], function ($, utils) { + 'use strict'; + + return function (data) { + $.ajax({ + method: 'GET', + url: data.url, + data: { + 'q': utils.getUrlParameters(window.location.href).q + } + }); + }; +}); diff --git a/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php new file mode 100644 index 0000000000000..75959c3872fe0 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php @@ -0,0 +1,116 @@ +categoryUrlPathGenerator = $categoryUrlPathGenerator; + $this->categoryUrlRewriteGenerator = $categoryUrlRewriteGenerator; + $this->urlPersist = $urlPersist; + $this->storeViewService = $storeViewService; + } + + /** + * Perform url updating for different stores + * + * @param CategoryResource $subject + * @param CategoryResource $result + * @param AbstractModel $category + * @return CategoryResource + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterSave( + CategoryResource $subject, + CategoryResource $result, + AbstractModel $category + ) { + $parentCategoryId = $category->getParentId(); + if ($category->isObjectNew() + && !$category->isInRootCategoryList() + && !empty($parentCategoryId)) { + foreach ($category->getStoreIds() as $storeId) { + if (!$this->isGlobalScope($storeId) + && $this->storeViewService->doesEntityHaveOverriddenUrlPathForStore( + $storeId, + $parentCategoryId, + Category::ENTITY + ) + ) { + $category->setStoreId($storeId); + $this->updateUrlPathForCategory($category, $subject); + $this->urlPersist->replace($this->categoryUrlRewriteGenerator->generate($category)); + } + } + } + return $result; + } + + /** + * Check that store id is in global scope + * + * @param int|null $storeId + * @return bool + */ + private function isGlobalScope(int $storeId): bool + { + return null === $storeId || $storeId === Store::DEFAULT_STORE_ID; + } + + /** + * @param Category $category + * @param \Magento\Catalog\Model\ResourceModel\Category $categoryResource + */ + private function updateUrlPathForCategory(Category $category, CategoryResource $categoryResource) + { + $category->unsUrlPath(); + $category->setUrlPath($this->categoryUrlPathGenerator->getUrlPath($category)); + $categoryResource->saveAttribute($category, 'url_path'); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php index 539e5c3f42f15..5130b43333d47 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\CatalogUrlRewrite\Observer; use Magento\Catalog\Model\Category; @@ -12,6 +13,9 @@ use Magento\CatalogUrlRewrite\Model\Map\DataProductUrlRewriteDatabaseMap; use Magento\CatalogUrlRewrite\Model\UrlRewriteBunchReplacer; use Magento\Framework\Event\ObserverInterface; +use Magento\Store\Model\ResourceModel\Group\CollectionFactory; +use Magento\Store\Model\ResourceModel\Group\Collection as StoreGroupCollection; +use Magento\Framework\App\ObjectManager; /** * Generates Category Url Rewrites after save and Products Url Rewrites assigned to the category that's being saved @@ -43,12 +47,18 @@ class CategoryProcessUrlRewriteSavingObserver implements ObserverInterface */ private $dataUrlRewriteClassNames; + /** + * @var CollectionFactory + */ + private $storeGroupFactory; + /** * @param CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator * @param UrlRewriteHandler $urlRewriteHandler * @param UrlRewriteBunchReplacer $urlRewriteBunchReplacer * @param DatabaseMapPool $databaseMapPool * @param string[] $dataUrlRewriteClassNames + * @param CollectionFactory|null $storeGroupFactory */ public function __construct( CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator, @@ -56,15 +66,18 @@ public function __construct( UrlRewriteBunchReplacer $urlRewriteBunchReplacer, DatabaseMapPool $databaseMapPool, $dataUrlRewriteClassNames = [ - DataCategoryUrlRewriteDatabaseMap::class, - DataProductUrlRewriteDatabaseMap::class - ] + DataCategoryUrlRewriteDatabaseMap::class, + DataProductUrlRewriteDatabaseMap::class + ], + CollectionFactory $storeGroupFactory = null ) { $this->categoryUrlRewriteGenerator = $categoryUrlRewriteGenerator; $this->urlRewriteHandler = $urlRewriteHandler; $this->urlRewriteBunchReplacer = $urlRewriteBunchReplacer; $this->databaseMapPool = $databaseMapPool; $this->dataUrlRewriteClassNames = $dataUrlRewriteClassNames; + $this->storeGroupFactory = $storeGroupFactory + ?: ObjectManager::getInstance()->get(CollectionFactory::class); } /** @@ -82,6 +95,10 @@ public function execute(\Magento\Framework\Event\Observer $observer) return; } + if (!$category->hasData('store_id')) { + $this->setCategoryStoreId($category); + } + $mapsGenerated = false; if ($category->dataHasChangedFor('url_key') || $category->dataHasChangedFor('is_anchor') @@ -102,6 +119,29 @@ public function execute(\Magento\Framework\Event\Observer $observer) } } + /** + * in case store_id is not set for category then we can assume that it was passed through product import. + * store group must have only one root category, so receiving category's path and checking if one of it parts + * is the root category for store group, we can set default_store_id value from it to category. + * it prevents urls duplication for different stores + * ("Default Category/category/sub" and "Default Category2/category/sub") + * + * @param Category $category + * @return void + */ + private function setCategoryStoreId($category) + { + /** @var StoreGroupCollection $storeGroupCollection */ + $storeGroupCollection = $this->storeGroupFactory->create(); + + foreach ($storeGroupCollection as $storeGroup) { + /** @var \Magento\Store\Model\Group $storeGroup */ + if (in_array($storeGroup->getRootCategoryId(), explode('/', $category->getPath()))) { + $category->setStoreId($storeGroup->getDefaultStoreId()); + } + } + } + /** * Resets used data maps to free up memory and temporary tables * diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/ProductProcessUrlRewriteSavingObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/ProductProcessUrlRewriteSavingObserver.php index e4ccd0b869db7..c4d67f447e2cf 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/ProductProcessUrlRewriteSavingObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/ProductProcessUrlRewriteSavingObserver.php @@ -50,13 +50,6 @@ public function execute(\Magento\Framework\Event\Observer $observer) || $product->getIsChangedWebsites() || $product->dataHasChangedFor('visibility') ) { - $this->urlPersist->deleteByData([ - UrlRewrite::ENTITY_ID => $product->getId(), - UrlRewrite::ENTITY_TYPE => ProductUrlRewriteGenerator::ENTITY_TYPE, - UrlRewrite::REDIRECT_TYPE => 0, - UrlRewrite::STORE_ID => $product->getStoreId() - ]); - if ($product->isVisibleInSiteVisibility()) { $this->urlPersist->replace($this->productUrlRewriteGenerator->generate($product)); } diff --git a/app/code/Magento/CatalogUrlRewrite/Plugin/Store/Block/Switcher.php b/app/code/Magento/CatalogUrlRewrite/Plugin/Store/Block/Switcher.php new file mode 100644 index 0000000000000..498521af8852e --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Plugin/Store/Block/Switcher.php @@ -0,0 +1,86 @@ +postHelper = $postHelper; + $this->urlFinder = $urlFinder; + $this->request = $request; + } + + /** + * @param \Magento\Store\Block\Switcher $subject + * @param string $result + * @param Store $store + * @param array $data + * @return string + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetTargetStorePostData( + \Magento\Store\Block\Switcher $subject, + string $result, + Store $store, + array $data = [] + ): string { + $data[StoreResolverInterface::PARAM_NAME] = $store->getCode(); + + $currentUrl = $store->getCurrentUrl(true); + $baseUrl = $store->getBaseUrl(); + $urlPath = parse_url($currentUrl, PHP_URL_PATH); + + $urlToSwitch = $currentUrl; + + //check only catalog pages + if ($this->request->getFrontName() === 'catalog') { + $currentRewrite = $this->urlFinder->findOneByData([ + UrlRewrite::REQUEST_PATH => ltrim($urlPath, '/'), + UrlRewrite::STORE_ID => $store->getId(), + ]); + if (null === $currentRewrite) { + $urlToSwitch = $baseUrl; + } + } + + return $this->postHelper->getPostData($urlToSwitch, $data); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php new file mode 100644 index 0000000000000..a09620f0797ab --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php @@ -0,0 +1,163 @@ +objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->categoryUrlPathGenerator = $this->getMockBuilder( + \Magento\CatalogUrlRewrite\Model\CategoryUrlPathGenerator::class + ) + ->disableOriginalConstructor() + ->setMethods(['getUrlPath']) + ->getMock(); + $this->categoryUrlRewriteGenerator = $this->getMockBuilder( + \Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator::class + ) + ->disableOriginalConstructor() + ->setMethods(['generate']) + ->getMock(); + $this->categoryResource = $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Category::class) + ->disableOriginalConstructor() + ->setMethods(['saveAttribute']) + ->getMock(); + $this->category = $this->getMockBuilder(\Magento\Catalog\Model\Category::class) + ->disableOriginalConstructor() + ->setMethods( + [ + 'getStoreId', + 'getParentId', + 'isObjectNew', + 'isInRootCategoryList', + 'getStoreIds', + 'setStoreId', + 'unsUrlPath', + 'setUrlPath' + ] + ) + ->getMock(); + $this->storeViewService = $this->getMockBuilder(\Magento\CatalogUrlRewrite\Service\V1\StoreViewService::class) + ->disableOriginalConstructor() + ->setMethods(['doesEntityHaveOverriddenUrlPathForStore']) + ->getMock(); + $this->urlPersist = $this->getMockBuilder(\Magento\UrlRewrite\Model\UrlPersistInterface::class) + ->disableOriginalConstructor() + ->setMethods(['replace']) + ->getMockForAbstractClass(); + + $this->updateUrlPathPlugin = $this->objectManager->getObject( + \Magento\CatalogUrlRewrite\Model\Category\Plugin\Category\UpdateUrlPath::class, + [ + 'categoryUrlPathGenerator' => $this->categoryUrlPathGenerator, + 'categoryUrlRewriteGenerator' => $this->categoryUrlRewriteGenerator, + 'urlPersist' => $this->urlPersist, + 'storeViewService' => $this->storeViewService + ] + ); + } + + public function testAroundSaveWithoutRootCategory() + { + $this->category->expects($this->atLeastOnce())->method('getParentId')->willReturn(0); + $this->category->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->category->expects($this->atLeastOnce())->method('isInRootCategoryList')->willReturn(false); + $this->category->expects($this->never())->method('getStoreIds'); + + $this->assertEquals( + $this->categoryResource, + $this->updateUrlPathPlugin->afterSave($this->categoryResource, $this->categoryResource, $this->category) + ); + } + + public function testAroundSaveWithRootCategory() + { + $parentId = 1; + $categoryStoreIds = [0,1,2]; + $generatedUrlPath = 'parent_category/child_category'; + + $this->categoryUrlPathGenerator->expects($this->once())->method('getUrlPath')->with($this->category) + ->willReturn($generatedUrlPath); + $this->category->expects($this->atLeastOnce())->method('getParentId')->willReturn($parentId); + $this->category->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->category->expects($this->atLeastOnce())->method('isInRootCategoryList')->willReturn(false); + $this->category->expects($this->atLeastOnce())->method('getStoreIds')->willReturn($categoryStoreIds); + $this->category->expects($this->once())->method('setStoreId')->with($categoryStoreIds[2])->willReturnSelf(); + $this->category->expects($this->once())->method('unsUrlPath')->willReturnSelf(); + $this->category->expects($this->once())->method('setUrlPath')->with($generatedUrlPath)->willReturnSelf(); + $this->storeViewService->expects($this->exactly(2))->method('doesEntityHaveOverriddenUrlPathForStore') + ->willReturnMap( + [ + [ + $categoryStoreIds[1], $parentId, 'catalog_category', false + ], + [ + $categoryStoreIds[2], $parentId, 'catalog_category', true + ] + ] + ); + $this->categoryResource->expects($this->once())->method('saveAttribute')->with($this->category, 'url_path') + ->willReturnSelf(); + $generatedUrlRewrite = $this->getMockBuilder(\Magento\UrlRewrite\Service\V1\Data\UrlRewrite::class) + ->disableOriginalConstructor() + ->getMock(); + $this->categoryUrlRewriteGenerator->expects($this->once())->method('generate')->with($this->category) + ->willReturn([$generatedUrlRewrite]); + $this->urlPersist->expects($this->once())->method('replace')->with([$generatedUrlRewrite])->willReturnSelf(); + + $this->assertEquals( + $this->categoryResource, + $this->updateUrlPathPlugin->afterSave($this->categoryResource, $this->categoryResource, $this->category) + ); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/ProductProcessUrlRewriteSavingObserverTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/ProductProcessUrlRewriteSavingObserverTest.php index d294f6d022ef3..39317b42af989 100644 --- a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/ProductProcessUrlRewriteSavingObserverTest.php +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/ProductProcessUrlRewriteSavingObserverTest.php @@ -103,7 +103,6 @@ public function urlKeyDataProvider() 'isChangedWebsites' => false, 'isChangedCategories' => false, 'visibilityResult' => true, - 'expectedDeleteCount' => 1, 'expectedReplaceCount' => 1, ], @@ -113,7 +112,6 @@ public function urlKeyDataProvider() 'isChangedWebsites' => false, 'isChangedCategories' => false, 'visibilityResult' => true, - 'expectedDeleteCount' => 0, 'expectedReplaceCount' => 0 ], 'visibility changed' => [ @@ -122,7 +120,6 @@ public function urlKeyDataProvider() 'isChangedWebsites' => false, 'isChangedCategories' => false, 'visibilityResult' => true, - 'expectedDeleteCount' => 1, 'expectedReplaceCount' => 1 ], 'websites changed' => [ @@ -131,7 +128,6 @@ public function urlKeyDataProvider() 'isChangedWebsites' => true, 'isChangedCategories' => false, 'visibilityResult' => true, - 'expectedDeleteCount' => 1, 'expectedReplaceCount' => 1 ], 'categories changed' => [ @@ -140,7 +136,6 @@ public function urlKeyDataProvider() 'isChangedWebsites' => false, 'isChangedCategories' => true, 'visibilityResult' => true, - 'expectedDeleteCount' => 1, 'expectedReplaceCount' => 1 ], 'url changed invisible' => [ @@ -149,7 +144,6 @@ public function urlKeyDataProvider() 'isChangedWebsites' => false, 'isChangedCategories' => false, 'visibilityResult' => false, - 'expectedDeleteCount' => 1, 'expectedReplaceCount' => 0 ], ]; @@ -161,7 +155,6 @@ public function urlKeyDataProvider() * @param bool $isChangedWebsites * @param bool $isChangedCategories * @param bool $visibilityResult - * @param int $expectedDeleteCount * @param int $expectedReplaceCount * * @dataProvider urlKeyDataProvider @@ -172,7 +165,6 @@ public function testExecuteUrlKey( $isChangedWebsites, $isChangedCategories, $visibilityResult, - $expectedDeleteCount, $expectedReplaceCount ) { $this->product->expects($this->any())->method('getStoreId')->will($this->returnValue(12)); @@ -194,13 +186,6 @@ public function testExecuteUrlKey( ->method('getIsChangedCategories') ->will($this->returnValue($isChangedCategories)); - $this->urlPersist->expects($this->exactly($expectedDeleteCount))->method('deleteByData')->with([ - UrlRewrite::ENTITY_ID => $this->product->getId(), - UrlRewrite::ENTITY_TYPE => ProductUrlRewriteGenerator::ENTITY_TYPE, - UrlRewrite::REDIRECT_TYPE => 0, - UrlRewrite::STORE_ID => $this->product->getStoreId() - ]); - $this->product->expects($this->any()) ->method('isVisibleInSiteVisibility') ->will($this->returnValue($visibilityResult)); diff --git a/app/code/Magento/CatalogUrlRewrite/composer.json b/app/code/Magento/CatalogUrlRewrite/composer.json index 55cbab2077cef..d932e96e611b2 100644 --- a/app/code/Magento/CatalogUrlRewrite/composer.json +++ b/app/code/Magento/CatalogUrlRewrite/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-url-rewrite", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-backend": "100.2.*", "magento/module-catalog": "102.0.*", "magento/module-catalog-import-export": "100.2.*", @@ -14,7 +14,7 @@ "magento/module-ui": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogUrlRewrite/etc/di.xml b/app/code/Magento/CatalogUrlRewrite/etc/di.xml index 2d421417bfdc0..f6426677e8ce8 100644 --- a/app/code/Magento/CatalogUrlRewrite/etc/di.xml +++ b/app/code/Magento/CatalogUrlRewrite/etc/di.xml @@ -19,6 +19,7 @@ + diff --git a/app/code/Magento/CatalogUrlRewrite/etc/frontend/di.xml b/app/code/Magento/CatalogUrlRewrite/etc/frontend/di.xml new file mode 100644 index 0000000000000..3a9122b2f748d --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/etc/frontend/di.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php index 373b88049c7b5..9a55f981b7607 100644 --- a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php +++ b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php @@ -160,14 +160,16 @@ public function getCacheKeyInfo() return [ 'CATALOG_PRODUCTS_LIST_WIDGET', - $this->getPriceCurrency()->getCurrencySymbol(), + $this->getPriceCurrency()->getCurrency()->getCode(), $this->_storeManager->getStore()->getId(), $this->_design->getDesignTheme()->getId(), $this->httpContext->getValue(\Magento\Customer\Model\Context::CONTEXT_GROUP), intval($this->getRequest()->getParam($this->getData('page_var_name'), 1)), $this->getProductsPerPage(), $conditions, - $this->json->serialize($this->getRequest()->getParams()) + $this->json->serialize($this->getRequest()->getParams()), + $this->getTemplate(), + $this->getTitle() ]; } diff --git a/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php b/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php index e871ed4359d5c..3039066ad1388 100644 --- a/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php +++ b/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php @@ -87,8 +87,8 @@ protected function setUp() { $this->collectionFactory = $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Product\CollectionFactory::class) - ->setMethods(['create']) - ->disableOriginalConstructor()->getMock(); + ->setMethods(['create']) + ->disableOriginalConstructor()->getMock(); $this->visibility = $this->getMockBuilder(\Magento\Catalog\Model\Product\Visibility::class) ->setMethods(['getVisibleInCatalogIds']) ->disableOriginalConstructor() @@ -144,10 +144,14 @@ public function testGetCacheKeyInfo() $this->productsList->setData('conditions', 'some_serialized_conditions'); $this->productsList->setData('page_var_name', 'page_number'); + $this->productsList->setTemplate('test_template'); + $this->productsList->setData('title', 'test_title'); $this->request->expects($this->once())->method('getParam')->with('page_number')->willReturn(1); $this->request->expects($this->once())->method('getParams')->willReturn('request_params'); - $this->priceCurrency->expects($this->once())->method('getCurrencySymbol')->willReturn('$'); + $currency = $this->createMock(\Magento\Directory\Model\Currency::class); + $currency->expects($this->once())->method('getCode')->willReturn('USD'); + $this->priceCurrency->expects($this->once())->method('getCurrency')->willReturn($currency); $this->serializer->expects($this->any()) ->method('serialize') @@ -157,14 +161,16 @@ public function testGetCacheKeyInfo() $cacheKey = [ 'CATALOG_PRODUCTS_LIST_WIDGET', - '$', + 'USD', 1, 'blank', 'context_group', 1, 5, 'some_serialized_conditions', - json_encode('request_params') + json_encode('request_params'), + 'test_template', + 'test_title' ]; $this->assertEquals($cacheKey, $this->productsList->getCacheKeyInfo()); } @@ -249,9 +255,10 @@ public function testGetPagerHtml() * Test public `createCollection` method and protected `getPageSize` method via `createCollection` * * @param bool $pagerEnable - * @param int $productsCount - * @param int $productsPerPage - * @param int $expectedPageSize + * @param int $productsCount + * @param int $productsPerPage + * @param int $expectedPageSize + * * @dataProvider createCollectionDataProvider */ public function testCreateCollection($pagerEnable, $productsCount, $productsPerPage, $expectedPageSize) @@ -380,6 +387,7 @@ public function testGetIdentities() /** * @param $collection + * * @return \PHPUnit_Framework_MockObject_MockObject */ private function getConditionsForCollection($collection) diff --git a/app/code/Magento/CatalogWidget/composer.json b/app/code/Magento/CatalogWidget/composer.json index 44d1d6296eadd..e6405a35f2285 100644 --- a/app/code/Magento/CatalogWidget/composer.json +++ b/app/code/Magento/CatalogWidget/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-catalog-widget", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-catalog": "102.0.*", "magento/module-widget": "101.0.*", "magento/module-backend": "100.2.*", @@ -14,7 +14,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.0", + "version": "100.2.1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogWidget/view/frontend/templates/product/widget/content/grid.phtml b/app/code/Magento/CatalogWidget/view/frontend/templates/product/widget/content/grid.phtml index 201d6ffe4c683..574cbe1107e88 100644 --- a/app/code/Magento/CatalogWidget/view/frontend/templates/product/widget/content/grid.phtml +++ b/app/code/Magento/CatalogWidget/view/frontend/templates/product/widget/content/grid.phtml @@ -35,8 +35,7 @@
    - -
  1. + ' : '
  2. ' ?>
    getImage($_item, $image)->toHtml() ?> diff --git a/app/code/Magento/Checkout/Block/Cart/Shipping.php b/app/code/Magento/Checkout/Block/Cart/Shipping.php index 7b0ab1bc03e5b..c52b7fe18814f 100644 --- a/app/code/Magento/Checkout/Block/Cart/Shipping.php +++ b/app/code/Magento/Checkout/Block/Cart/Shipping.php @@ -74,7 +74,8 @@ public function getJsLayout() foreach ($this->layoutProcessors as $processor) { $this->jsLayout = $processor->process($this->jsLayout); } - return $this->serializer->serialize($this->jsLayout); + + return json_encode($this->jsLayout, JSON_HEX_TAG); } /** @@ -94,6 +95,6 @@ public function getBaseUrl() */ public function getSerializedCheckoutConfig() { - return $this->serializer->serialize($this->getCheckoutConfig()); + return json_encode($this->getCheckoutConfig(), JSON_HEX_TAG); } } diff --git a/app/code/Magento/Checkout/Block/Cart/Totals.php b/app/code/Magento/Checkout/Block/Cart/Totals.php index d3d3adbe40f38..375c564f29059 100644 --- a/app/code/Magento/Checkout/Block/Cart/Totals.php +++ b/app/code/Magento/Checkout/Block/Cart/Totals.php @@ -69,7 +69,8 @@ public function getJsLayout() foreach ($this->layoutProcessors as $processor) { $this->jsLayout = $processor->process($this->jsLayout); } - return parent::getJsLayout(); + + return json_encode($this->jsLayout, JSON_HEX_TAG); } /** diff --git a/app/code/Magento/Checkout/Block/Onepage.php b/app/code/Magento/Checkout/Block/Onepage.php index bc3cd43a024a6..ca6b045ddbb5d 100644 --- a/app/code/Magento/Checkout/Block/Onepage.php +++ b/app/code/Magento/Checkout/Block/Onepage.php @@ -77,7 +77,8 @@ public function getJsLayout() foreach ($this->layoutProcessors as $processor) { $this->jsLayout = $processor->process($this->jsLayout); } - return $this->serializer->serialize($this->jsLayout); + + return json_encode($this->jsLayout, JSON_HEX_TAG); } /** @@ -119,6 +120,6 @@ public function getBaseUrl() */ public function getSerializedCheckoutConfig() { - return $this->serializer->serialize($this->getCheckoutConfig()); + return json_encode($this->getCheckoutConfig(), JSON_HEX_TAG); } } diff --git a/app/code/Magento/Checkout/Block/Registration.php b/app/code/Magento/Checkout/Block/Registration.php index 91ec85c1db0ed..e880230f50a74 100644 --- a/app/code/Magento/Checkout/Block/Registration.php +++ b/app/code/Magento/Checkout/Block/Registration.php @@ -91,7 +91,7 @@ public function getEmailAddress() */ public function getCreateAccountUrl() { - return $this->getUrl('checkout/account/create'); + return $this->getUrl('checkout/account/delegateCreate'); } /** diff --git a/app/code/Magento/Checkout/Controller/Account/Create.php b/app/code/Magento/Checkout/Controller/Account/Create.php index 89fbb1890a821..55b4ae66d01e0 100644 --- a/app/code/Magento/Checkout/Controller/Account/Create.php +++ b/app/code/Magento/Checkout/Controller/Account/Create.php @@ -8,6 +8,10 @@ use Magento\Framework\Exception\AlreadyExistsException; use Magento\Framework\Exception\NoSuchEntityException; +/** + * @deprecated + * @see DelegateCreate + */ class Create extends \Magento\Framework\App\Action\Action { /** diff --git a/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php b/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php new file mode 100644 index 0000000000000..b532be744edb2 --- /dev/null +++ b/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php @@ -0,0 +1,59 @@ +delegateService = $customerDelegation; + $this->session = $session; + } + + /** + * @inheritDoc + */ + public function execute() + { + /** @var string|null $orderId */ + $orderId = $this->session->getLastOrderId(); + if (!$orderId) { + return $this->resultRedirectFactory->create()->setPath('/'); + } + + return $this->delegateService->delegateNew((int)$orderId); + } +} diff --git a/app/code/Magento/Checkout/Controller/Cart.php b/app/code/Magento/Checkout/Controller/Cart.php index f6c59562ee942..7258ab9921226 100644 --- a/app/code/Magento/Checkout/Controller/Cart.php +++ b/app/code/Magento/Checkout/Controller/Cart.php @@ -118,12 +118,7 @@ protected function getBackUrl($defaultUrl = null) return $returnUrl; } - $shouldRedirectToCart = $this->_scopeConfig->getValue( - 'checkout/cart/redirect_to_cart', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); - - if ($shouldRedirectToCart || $this->getRequest()->getParam('in_cart')) { + if ($this->shouldRedirectToCart() || $this->getRequest()->getParam('in_cart')) { if ($this->getRequest()->getActionName() == 'add' && !$this->getRequest()->getParam('in_cart')) { $this->_checkoutSession->setContinueShoppingUrl($this->_redirect->getRefererUrl()); } @@ -132,4 +127,15 @@ protected function getBackUrl($defaultUrl = null) return $defaultUrl; } + + /** + * @return bool + */ + private function shouldRedirectToCart() + { + return $this->_scopeConfig->isSetFlag( + 'checkout/cart/redirect_to_cart', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } } diff --git a/app/code/Magento/Checkout/Controller/Cart/Add.php b/app/code/Magento/Checkout/Controller/Cart/Add.php index 8831b92f3ec86..6aa489dc8cacc 100644 --- a/app/code/Magento/Checkout/Controller/Cart/Add.php +++ b/app/code/Magento/Checkout/Controller/Cart/Add.php @@ -122,11 +122,21 @@ public function execute() if (!$this->_checkoutSession->getNoCartRedirect(true)) { if (!$this->cart->getQuote()->getHasError()) { - $message = __( - 'You added %1 to your shopping cart.', - $product->getName() - ); - $this->messageManager->addSuccessMessage($message); + if ($this->shouldRedirectToCart()) { + $message = __( + 'You added %1 to your shopping cart.', + $product->getName() + ); + $this->messageManager->addSuccessMessage($message); + } else { + $this->messageManager->addComplexSuccessMessage( + 'addCartSuccessMessage', + [ + 'product_name' => $product->getName(), + 'cart_url' => $this->getCartUrl(), + ] + ); + } } return $this->goBack(null, $product); } @@ -147,8 +157,7 @@ public function execute() $url = $this->_checkoutSession->getRedirectUrl(true); if (!$url) { - $cartUrl = $this->_objectManager->get(\Magento\Checkout\Helper\Cart::class)->getCartUrl(); - $url = $this->_redirect->getRedirectUrl($cartUrl); + $url = $this->_redirect->getRedirectUrl($this->getCartUrl()); } return $this->goBack($url); @@ -188,4 +197,23 @@ protected function goBack($backUrl = null, $product = null) $this->_objectManager->get(\Magento\Framework\Json\Helper\Data::class)->jsonEncode($result) ); } + + /** + * @return string + */ + private function getCartUrl() + { + return $this->_url->getUrl('checkout/cart', ['_secure' => true]); + } + + /** + * @return bool + */ + private function shouldRedirectToCart() + { + return $this->_scopeConfig->isSetFlag( + 'checkout/cart/redirect_to_cart', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } } diff --git a/app/code/Magento/Checkout/Controller/Index/Index.php b/app/code/Magento/Checkout/Controller/Index/Index.php index 0a5b7f190e3d3..0902782b72d83 100644 --- a/app/code/Magento/Checkout/Controller/Index/Index.php +++ b/app/code/Magento/Checkout/Controller/Index/Index.php @@ -32,11 +32,35 @@ public function execute() return $this->resultRedirectFactory->create()->setPath('checkout/cart'); } - $this->_customerSession->regenerateId(); + // generate session ID only if connection is unsecure according to issues in session_regenerate_id function. + // @see http://php.net/manual/en/function.session-regenerate-id.php + if (!$this->isSecureRequest()) { + $this->_customerSession->regenerateId(); + } $this->_objectManager->get(\Magento\Checkout\Model\Session::class)->setCartWasUpdated(false); $this->getOnepage()->initCheckout(); $resultPage = $this->resultPageFactory->create(); $resultPage->getConfig()->getTitle()->set(__('Checkout')); return $resultPage; } + + /** + * Checks if current request uses SSL and referer also is secure. + * + * @return bool + */ + private function isSecureRequest(): bool + { + $request = $this->getRequest(); + + $referrer = $request->getHeader('referer'); + $secure = false; + + if ($referrer) { + $scheme = parse_url($referrer, PHP_URL_SCHEME); + $secure = $scheme === 'https'; + } + + return $secure && $request->isSecure(); + } } diff --git a/app/code/Magento/Checkout/CustomerData/DefaultItem.php b/app/code/Magento/Checkout/CustomerData/DefaultItem.php index 6e917366c9cd2..9351685405a60 100644 --- a/app/code/Magento/Checkout/CustomerData/DefaultItem.php +++ b/app/code/Magento/Checkout/CustomerData/DefaultItem.php @@ -6,6 +6,8 @@ namespace Magento\Checkout\CustomerData; +use Magento\Framework\App\ObjectManager; + /** * Default item */ @@ -36,12 +38,20 @@ class DefaultItem extends AbstractItem */ protected $checkoutHelper; + /** + * Escaper + * + * @var \Magento\Framework\Escaper + */ + private $escaper; + /** * @param \Magento\Catalog\Helper\Image $imageHelper * @param \Magento\Msrp\Helper\Data $msrpHelper * @param \Magento\Framework\UrlInterface $urlBuilder * @param \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool * @param \Magento\Checkout\Helper\Data $checkoutHelper + * @param \Magento\Framework\Escaper|null $escaper * @codeCoverageIgnore */ public function __construct( @@ -49,13 +59,15 @@ public function __construct( \Magento\Msrp\Helper\Data $msrpHelper, \Magento\Framework\UrlInterface $urlBuilder, \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool, - \Magento\Checkout\Helper\Data $checkoutHelper + \Magento\Checkout\Helper\Data $checkoutHelper, + \Magento\Framework\Escaper $escaper = null ) { $this->configurationPool = $configurationPool; $this->imageHelper = $imageHelper; $this->msrpHelper = $msrpHelper; $this->urlBuilder = $urlBuilder; $this->checkoutHelper = $checkoutHelper; + $this->escaper = $escaper ?: ObjectManager::getInstance()->get(\Magento\Framework\Escaper::class); } /** @@ -64,6 +76,8 @@ public function __construct( protected function doGetItemData() { $imageHelper = $this->imageHelper->init($this->getProductForThumbnail(), 'mini_cart_product_thumbnail'); + $productName = $this->escaper->escapeHtml($this->item->getProduct()->getName()); + return [ 'options' => $this->getOptionList(), 'qty' => $this->item->getQty() * 1, @@ -71,7 +85,7 @@ protected function doGetItemData() 'configure_url' => $this->getConfigureUrl(), 'is_visible_in_site_visibility' => $this->item->getProduct()->isVisibleInSiteVisibility(), 'product_id' => $this->item->getProduct()->getId(), - 'product_name' => $this->item->getProduct()->getName(), + 'product_name' => $productName, 'product_sku' => $this->item->getProduct()->getSku(), 'product_url' => $this->getProductUrl(), 'product_has_url' => $this->hasProductUrl(), diff --git a/app/code/Magento/Checkout/Helper/Data.php b/app/code/Magento/Checkout/Helper/Data.php index b3c2e17e5d678..23ffc3dca14ab 100644 --- a/app/code/Magento/Checkout/Helper/Data.php +++ b/app/code/Magento/Checkout/Helper/Data.php @@ -9,6 +9,7 @@ use Magento\Quote\Model\Quote\Item\AbstractItem; use Magento\Store\Model\Store; use Magento\Store\Model\ScopeInterface; +use Magento\Sales\Api\PaymentFailuresInterface; /** * Checkout default helper @@ -20,6 +21,9 @@ class Data extends \Magento\Framework\App\Helper\AbstractHelper { const XML_PATH_GUEST_CHECKOUT = 'checkout/options/guest_checkout'; + /** + * @deprecated + */ const XML_PATH_CUSTOMER_MUST_BE_LOGGED = 'checkout/options/customer_must_be_logged'; /** @@ -52,6 +56,11 @@ class Data extends \Magento\Framework\App\Helper\AbstractHelper */ protected $priceCurrency; + /** + * @var PaymentFailuresInterface + */ + private $paymentFailures; + /** * @param \Magento\Framework\App\Helper\Context $context * @param \Magento\Store\Model\StoreManagerInterface $storeManager @@ -60,6 +69,7 @@ class Data extends \Magento\Framework\App\Helper\AbstractHelper * @param \Magento\Framework\Mail\Template\TransportBuilder $transportBuilder * @param \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation * @param PriceCurrencyInterface $priceCurrency + * @param PaymentFailuresInterface|null $paymentFailures * @codeCoverageIgnore */ public function __construct( @@ -69,7 +79,8 @@ public function __construct( \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, \Magento\Framework\Mail\Template\TransportBuilder $transportBuilder, \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation, - PriceCurrencyInterface $priceCurrency + PriceCurrencyInterface $priceCurrency, + PaymentFailuresInterface $paymentFailures = null ) { $this->_storeManager = $storeManager; $this->_checkoutSession = $checkoutSession; @@ -77,6 +88,8 @@ public function __construct( $this->_transportBuilder = $transportBuilder; $this->inlineTranslation = $inlineTranslation; $this->priceCurrency = $priceCurrency; + $this->paymentFailures = $paymentFailures ? : \Magento\Framework\App\ObjectManager::getInstance() + ->get(PaymentFailuresInterface::class); parent::__construct($context); } @@ -202,126 +215,13 @@ public function getBaseSubtotalInclTax($item) * @param string $message * @param string $checkoutType * @return $this - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function sendPaymentFailedEmail($checkout, $message, $checkoutType = 'onepage') - { - $this->inlineTranslation->suspend(); - - $template = $this->scopeConfig->getValue( - 'checkout/payment_failed/template', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ); - - $copyTo = $this->_getEmails('checkout/payment_failed/copy_to', $checkout->getStoreId()); - $copyMethod = $this->scopeConfig->getValue( - 'checkout/payment_failed/copy_method', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ); - $bcc = []; - if ($copyTo && $copyMethod == 'bcc') { - $bcc = $copyTo; - } - - $_receiver = $this->scopeConfig->getValue( - 'checkout/payment_failed/receiver', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ); - $sendTo = [ - [ - 'email' => $this->scopeConfig->getValue( - 'trans_email/ident_' . $_receiver . '/email', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ), - 'name' => $this->scopeConfig->getValue( - 'trans_email/ident_' . $_receiver . '/name', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ), - ], - ]; - - if ($copyTo && $copyMethod == 'copy') { - foreach ($copyTo as $email) { - $sendTo[] = ['email' => $email, 'name' => null]; - } - } - $shippingMethod = ''; - if ($shippingInfo = $checkout->getShippingAddress()->getShippingMethod()) { - $data = explode('_', $shippingInfo); - $shippingMethod = $data[0]; - } - - $paymentMethod = ''; - if ($paymentInfo = $checkout->getPayment()) { - $paymentMethod = $paymentInfo->getMethod(); - } - - $items = ''; - foreach ($checkout->getAllVisibleItems() as $_item) { - /* @var $_item \Magento\Quote\Model\Quote\Item */ - $items .= - $_item->getProduct()->getName() . ' x ' . $_item->getQty() . ' ' . $checkout->getStoreCurrencyCode() - . ' ' . $_item->getProduct()->getFinalPrice( - $_item->getQty() - ) . "\n"; - } - $total = $checkout->getStoreCurrencyCode() . ' ' . $checkout->getGrandTotal(); - - foreach ($sendTo as $recipient) { - $transport = $this->_transportBuilder->setTemplateIdentifier( - $template - )->setTemplateOptions( - [ - 'area' => \Magento\Backend\App\Area\FrontNameResolver::AREA_CODE, - 'store' => Store::DEFAULT_STORE_ID - ] - )->setTemplateVars( - [ - 'reason' => $message, - 'checkoutType' => $checkoutType, - 'dateAndTime' => $this->_localeDate->formatDateTime( - new \DateTime(), - \IntlDateFormatter::MEDIUM, - \IntlDateFormatter::MEDIUM - ), - 'customer' => $checkout->getCustomerFirstname() . ' ' . $checkout->getCustomerLastname(), - 'customerEmail' => $checkout->getCustomerEmail(), - 'billingAddress' => $checkout->getBillingAddress(), - 'shippingAddress' => $checkout->getShippingAddress(), - 'shippingMethod' => $this->scopeConfig->getValue( - 'carriers/' . $shippingMethod . '/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ), - 'paymentMethod' => $this->scopeConfig->getValue( - 'payment/' . $paymentMethod . '/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ), - 'items' => nl2br($items), - 'total' => $total, - ] - )->setFrom( - $this->scopeConfig->getValue( - 'checkout/payment_failed/identity', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $checkout->getStoreId() - ) - )->addTo( - $recipient['email'], - $recipient['name'] - )->addBcc( - $bcc - )->getTransport(); - - $transport->sendMessage(); - } - - $this->inlineTranslation->resume(); + public function sendPaymentFailedEmail( + \Magento\Quote\Model\Quote $checkout, + string $message, + string $checkoutType = 'onepage' + ): \Magento\Checkout\Helper\Data { + $this->paymentFailures->handle((int)$checkout->getId(), $message, $checkoutType); return $this; } @@ -393,6 +293,7 @@ public function isContextCheckout() * * @return boolean * @codeCoverageIgnore + * @deprecated */ public function isCustomerMustBeLogged() { diff --git a/app/code/Magento/Checkout/Model/DefaultConfigProvider.php b/app/code/Magento/Checkout/Model/DefaultConfigProvider.php index b5727bf8f365e..5335e31b6b574 100644 --- a/app/code/Magento/Checkout/Model/DefaultConfigProvider.php +++ b/app/code/Magento/Checkout/Model/DefaultConfigProvider.php @@ -259,7 +259,6 @@ public function getConfig() $output['selectedShippingMethod'] = $this->getSelectedShippingMethod(); $output['storeCode'] = $this->getStoreCode(); $output['isGuestCheckoutAllowed'] = $this->isGuestCheckoutAllowed(); - $output['isCustomerLoginRequired'] = $this->isCustomerLoginRequired(); $output['registerUrl'] = $this->getRegisterUrl(); $output['checkoutUrl'] = $this->getCheckoutUrl(); $output['defaultSuccessPageUrl'] = $this->getDefaultSuccessPageUrl(); @@ -513,17 +512,6 @@ private function isCustomerLoggedIn() return (bool)$this->httpContext->getValue(CustomerContext::CONTEXT_AUTH); } - /** - * Check if customer must be logged in to proceed with checkout - * - * @return bool - * @codeCoverageIgnore - */ - private function isCustomerLoginRequired() - { - return $this->checkoutHelper->isCustomerMustBeLogged(); - } - /** * Return forgot password URL * diff --git a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php index 6779da354faf8..d1894c98e7bce 100644 --- a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php +++ b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php @@ -6,8 +6,11 @@ namespace Magento\Checkout\Model; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\ResourceConnection; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Quote\Model\Quote; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -50,6 +53,11 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa */ private $logger; + /** + * @var ResourceConnection + */ + private $connectionPull; + /** * @param \Magento\Quote\Api\GuestBillingAddressManagementInterface $billingAddressManagement * @param \Magento\Quote\Api\GuestPaymentMethodManagementInterface $paymentMethodManagement @@ -57,6 +65,7 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa * @param \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement * @param \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory * @param CartRepositoryInterface $cartRepository + * @param ResourceConnection|null * @codeCoverageIgnore */ public function __construct( @@ -65,7 +74,8 @@ public function __construct( \Magento\Quote\Api\GuestCartManagementInterface $cartManagement, \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement, \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory, - CartRepositoryInterface $cartRepository + CartRepositoryInterface $cartRepository, + ResourceConnection $connectionPull = null ) { $this->billingAddressManagement = $billingAddressManagement; $this->paymentMethodManagement = $paymentMethodManagement; @@ -73,6 +83,7 @@ public function __construct( $this->paymentInformationManagement = $paymentInformationManagement; $this->quoteIdMaskFactory = $quoteIdMaskFactory; $this->cartRepository = $cartRepository; + $this->connectionPull = $connectionPull ?: ObjectManager::getInstance()->get(ResourceConnection::class); } /** @@ -84,21 +95,35 @@ public function savePaymentInformationAndPlaceOrder( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { - $this->savePaymentInformation($cartId, $email, $paymentMethod, $billingAddress); + $salesConnection = $this->connectionPull->getConnection('sales'); + $checkoutConnection = $this->connectionPull->getConnection('checkout'); + $salesConnection->beginTransaction(); + $checkoutConnection->beginTransaction(); + try { - $orderId = $this->cartManagement->placeOrder($cartId); - } catch (\Magento\Framework\Exception\LocalizedException $e) { - throw new CouldNotSaveException( - __($e->getMessage()), - $e - ); + $this->savePaymentInformation($cartId, $email, $paymentMethod, $billingAddress); + try { + $orderId = $this->cartManagement->placeOrder($cartId); + } catch (\Magento\Framework\Exception\LocalizedException $e) { + throw new CouldNotSaveException( + __($e->getMessage()), + $e + ); + } catch (\Exception $e) { + $this->getLogger()->critical($e); + throw new CouldNotSaveException( + __('An error occurred on the server. Please try to place the order again.'), + $e + ); + } + $salesConnection->commit(); + $checkoutConnection->commit(); } catch (\Exception $e) { - $this->getLogger()->critical($e); - throw new CouldNotSaveException( - __('An error occurred on the server. Please try to place the order again.'), - $e - ); + $salesConnection->rollBack(); + $checkoutConnection->rollBack(); + throw $e; } + return $orderId; } @@ -111,13 +136,19 @@ public function savePaymentInformation( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { + $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); + /** @var Quote $quote */ + $quote = $this->cartRepository->getActive($quoteIdMask->getQuoteId()); + if ($billingAddress) { $billingAddress->setEmail($email); - $this->billingAddressManagement->assign($cartId, $billingAddress); + $quote->removeAddress($quote->getBillingAddress()->getId()); + $quote->setBillingAddress($billingAddress); + $quote->setDataChanges(true); } else { - $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); - $this->cartRepository->getActive($quoteIdMask->getQuoteId())->getBillingAddress()->setEmail($email); + $quote->getBillingAddress()->setEmail($email); } + $this->limitShippingCarrier($quote); $this->paymentMethodManagement->set($cartId, $paymentMethod); return true; @@ -145,4 +176,22 @@ private function getLogger() } return $this->logger; } + + /** + * Limits shipping rates request by carrier from shipping address. + * + * @param Quote $quote + * + * @return void + * @see \Magento\Shipping\Model\Shipping::collectRates + */ + private function limitShippingCarrier(Quote $quote) + { + $shippingAddress = $quote->getShippingAddress(); + if ($shippingAddress && $shippingAddress->getShippingMethod()) { + $shippingDataArray = explode('_', $shippingAddress->getShippingMethod()); + $shippingCarrier = array_shift($shippingDataArray); + $shippingAddress->setLimitCarrier($shippingCarrier); + } + } } diff --git a/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php b/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php index e419a1535207e..6a2ffd87b1885 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php @@ -98,10 +98,7 @@ public function testGetJsLayout() ->method('process') ->with($this->layout) ->willReturn($layoutProcessed); - - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue($jsonLayoutProcessed) - ); + $this->assertEquals( $jsonLayoutProcessed, $this->model->getJsLayout() @@ -121,9 +118,6 @@ public function testGetSerializedCheckoutConfig() { $checkoutConfig = ['checkout', 'config']; $this->configProvider->expects($this->once())->method('getConfig')->willReturn($checkoutConfig); - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue(json_encode($checkoutConfig)) - ); $this->assertEquals(json_encode($checkoutConfig), $this->model->getSerializedCheckoutConfig()); } diff --git a/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php b/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php index e47fac06d8057..54f77c95148ac 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php @@ -93,9 +93,6 @@ public function testGetJsLayout() $processedLayout = ['layout' => ['processed' => true]]; $jsonLayout = '{"layout":{"processed":true}}'; $this->layoutProcessorMock->expects($this->once())->method('process')->with([])->willReturn($processedLayout); - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue(json_encode($processedLayout)) - ); $this->assertEquals($jsonLayout, $this->model->getJsLayout()); } @@ -104,9 +101,6 @@ public function testGetSerializedCheckoutConfig() { $checkoutConfig = ['checkout', 'config']; $this->configProviderMock->expects($this->once())->method('getConfig')->willReturn($checkoutConfig); - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue(json_encode($checkoutConfig)) - ); $this->assertEquals(json_encode($checkoutConfig), $this->model->getSerializedCheckoutConfig()); } diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php b/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php index 8d105f25465e4..04723c5894f8f 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php @@ -1,14 +1,23 @@ objectManager = new ObjectManager($this); - $this->objectManagerMock = $this->basicMock(\Magento\Framework\ObjectManagerInterface::class); - $this->dataMock = $this->basicMock(\Magento\Checkout\Helper\Data::class); - $this->quoteMock = $this->createPartialMock( - \Magento\Quote\Model\Quote::class, + $this->objectManagerMock = $this->basicMock(ObjectManagerInterface::class); + $this->data = $this->basicMock(Data::class); + $this->quote = $this->createPartialMock( + Quote::class, ['getHasError', 'hasItems', 'validateMinimumAmount', 'hasError'] ); $this->contextMock = $this->basicMock(\Magento\Framework\App\Action\Context::class); - $this->sessionMock = $this->basicMock(\Magento\Customer\Model\Session::class); + $this->session = $this->basicMock(Session::class); $this->onepageMock = $this->basicMock(\Magento\Checkout\Model\Type\Onepage::class); $this->layoutMock = $this->basicMock(\Magento\Framework\View\Layout::class); - $this->requestMock = $this->basicMock(\Magento\Framework\App\RequestInterface::class); + $this->request = $this->getMockBuilder(Http::class) + ->disableOriginalConstructor() + ->setMethods(['isSecure', 'getHeader']) + ->getMock(); $this->responseMock = $this->basicMock(\Magento\Framework\App\ResponseInterface::class); $this->redirectMock = $this->basicMock(\Magento\Framework\App\Response\RedirectInterface::class); - $this->resultPageMock = $this->basicMock(\Magento\Framework\View\Result\Page::class); + $this->resultPage = $this->basicMock(Page::class); $this->pageConfigMock = $this->basicMock(\Magento\Framework\View\Page\Config::class); $this->titleMock = $this->basicMock(\Magento\Framework\View\Page\Title::class); $this->url = $this->createMock(\Magento\Framework\UrlInterface::class); @@ -130,7 +142,7 @@ protected function setUp() ->getMock(); $resultPageFactoryMock->expects($this->any()) ->method('create') - ->willReturn($this->resultPageMock); + ->willReturn($this->resultPage); $resultRedirectFactoryMock = $this->getMockBuilder(\Magento\Framework\Controller\Result\RedirectFactory::class) ->disableOriginalConstructor() @@ -141,21 +153,21 @@ protected function setUp() ->willReturn($this->resultRedirectMock); // stubs - $this->basicStub($this->onepageMock, 'getQuote')->willReturn($this->quoteMock); - $this->basicStub($this->resultPageMock, 'getLayout')->willReturn($this->layoutMock); + $this->basicStub($this->onepageMock, 'getQuote')->willReturn($this->quote); + $this->basicStub($this->resultPage, 'getLayout')->willReturn($this->layoutMock); $this->basicStub($this->layoutMock, 'getBlock') ->willReturn($this->basicMock(\Magento\Theme\Block\Html\Header::class)); - $this->basicStub($this->resultPageMock, 'getConfig')->willReturn($this->pageConfigMock); + $this->basicStub($this->resultPage, 'getConfig')->willReturn($this->pageConfigMock); $this->basicStub($this->pageConfigMock, 'getTitle')->willReturn($this->titleMock); $this->basicStub($this->titleMock, 'set')->willReturn($this->titleMock); // objectManagerMock $objectManagerReturns = [ - [\Magento\Checkout\Helper\Data::class, $this->dataMock], + [Data::class, $this->data], [\Magento\Checkout\Model\Type\Onepage::class, $this->onepageMock], [\Magento\Checkout\Model\Session::class, $this->basicMock(\Magento\Checkout\Model\Session::class)], - [\Magento\Customer\Model\Session::class, $this->basicMock(\Magento\Customer\Model\Session::class)], + [Session::class, $this->basicMock(Session::class)], ]; $this->objectManagerMock->expects($this->any()) @@ -165,7 +177,7 @@ protected function setUp() ->willReturn($this->basicMock(\Magento\Framework\UrlInterface::class)); // context stubs $this->basicStub($this->contextMock, 'getObjectManager')->willReturn($this->objectManagerMock); - $this->basicStub($this->contextMock, 'getRequest')->willReturn($this->requestMock); + $this->basicStub($this->contextMock, 'getRequest')->willReturn($this->request); $this->basicStub($this->contextMock, 'getResponse')->willReturn($this->responseMock); $this->basicStub($this->contextMock, 'getMessageManager') ->willReturn($this->basicMock(\Magento\Framework\Message\ManagerInterface::class)); @@ -175,33 +187,82 @@ protected function setUp() // SUT $this->model = $this->objectManager->getObject( - \Magento\Checkout\Controller\Index\Index::class, + Index::class, [ 'context' => $this->contextMock, - 'customerSession' => $this->sessionMock, + 'customerSession' => $this->session, 'resultPageFactory' => $resultPageFactoryMock, 'resultRedirectFactory' => $resultRedirectFactoryMock ] ); } - public function testRegenerateSessionIdOnExecute() + /** + * Checks a case when session should be or not regenerated during the request. + * + * @param bool $secure + * @param string $referer + * @param InvokedCount $expectedCall + * @dataProvider sessionRegenerationDataProvider + */ + public function testRegenerateSessionIdOnExecute(bool $secure, string $referer, InvokedCount $expectedCall) + { + $this->data->method('canOnepageCheckout') + ->willReturn(true); + $this->quote->method('hasItems') + ->willReturn(true); + $this->quote->method('getHasError') + ->willReturn(false); + $this->quote->method('validateMinimumAmount') + ->willReturn(true); + $this->session->method('isLoggedIn') + ->willReturn(true); + $this->request->method('isSecure') + ->willReturn($secure); + $this->request->method('getHeader') + ->with('referer') + ->willReturn($referer); + + $this->session->expects($expectedCall) + ->method('regenerateId'); + $this->assertSame($this->resultPage, $this->model->execute()); + } + + /** + * Gets list of variations for generating new session. + * + * @return array + */ + public function sessionRegenerationDataProvider(): array { - //Stubs to control execution flow - $this->basicStub($this->dataMock, 'canOnepageCheckout')->willReturn(true); - $this->basicStub($this->quoteMock, 'hasItems')->willReturn(true); - $this->basicStub($this->quoteMock, 'getHasError')->willReturn(false); - $this->basicStub($this->quoteMock, 'validateMinimumAmount')->willReturn(true); - $this->basicStub($this->sessionMock, 'isLoggedIn')->willReturn(true); - - //Expected outcomes - $this->sessionMock->expects($this->once())->method('regenerateId'); - $this->assertSame($this->resultPageMock, $this->model->execute()); + return [ + [ + 'secure' => false, + 'referer' => 'https://test.domain.com/', + 'expectedCall' => self::once() + ], + [ + 'secure' => true, + 'referer' => false, + 'expectedCall' => self::once() + ], + [ + 'secure' => true, + 'referer' => 'http://test.domain.com/', + 'expectedCall' => self::once() + ], + // This is the only case in which session regeneration can be skipped + [ + 'secure' => true, + 'referer' => 'https://test.domain.com/', + 'expectedCall' => self::never() + ], + ]; } public function testOnepageCheckoutNotAvailable() { - $this->basicStub($this->dataMock, 'canOnepageCheckout')->willReturn(false); + $this->basicStub($this->data, 'canOnepageCheckout')->willReturn(false); $expectedPath = 'checkout/cart'; $this->resultRedirectMock->expects($this->once()) @@ -214,7 +275,7 @@ public function testOnepageCheckoutNotAvailable() public function testInvalidQuote() { - $this->basicStub($this->quoteMock, 'hasError')->willReturn(true); + $this->basicStub($this->quote, 'hasError')->willReturn(true); $expectedPath = 'checkout/cart'; $this->resultRedirectMock->expects($this->once()) @@ -226,23 +287,22 @@ public function testInvalidQuote() } /** - * @param \PHPUnit_Framework_MockObject_MockObject $mock + * @param MockObject $mock * @param string $method * - * @return \PHPUnit\Framework\MockObject_Builder_InvocationMocker + * @return InvocationMocker */ - private function basicStub($mock, $method) + private function basicStub($mock, $method): InvocationMocker { - return $mock->expects($this->any()) - ->method($method) - ->withAnyParameters(); + return $mock->method($method) + ->withAnyParameters(); } /** * @param string $className - * @return \PHPUnit_Framework_MockObject_MockObject + * @return MockObject */ - private function basicMock($className) + private function basicMock(string $className): MockObject { return $this->getMockBuilder($className) ->disableOriginalConstructor() diff --git a/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php b/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php index 31203b63f854a..d6c740dcec28a 100644 --- a/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Helper/DataTest.php @@ -8,8 +8,7 @@ namespace Magento\Checkout\Test\Unit\Helper; -use \Magento\Checkout\Helper\Data; - +use Magento\Checkout\Helper\Data; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Store\Model\ScopeInterface; @@ -41,23 +40,21 @@ class DataTest extends \PHPUnit\Framework\TestCase /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $_checkoutSession; + private $_checkoutSession; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $_scopeConfig; + private $_scopeConfig; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $_collectionFactory; + private $_eventManager; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @inheritdoc */ - protected $_eventManager; - protected function setUp() { $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -70,59 +67,57 @@ protected function setUp() $this->_scopeConfig = $context->getScopeConfig(); $this->_scopeConfig->expects($this->any()) ->method('getValue') - ->will( - $this->returnValueMap( + ->willReturnMap( + [ + [ + 'checkout/payment_failed/template', + ScopeInterface::SCOPE_STORE, + 8, + 'fixture_email_template_payment_failed', + ], + [ + 'checkout/payment_failed/receiver', + ScopeInterface::SCOPE_STORE, + 8, + 'sysadmin', + ], + [ + 'trans_email/ident_sysadmin/email', + ScopeInterface::SCOPE_STORE, + 8, + 'sysadmin@example.com', + ], + [ + 'trans_email/ident_sysadmin/name', + ScopeInterface::SCOPE_STORE, + 8, + 'System Administrator', + ], + [ + 'checkout/payment_failed/identity', + ScopeInterface::SCOPE_STORE, + 8, + 'noreply@example.com', + ], + [ + 'carriers/ground/title', + ScopeInterface::SCOPE_STORE, + null, + 'Ground Shipping', + ], [ - [ - 'checkout/payment_failed/template', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'fixture_email_template_payment_failed' - ], - [ - 'checkout/payment_failed/receiver', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'sysadmin' - ], - [ - 'trans_email/ident_sysadmin/email', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'sysadmin@example.com' - ], - [ - 'trans_email/ident_sysadmin/name', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'System Administrator' - ], - [ - 'checkout/payment_failed/identity', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - 8, - 'noreply@example.com' - ], - [ - 'carriers/ground/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - null, - 'Ground Shipping' - ], - [ - 'payment/fixture-payment-method/title', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - null, - 'Check Money Order' - ], - [ - 'checkout/options/onepage_checkout_enabled', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - null, - 'One Page Checkout' - ] - ] - ) + 'payment/fixture-payment-method/title', + ScopeInterface::SCOPE_STORE, + null, + 'Check Money Order', + ], + [ + 'checkout/options/onepage_checkout_enabled', + ScopeInterface::SCOPE_STORE, + null, + 'One Page Checkout', + ], + ] ); $this->_checkoutSession = $arguments['checkoutSession']; @@ -139,117 +134,16 @@ protected function setUp() /** * @return void - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testSendPaymentFailedEmail() { - $shippingAddress = new \Magento\Framework\DataObject(['shipping_method' => 'ground_transportation']); - $billingAddress = new \Magento\Framework\DataObject(['street' => 'Fixture St']); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setTemplateOptions' - )->with( - [ - 'area' => \Magento\Backend\App\Area\FrontNameResolver::AREA_CODE, - 'store' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, - ] - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setTemplateIdentifier' - )->with( - 'fixture_email_template_payment_failed' - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setFrom' - )->with( - 'noreply@example.com' - )->will( - $this->returnSelf() - ); + $quoteMock = $this->getMockBuilder(\Magento\Quote\Model\Quote::class) + ->setMethods(['getId']) + ->disableOriginalConstructor() + ->getMock(); + $quoteMock->expects($this->any())->method('getId')->willReturn(1); - $this->_transportBuilder->expects( - $this->once() - )->method( - 'addTo' - )->with( - 'sysadmin@example.com', - 'System Administrator' - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects( - $this->once() - )->method( - 'setTemplateVars' - )->with( - [ - 'reason' => 'test message', - 'checkoutType' => 'onepage', - 'dateAndTime' => 'Oct 02, 2013', - 'customer' => 'John Doe', - 'customerEmail' => 'john.doe@example.com', - 'billingAddress' => $billingAddress, - 'shippingAddress' => $shippingAddress, - 'shippingMethod' => 'Ground Shipping', - 'paymentMethod' => 'Check Money Order', - 'items' => "Product One x 2 USD 10
    \nProduct Two x 3 USD 60
    \n", - 'total' => 'USD 70' - ] - )->will( - $this->returnSelf() - ); - - $this->_transportBuilder->expects($this->once())->method('addBcc')->will($this->returnSelf()); - $this->_transportBuilder->expects( - $this->once() - )->method( - 'getTransport' - )->will( - $this->returnValue($this->createMock(\Magento\Framework\Mail\TransportInterface::class)) - ); - - $this->_translator->expects($this->at(1))->method('suspend'); - $this->_translator->expects($this->at(1))->method('resume'); - - $productOne = $this->createMock(\Magento\Catalog\Model\Product::class); - $productOne->expects($this->once())->method('getName')->will($this->returnValue('Product One')); - $productOne->expects($this->once())->method('getFinalPrice')->with(2)->will($this->returnValue(10)); - - $productTwo = $this->createMock(\Magento\Catalog\Model\Product::class); - $productTwo->expects($this->once())->method('getName')->will($this->returnValue('Product Two')); - $productTwo->expects($this->once())->method('getFinalPrice')->with(3)->will($this->returnValue(60)); - - $quote = new \Magento\Framework\DataObject( - [ - 'store_id' => 8, - 'store_currency_code' => 'USD', - 'grand_total' => 70, - 'customer_firstname' => 'John', - 'customer_lastname' => 'Doe', - 'customer_email' => 'john.doe@example.com', - 'billing_address' => $billingAddress, - 'shipping_address' => $shippingAddress, - 'payment' => new \Magento\Framework\DataObject(['method' => 'fixture-payment-method']), - 'all_visible_items' => [ - new \Magento\Framework\DataObject(['product' => $productOne, 'qty' => 2]), - new \Magento\Framework\DataObject(['product' => $productTwo, 'qty' => 3]) - ] - ] - ); - $this->assertSame($this->_helper, $this->_helper->sendPaymentFailedEmail($quote, 'test message')); + $this->assertSame($this->_helper, $this->_helper->sendPaymentFailedEmail($quoteMock, 'test message')); } /** @@ -343,7 +237,10 @@ public function testGetPriceInclTaxWithoutTax() 'priceCurrency' => $this->priceCurrency, ] ); - $itemMock = $this->createPartialMock(\Magento\Framework\DataObject::class, ['getPriceInclTax', 'getQty', 'getTaxAmount', 'getDiscountTaxCompensation', 'getRowTotal', 'getQtyOrdered']); + $itemMock = $this->createPartialMock( + \Magento\Framework\DataObject::class, + ['getPriceInclTax', 'getQty', 'getTaxAmount', 'getDiscountTaxCompensation', 'getRowTotal', 'getQtyOrdered'] + ); $itemMock->expects($this->once())->method('getPriceInclTax')->will($this->returnValue(false)); $itemMock->expects($this->exactly(2))->method('getQty')->will($this->returnValue($qty)); $itemMock->expects($this->never())->method('getQtyOrdered'); @@ -370,7 +267,10 @@ public function testGetSubtotalInclTaxNegative() $discountTaxCompensation = 1; $rowTotal = 15; $expected = 17; - $itemMock = $this->createPartialMock(\Magento\Framework\DataObject::class, ['getRowTotalInclTax', 'getTaxAmount', 'getDiscountTaxCompensation', 'getRowTotal']); + $itemMock = $this->createPartialMock( + \Magento\Framework\DataObject::class, + ['getRowTotalInclTax', 'getTaxAmount', 'getDiscountTaxCompensation', 'getRowTotal'] + ); $itemMock->expects($this->once())->method('getRowTotalInclTax')->will($this->returnValue(false)); $itemMock->expects($this->once())->method('getTaxAmount')->will($this->returnValue($taxAmount)); $itemMock->expects($this->once()) @@ -416,7 +316,10 @@ public function testGetBasePriceInclTax() public function testGetBaseSubtotalInclTax() { - $itemMock = $this->createPartialMock(\Magento\Framework\DataObject::class, ['getBaseTaxAmount', 'getBaseDiscountTaxCompensation', 'getBaseRowTotal']); + $itemMock = $this->createPartialMock( + \Magento\Framework\DataObject::class, + ['getBaseTaxAmount', 'getBaseDiscountTaxCompensation', 'getBaseRowTotal'] + ); $itemMock->expects($this->once())->method('getBaseTaxAmount'); $itemMock->expects($this->once())->method('getBaseDiscountTaxCompensation'); $itemMock->expects($this->once())->method('getBaseRowTotal'); diff --git a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php index a33649551bdcb..2d313d2f50052 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php @@ -5,6 +5,12 @@ */ namespace Magento\Checkout\Test\Unit\Model; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Address; +use Magento\Quote\Model\QuoteIdMask; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -45,6 +51,11 @@ class GuestPaymentInformationManagementTest extends \PHPUnit\Framework\TestCase */ private $loggerMock; + /** + * @var ResourceConnection|\PHPUnit_Framework_MockObject_MockObject + */ + private $resourceConnectionMock; + protected function setUp() { $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -62,6 +73,10 @@ protected function setUp() ['create'] ); $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + $this->resourceConnectionMock = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->model = $objectManager->getObject( \Magento\Checkout\Model\GuestPaymentInformationManagement::class, [ @@ -69,7 +84,8 @@ protected function setUp() 'paymentMethodManagement' => $this->paymentMethodManagementMock, 'cartManagement' => $this->cartManagementMock, 'cartRepository' => $this->cartRepositoryMock, - 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock + 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock, + 'connectionPull' => $this->resourceConnectionMock, ] ); $objectManager->setBackwardCompatibleProperty($this->model, 'logger', $this->loggerMock); @@ -82,12 +98,30 @@ public function testSavePaymentInformationAndPlaceOrder() $email = 'email@magento.com'; $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('commit'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('commit'); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); @@ -108,11 +142,30 @@ public function testSavePaymentInformationAndPlaceOrderException() $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('rollback'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('rollback'); + $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); $exception = new \Exception(__('DB exception')); $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); @@ -126,11 +179,9 @@ public function testSavePaymentInformation() $email = 'email@magento.com'; $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); $this->assertTrue($this->model->savePaymentInformation($cartId, $email, $paymentMock, $billingAddressMock)); @@ -142,13 +193,13 @@ public function testSavePaymentInformationWithoutBillingAddress() $email = 'email@magento.com'; $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); - $quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); + $quoteMock = $this->createMock(Quote::class); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); $this->billingAddressManagementMock->expects($this->never())->method('assign'); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $quoteIdMaskMock = $this->createPartialMock(\Magento\Quote\Model\QuoteIdMask::class, ['getQuoteId', 'load']); + $quoteIdMaskMock = $this->createPartialMock(QuoteIdMask::class, ['getQuoteId', 'load']); $this->quoteIdMaskFactoryMock->expects($this->once())->method('create')->willReturn($quoteIdMaskMock); $quoteIdMaskMock->expects($this->once())->method('load')->with($cartId, 'masked_id')->willReturnSelf(); $quoteIdMaskMock->expects($this->once())->method('getQuoteId')->willReturn($cartId); @@ -169,11 +220,38 @@ public function testSavePaymentInformationAndPlaceOrderWithLocolizedException() $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $quoteMock = $this->createMock(Quote::class); + $quoteMock->method('getBillingAddress')->willReturn($billingAddressMock); + $this->cartRepositoryMock->method('getActive')->with($cartId)->willReturn($quoteMock); + + $quoteIdMask = $this->createPartialMock(QuoteIdMask::class, ['getQuoteId', 'load']); + $this->quoteIdMaskFactoryMock->method('create')->willReturn($quoteIdMask); + $quoteIdMask->method('load')->with($cartId, 'masked_id')->willReturnSelf(); + $quoteIdMask->method('getQuoteId')->willReturn($cartId); + $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - $this->billingAddressManagementMock->expects($this->once()) - ->method('assign') - ->with($cartId, $billingAddressMock); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('rollback'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('rollback'); + $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); $phrase = new \Magento\Framework\Phrase(__('DB exception')); $exception = new \Magento\Framework\Exception\LocalizedException($phrase); @@ -182,4 +260,55 @@ public function testSavePaymentInformationAndPlaceOrderWithLocolizedException() $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock); } + + /** + * @param int $cartId + * @param \PHPUnit_Framework_MockObject_MockObject $billingAddressMock + * @return void + */ + private function getMockForAssignBillingAddress($cartId, $billingAddressMock) + { + $quoteIdMask = $this->createPartialMock(QuoteIdMask::class, ['getQuoteId', 'load']); + $this->quoteIdMaskFactoryMock->method('create') + ->willReturn($quoteIdMask); + $quoteIdMask->method('load') + ->with($cartId, 'masked_id') + ->willReturnSelf(); + $quoteIdMask->method('getQuoteId') + ->willReturn($cartId); + + $billingAddressId = 1; + $quote = $this->createMock(Quote::class); + $quoteBillingAddress = $this->createMock(Address::class); + $quoteShippingAddress = $this->createPartialMock( + Address::class, + ['setLimitCarrier', 'getShippingMethod'] + ); + $this->cartRepositoryMock->method('getActive') + ->with($cartId) + ->willReturn($quote); + $quote->expects($this->once()) + ->method('getBillingAddress') + ->willReturn($quoteBillingAddress); + $quote->expects($this->once()) + ->method('getShippingAddress') + ->willReturn($quoteShippingAddress); + $quoteBillingAddress->expects($this->once()) + ->method('getId') + ->willReturn($billingAddressId); + $quote->expects($this->once()) + ->method('removeAddress') + ->with($billingAddressId); + $quote->expects($this->once()) + ->method('setBillingAddress') + ->with($billingAddressMock); + $quote->expects($this->once()) + ->method('setDataChanges') + ->willReturnSelf(); + $quoteShippingAddress->method('getShippingMethod') + ->willReturn('flatrate_flatrate'); + $quoteShippingAddress->expects($this->once()) + ->method('setLimitCarrier') + ->with('flatrate'); + } } diff --git a/app/code/Magento/Checkout/composer.json b/app/code/Magento/Checkout/composer.json index 7f6b1dcaec01e..f53d1b8d839ea 100644 --- a/app/code/Magento/Checkout/composer.json +++ b/app/code/Magento/Checkout/composer.json @@ -2,10 +2,9 @@ "name": "magento/module-checkout", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-sales": "101.0.*", - "magento/module-backend": "100.2.*", "magento/module-catalog-inventory": "100.2.*", "magento/module-config": "101.0.*", "magento/module-customer": "101.0.*", @@ -27,7 +26,7 @@ "magento/module-cookie": "100.2.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Checkout/etc/adminhtml/routes.xml b/app/code/Magento/Checkout/etc/adminhtml/routes.xml new file mode 100644 index 0000000000000..e537861059870 --- /dev/null +++ b/app/code/Magento/Checkout/etc/adminhtml/routes.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/Checkout/etc/frontend/di.xml b/app/code/Magento/Checkout/etc/frontend/di.xml index 889689e6c0d16..940b60e796c9d 100644 --- a/app/code/Magento/Checkout/etc/frontend/di.xml +++ b/app/code/Magento/Checkout/etc/frontend/di.xml @@ -83,4 +83,16 @@ + + + + + \Magento\Framework\View\Element\Message\Renderer\BlockRenderer::CODE + + Magento_Checkout::messages/addCartSuccessMessage.phtml + + + + + diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml index b224c96f07e9b..1d67b325e01c5 100644 --- a/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml +++ b/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml @@ -24,7 +24,7 @@
    - + getCouponCode())): ?> disabled="disabled" />
    diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/item/configure/updatecart.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/item/configure/updatecart.phtml index b41d548e95b99..c1db2f7775ca8 100644 --- a/app/code/Magento/Checkout/view/frontend/templates/cart/item/configure/updatecart.phtml +++ b/app/code/Magento/Checkout/view/frontend/templates/cart/item/configure/updatecart.phtml @@ -23,7 +23,7 @@ value="" title="" class="input-text qty" - data-validate="{'required-number':true,digits:true}"/> + data-validate="escapeHtml(json_encode($block->getQuantityValidators())) ?>"/>
    diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml index 02c969f849074..0567c61f0db60 100644 --- a/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml +++ b/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml @@ -90,7 +90,7 @@ $canApplyMsrp = $helper->isShowBeforeOrderConfirm($product) && $helper->isMinima
    getIsNeedToDisplaySideBar()): ?> -
    shopping cart.', + $block->getData('product_name'), + $block->getData('cart_url') +), ['a']); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/address-converter.js b/app/code/Magento/Checkout/view/frontend/web/js/model/address-converter.js index 6e689091d091f..a1aacf6e80320 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/address-converter.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/address-converter.js @@ -72,20 +72,20 @@ define([ output = {}, streetObject; + $.each(addrs, function (key) { + if (addrs.hasOwnProperty(key) && !$.isFunction(addrs[key])) { + output[self.toUnderscore(key)] = addrs[key]; + } + }); + if ($.isArray(addrs.street)) { streetObject = {}; addrs.street.forEach(function (value, index) { streetObject[index] = value; }); - addrs.street = streetObject; + output.street = streetObject; } - $.each(addrs, function (key) { - if (addrs.hasOwnProperty(key) && !$.isFunction(addrs[key])) { - output[self.toUnderscore(key)] = addrs[key]; - } - }); - return output; }, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/new-customer-address.js b/app/code/Magento/Checkout/view/frontend/web/js/model/new-customer-address.js index 3bc5911946fda..f9dae2691e5f3 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/new-customer-address.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/new-customer-address.js @@ -17,18 +17,26 @@ define([ */ return function (addressData) { var identifier = Date.now(), + countryId, regionId; - if (addressData.region && addressData.region['region_id']) { - regionId = addressData.region['region_id']; - } else if (addressData['country_id'] && addressData['country_id'] == window.checkoutConfig.defaultCountryId) { //eslint-disable-line - regionId = window.checkoutConfig.defaultRegionId || undefined; + countryId = addressData['country_id'] || addressData.countryId; + + if (countryId) { + if (addressData.region && addressData.region['region_id']) { + regionId = addressData.region['region_id']; + } else if (countryId === window.checkoutConfig.defaultCountryId) { + regionId = window.checkoutConfig.defaultRegionId; + } + } else { + countryId = window.checkoutConfig.defaultCountryId; + regionId = window.checkoutConfig.defaultRegionId; } return { email: addressData.email, - countryId: addressData['country_id'] || addressData.countryId || window.checkoutConfig.defaultCountryId, - regionId: regionId || addressData.regionId, + countryId: countryId, + regionId: regionId, regionCode: addressData.region ? addressData.region['region_code'] : null, region: addressData.region ? addressData.region.region : null, customerId: addressData['customer_id'] || addressData.customerId, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-save-processor/payload-extender.js b/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-save-processor/payload-extender.js index 9a082a056a382..dcd4340774af4 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-save-processor/payload-extender.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-save-processor/payload-extender.js @@ -1,5 +1,5 @@ /** - * Copyright © 2013-2017 Magento, Inc. All rights reserved. + * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ define(function () { diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/step-navigator.js b/app/code/Magento/Checkout/view/frontend/web/js/model/step-navigator.js index 2341748bc4e4a..64e751d0d56d0 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/step-navigator.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/step-navigator.js @@ -182,6 +182,15 @@ define([ }); }, + /** + * Sets window location hash. + * + * @param {String} hash + */ + setHash: function (hash) { + window.location.hash = hash; + }, + /** * Next step. */ @@ -199,7 +208,7 @@ define([ if (steps().length > activeIndex + 1) { code = steps()[activeIndex + 1].code; steps()[activeIndex + 1].isVisible(true); - window.location = window.checkoutConfig.checkoutUrl + '#' + code; + this.setHash(code); document.body.scrollTop = document.documentElement.scrollTop = 0; } } diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/totals.js b/app/code/Magento/Checkout/view/frontend/web/js/model/totals.js index aba0c31b998d1..a0bdf0a17d7fe 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/totals.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/totals.js @@ -14,17 +14,20 @@ define([ 'use strict'; var quoteItems = ko.observable(quote.totals().items), - cartData = customerData.get('cart'), - quoteSubtotal = parseFloat(quote.totals().subtotal), - subtotalAmount = parseFloat(cartData().subtotalAmount); + cartData = customerData.get('cart'); quote.totals.subscribe(function (newValue) { quoteItems(newValue.items); }); - if (quoteSubtotal !== subtotalAmount) { - customerData.reload(['cart'], false); - } + cartData.subscribe(function () { + var quoteSubtotal = parseFloat(quote.totals().subtotal), + subtotalAmount = parseFloat(cartData().subtotalAmount); + + if (quoteSubtotal !== subtotalAmount) { + customerData.reload(['cart'], false); + } + }, this); return { totals: quote.totals, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/region-updater.js b/app/code/Magento/Checkout/view/frontend/web/js/region-updater.js index 79050ca087740..dacebd75c3c68 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/region-updater.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/region-updater.js @@ -195,6 +195,8 @@ define([ regionInput.hide(); label.attr('for', regionList.attr('id')); } else { + this._removeSelectOptions(regionList); + if (this.options.isRegionRequired) { regionInput.addClass('required-entry').removeAttr('disabled'); requiredLabel.addClass('required'); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js b/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js index 13a2b524e5186..dab40f026645d 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js @@ -105,6 +105,13 @@ define([ self._showItemButton($(event.target)); }; + /** + * @param {jQuery.Event} event + */ + events['change ' + this.options.item.qty] = function (event) { + self._showItemButton($(event.target)); + }; + /** * @param {jQuery.Event} event */ diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/authentication.js b/app/code/Magento/Checkout/view/frontend/web/js/view/authentication.js index e7480b25aa791..796b502cab54d 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/authentication.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/authentication.js @@ -18,7 +18,6 @@ define([ return Component.extend({ isGuestCheckoutAllowed: checkoutConfig.isGuestCheckoutAllowed, - isCustomerLoginRequired: checkoutConfig.isCustomerLoginRequired, registerUrl: checkoutConfig.registerUrl, forgotPasswordUrl: checkoutConfig.forgotPasswordUrl, autocomplete: checkoutConfig.autocomplete, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js b/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js index a612b5e2dc6b7..9af5201c267e8 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/configure/product-customer-data.js @@ -1,15 +1,18 @@ require([ 'jquery', - 'Magento_Customer/js/customer-data' + 'Magento_Customer/js/customer-data', + 'domReady!' ], function ($, customerData) { 'use strict'; var selectors = { qtySelector: '#product_addtocart_form [name="qty"]', - productIdSelector: '#product_addtocart_form [name="product"]' + productIdSelector: '#product_addtocart_form [name="product"]', + itemIdSelector: '#product_addtocart_form [name="item"]' }, cartData = customerData.get('cart'), productId = $(selectors.productIdSelector).val(), + itemId = $(selectors.itemIdSelector).val(), productQty, productQtyInput, @@ -39,8 +42,10 @@ require([ return; } product = data.items.find(function (item) { - return item['product_id'] === productId || - item['item_id'] === productId; + if (item['item_id'] === itemId) { + return item['product_id'] === productId || + item['item_id'] === productId; + } }); if (!product) { diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/minicart.js b/app/code/Magento/Checkout/view/frontend/web/js/view/minicart.js index 5ac062a12180c..33223e9ae3c24 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 @@ -10,7 +10,8 @@ define([ 'ko', 'underscore', 'sidebar', - 'mage/translate' + 'mage/translate', + 'mage/dropdown' ], function (Component, customerData, $, ko, _) { 'use strict'; @@ -93,6 +94,10 @@ define([ this.isLoading(addToCartCalls > 0); sidebarInitialized = false; this.update(updatedCart); + + if (cartData()['website_id'] !== window.checkout.websiteId) { + customerData.reload(['cart'], false); + } initSidebar(); }, this); $('[data-block="minicart"]').on('contentLoading', function () { @@ -100,10 +105,6 @@ define([ self.isLoading(true); }); - if (cartData()['website_id'] !== window.checkout.websiteId) { - customerData.reload(['cart'], false); - } - return this._super(); }, isLoading: ko.observable(false), diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js b/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js index 0cbc16ef72bc3..e4b1e464348b9 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js @@ -26,6 +26,11 @@ define([ initialize: function () { this._super(); $(window).hashchange(_.bind(stepNavigator.handleHash, stepNavigator)); + + if (!window.location.hash) { + stepNavigator.setHash(stepNavigator.steps().sort(stepNavigator.sortItems)[0].code); + } + stepNavigator.handleHash(); }, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js b/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js index c715b5c4d45ce..a7b3e18c06088 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js @@ -38,7 +38,16 @@ define([ }, /** - * Create new user account + * @return String + */ + getUrl: function () { + return this.registrationUrl; + }, + + /** + * Create new user account. + * + * @deprecated */ createAccount: function () { this.creationStarted(true); diff --git a/app/code/Magento/Checkout/view/frontend/web/template/billing-address/details.html b/app/code/Magento/Checkout/view/frontend/web/template/billing-address/details.html index f2baf5d50030e..fd994a4e8a955 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/billing-address/details.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/billing-address/details.html @@ -7,7 +7,7 @@

    -
    +
    ,

    diff --git a/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html b/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html index 8d32adb75308f..357b0e550af0f 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html @@ -24,7 +24,7 @@
    - + diff --git a/app/code/Magento/Checkout/view/frontend/web/template/registration.html b/app/code/Magento/Checkout/view/frontend/web/template/registration.html index 256fc1968abfc..ea94726e5443e 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/registration.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/registration.html @@ -11,11 +11,8 @@

    :

    - - + + - - -

    - +
    diff --git a/app/code/Magento/Checkout/view/frontend/web/template/shipping-information/address-renderer/default.html b/app/code/Magento/Checkout/view/frontend/web/template/shipping-information/address-renderer/default.html index ec41cae0bdc5e..b66526f660af7 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/shipping-information/address-renderer/default.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/shipping-information/address-renderer/default.html @@ -7,7 +7,7 @@
    -
    +
    ,

    diff --git a/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php b/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php index ed9ecc642e16b..4a35a58a41ff9 100644 --- a/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php +++ b/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php @@ -5,27 +5,42 @@ */ namespace Magento\CheckoutAgreements\Block\Adminhtml\Agreement; +use Magento\Framework\App\ObjectManager; +use Magento\CheckoutAgreements\Model\ResourceModel\Agreement\Grid\CollectionFactory as GridCollectionFactory; + class Grid extends \Magento\Backend\Block\Widget\Grid\Extended { /** * @var \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory + * @deprecated */ protected $_collectionFactory; + /** + * @param GridCollectionFactory + */ + private $gridCollectionFactory; + /** * @param \Magento\Backend\Block\Template\Context $context * @param \Magento\Backend\Helper\Data $backendHelper * @param \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory $collectionFactory * @param array $data + * @param GridCollectionFactory $gridColFactory * @codeCoverageIgnore */ public function __construct( \Magento\Backend\Block\Template\Context $context, \Magento\Backend\Helper\Data $backendHelper, \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory $collectionFactory, - array $data = [] + array $data = [], + GridCollectionFactory $gridColFactory = null ) { + $this->_collectionFactory = $collectionFactory; + $this->gridCollectionFactory = $gridColFactory + ? : ObjectManager::getInstance()->get(GridCollectionFactory::class); + parent::__construct($context, $backendHelper, $data); } @@ -47,7 +62,7 @@ protected function _construct() */ protected function _prepareCollection() { - $this->setCollection($this->_collectionFactory->create()); + $this->setCollection($this->gridCollectionFactory->create()); return parent::_prepareCollection(); } diff --git a/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php b/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php new file mode 100644 index 0000000000000..fe600d64a7c48 --- /dev/null +++ b/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php @@ -0,0 +1,77 @@ +isLoaded()) { + return $this; + } + + parent::load($printQuery, $logQuery); + + $this->addStoresToResult(); + + return $this; + } + + /** + * @return void + */ + private function addStoresToResult() + { + $stores = $this->getStoresForAgreements(); + + if (!empty($stores)) { + $storesByAgreementId = []; + + foreach ($stores as $storeData) { + $storesByAgreementId[$storeData['agreement_id']][] = $storeData['store_id']; + } + + foreach ($this as $item) { + $agreementId = $item->getData('agreement_id'); + + if (!isset($storesByAgreementId[$agreementId])) { + continue; + } + + $item->setData('stores', $storesByAgreementId[$agreementId]); + } + } + } + + /** + * @return array + */ + private function getStoresForAgreements() + { + $agreementId = $this->getColumnValues('agreement_id'); + + if (!empty($agreementId)) { + $select = $this->getConnection()->select()->from( + ['agreement_store' => 'checkout_agreement_store'] + )->where( + 'agreement_store.agreement_id IN (?)', + $agreementId + ); + + return $this->getConnection()->fetchAll($select); + } + + return []; + } +} diff --git a/app/code/Magento/CheckoutAgreements/composer.json b/app/code/Magento/CheckoutAgreements/composer.json index b8fa41ea7c724..b0c354cd230a8 100644 --- a/app/code/Magento/CheckoutAgreements/composer.json +++ b/app/code/Magento/CheckoutAgreements/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-checkout-agreements", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-checkout": "100.2.*", "magento/module-quote": "101.0.*", "magento/module-store": "100.2.*", diff --git a/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml b/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml index 1249ea44b991e..5a708f49a7034 100644 --- a/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml +++ b/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml @@ -7,7 +7,7 @@ --> - + diff --git a/app/code/Magento/Cms/Api/GetUtilityPageIdentifiersInterface.php b/app/code/Magento/Cms/Api/GetUtilityPageIdentifiersInterface.php new file mode 100644 index 0000000000000..c6bf4c8404701 --- /dev/null +++ b/app/code/Magento/Cms/Api/GetUtilityPageIdentifiersInterface.php @@ -0,0 +1,20 @@ +dataPersistor = $dataPersistor; + $this->blockFactory = $blockFactory + ?: \Magento\Framework\App\ObjectManager::getInstance()->get(BlockFactory::class); + $this->blockRepository = $blockRepository + ?: \Magento\Framework\App\ObjectManager::getInstance()->get(BlockRepositoryInterface::class); parent::__construct($context, $coreRegistry); } @@ -45,8 +65,6 @@ public function execute() $resultRedirect = $this->resultRedirectFactory->create(); $data = $this->getRequest()->getPostValue(); if ($data) { - $id = $this->getRequest()->getParam('block_id'); - if (isset($data['is_active']) && $data['is_active'] === 'true') { $data['is_active'] = Block::STATUS_ENABLED; } @@ -55,27 +73,32 @@ public function execute() } /** @var \Magento\Cms\Model\Block $model */ - $model = $this->_objectManager->create(\Magento\Cms\Model\Block::class)->load($id); - if (!$model->getId() && $id) { - $this->messageManager->addError(__('This block no longer exists.')); - return $resultRedirect->setPath('*/*/'); + $model = $this->blockFactory->create(); + + $id = $this->getRequest()->getParam('block_id'); + if ($id) { + try { + $model = $this->blockRepository->getById($id); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage(__('This block no longer exists.')); + return $resultRedirect->setPath('*/*/'); + } } $model->setData($data); try { - $model->save(); - $this->messageManager->addSuccess(__('You saved the block.')); + $this->blockRepository->save($model); + $this->messageManager->addSuccessMessage(__('You saved the block.')); $this->dataPersistor->clear('cms_block'); - if ($this->getRequest()->getParam('back')) { return $resultRedirect->setPath('*/*/edit', ['block_id' => $model->getId()]); } return $resultRedirect->setPath('*/*/'); } catch (LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addException($e, __('Something went wrong while saving the block.')); + $this->messageManager->addExceptionMessage($e, __('Something went wrong while saving the block.')); } $this->dataPersistor->set('cms_block', $data); diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php b/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php index 5644e25dd4c4a..1364e61816796 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php @@ -46,7 +46,6 @@ class Save extends \Magento\Backend\App\Action * @param DataPersistorInterface $dataPersistor * @param \Magento\Cms\Model\PageFactory $pageFactory * @param \Magento\Cms\Api\PageRepositoryInterface $pageRepository - * */ public function __construct( Action\Context $context, @@ -90,11 +89,10 @@ public function execute() $id = $this->getRequest()->getParam('page_id'); if ($id) { - $model->load($id); - if (!$model->getId()) { + try { + $model = $this->pageRepository->getById($id); + } catch (LocalizedException $e) { $this->messageManager->addErrorMessage(__('This page no longer exists.')); - /** \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ - $resultRedirect = $this->resultRedirectFactory->create(); return $resultRedirect->setPath('*/*/'); } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php index 19dc989620b89..890c9bf5eae52 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php @@ -7,6 +7,9 @@ use Magento\Framework\App\Filesystem\DirectoryList; +/** + * Delete image files. + */ class DeleteFiles extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -19,6 +22,11 @@ class DeleteFiles extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultRawFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * Constructor * @@ -26,22 +34,28 @@ class DeleteFiles extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory * @param \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, - \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + \Magento\Framework\Controller\Result\RawFactory $resultRawFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { + parent::__construct($context, $coreRegistry); + $this->resultRawFactory = $resultRawFactory; $this->resultJsonFactory = $resultJsonFactory; - parent::__construct($context, $coreRegistry); + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * Delete file from media storage + * Delete file from media storage. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { @@ -54,6 +68,11 @@ public function execute() /** @var $helper \Magento\Cms\Helper\Wysiwyg\Images */ $helper = $this->_objectManager->get(\Magento\Cms\Helper\Wysiwyg\Images::class); $path = $this->getStorage()->getSession()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } foreach ($files as $file) { $file = $helper->idDecode($file); /** @var \Magento\Framework\Filesystem $filesystem */ @@ -64,11 +83,13 @@ public function execute() $this->getStorage()->deleteFile($filePath); } } + return $this->resultRawFactory->create(); } catch (\Exception $e) { $result = ['error' => true, 'message' => $e->getMessage()]; /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php index 8a89de87a6f85..a1de11c3c462e 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php @@ -6,6 +6,11 @@ */ namespace Magento\Cms\Controller\Adminhtml\Wysiwyg\Images; +use Magento\Framework\App\Filesystem\DirectoryList; + +/** + * Delete image folder. + */ class DeleteFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -18,38 +23,55 @@ class DeleteFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultRawFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory * @param \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, - \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + \Magento\Framework\Controller\Result\RawFactory $resultRawFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { + parent::__construct($context, $coreRegistry); $this->resultRawFactory = $resultRawFactory; $this->resultJsonFactory = $resultJsonFactory; - parent::__construct($context, $coreRegistry); + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * Delete folder action + * Delete folder action. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { try { $path = $this->getStorage()->getCmsWysiwygImages()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } $this->getStorage()->deleteDirectory($path); + return $this->resultRawFactory->create(); } catch (\Exception $e) { $result = ['error' => true, 'message' => $e->getMessage()]; /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php index 2124bdabe6009..7816e29405f27 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php @@ -6,6 +6,11 @@ */ namespace Magento\Cms\Controller\Adminhtml\Wysiwyg\Images; +use Magento\Framework\App\Filesystem\DirectoryList; + +/** + * Creates new folder. + */ class NewFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -13,24 +18,35 @@ class NewFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultJsonFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver + * @throws \RuntimeException */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, - \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { - $this->resultJsonFactory = $resultJsonFactory; parent::__construct($context, $coreRegistry); + $this->resultJsonFactory = $resultJsonFactory; + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * New folder action + * New folder action. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { @@ -38,12 +54,18 @@ public function execute() $this->_initAction(); $name = $this->getRequest()->getPost('name'); $path = $this->getStorage()->getSession()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } $result = $this->getStorage()->createDirectory($name, $path); } catch (\Exception $e) { $result = ['error' => true, 'message' => $e->getMessage()]; } /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/OnInsert.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/OnInsert.php index 2daaf39d58d14..dda3940cd9ba5 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/OnInsert.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/OnInsert.php @@ -35,7 +35,7 @@ public function __construct( public function execute() { $helper = $this->_objectManager->get(\Magento\Cms\Helper\Wysiwyg\Images::class); - $storeId = $this->getRequest()->getParam('store'); + $storeId = (int)$this->getRequest()->getParam('store'); $filename = $this->getRequest()->getParam('filename'); $filename = $helper->idDecode($filename); diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php index 7a94c4ab6aa12..5c9aa2243bc6d 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php @@ -6,6 +6,11 @@ */ namespace Magento\Cms\Controller\Adminhtml\Wysiwyg\Images; +use Magento\Framework\App\Filesystem\DirectoryList; + +/** + * Upload image. + */ class Upload extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -13,36 +18,52 @@ class Upload extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultJsonFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, - \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { - $this->resultJsonFactory = $resultJsonFactory; parent::__construct($context, $coreRegistry); + $this->resultJsonFactory = $resultJsonFactory; + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * Files upload processing + * Files upload processing. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { try { $this->_initAction(); - $targetPath = $this->getStorage()->getSession()->getCurrentPath(); - $result = $this->getStorage()->uploadFile($targetPath, $this->getRequest()->getParam('type')); + $path = $this->getStorage()->getSession()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } + $result = $this->getStorage()->uploadFile($path, $this->getRequest()->getParam('type')); } catch (\Exception $e) { $result = ['error' => $e->getMessage(), 'errorcode' => $e->getCode()]; } /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Helper/Wysiwyg/Images.php b/app/code/Magento/Cms/Helper/Wysiwyg/Images.php index d8a43d92f6d44..18c7d14cc8fcf 100644 --- a/app/code/Magento/Cms/Helper/Wysiwyg/Images.php +++ b/app/code/Magento/Cms/Helper/Wysiwyg/Images.php @@ -8,7 +8,9 @@ use Magento\Framework\App\Filesystem\DirectoryList; /** - * Wysiwyg Images Helper + * Wysiwyg Images Helper. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Images extends \Magento\Framework\App\Helper\AbstractHelper { @@ -127,17 +129,23 @@ public function convertPathToId($path) } /** - * Decode HTML element id + * Decode HTML element id. * * @param string $id * @return string + * @throws \InvalidArgumentException When path contains restricted symbols. */ public function convertIdToPath($id) { if ($id === \Magento\Theme\Helper\Storage::NODE_ROOT) { return $this->getStorageRoot(); } else { - return $this->getStorageRoot() . $this->idDecode($id); + $path = $this->getStorageRoot() . $this->idDecode($id); + if (strpos($path, DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR) !== false) { + throw new \InvalidArgumentException('Path is invalid'); + } + + return $path; } } diff --git a/app/code/Magento/Cms/Model/GetUtilityPageIdentifiers.php b/app/code/Magento/Cms/Model/GetUtilityPageIdentifiers.php new file mode 100644 index 0000000000000..09c68ee9cf82d --- /dev/null +++ b/app/code/Magento/Cms/Model/GetUtilityPageIdentifiers.php @@ -0,0 +1,54 @@ +scopeConfig = $scopeConfig; + } + + /** + * Get List Page Identifiers + * @return array + */ + public function execute() + { + $homePageIdentifier = $this->scopeConfig->getValue( + 'web/default/cms_home_page', + ScopeInterface::SCOPE_STORE + ); + $noRouteIdentifier = $this->scopeConfig->getValue( + 'web/default/cms_no_route', + ScopeInterface::SCOPE_STORE + ); + + $noCookieIdentifier = $this->scopeConfig->getValue( + 'web/default/cms_no_cookies', + ScopeInterface::SCOPE_STORE + ); + + return [$homePageIdentifier, $noRouteIdentifier, $noCookieIdentifier]; + } +} diff --git a/app/code/Magento/Cms/Model/PageRepository.php b/app/code/Magento/Cms/Model/PageRepository.php index 9c9e18211aa86..d6784df48defd 100644 --- a/app/code/Magento/Cms/Model/PageRepository.php +++ b/app/code/Magento/Cms/Model/PageRepository.php @@ -110,7 +110,7 @@ public function __construct( */ public function save(\Magento\Cms\Api\Data\PageInterface $page) { - if (empty($page->getStoreId())) { + if ($page->getStoreId() === null) { $storeId = $this->storeManager->getStore()->getId(); $page->setStoreId($storeId); } diff --git a/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php b/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php index f45d9ee223106..60e87afc61884 100644 --- a/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php +++ b/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php @@ -6,7 +6,7 @@ namespace Magento\Cms\Model\ResourceModel\Block\Grid; use Magento\Framework\Api\Search\SearchResultInterface; -use Magento\Framework\Search\AggregationInterface; +use Magento\Framework\Api\Search\AggregationInterface; use Magento\Cms\Model\ResourceModel\Block\Collection as BlockCollection; /** @@ -82,6 +82,7 @@ public function getAggregations() public function setAggregations($aggregations) { $this->aggregations = $aggregations; + return $this; } /** diff --git a/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php b/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php index c5c43c3120dcc..19f945e5b4637 100644 --- a/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php +++ b/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php @@ -83,6 +83,7 @@ public function getAggregations() public function setAggregations($aggregations) { $this->aggregations = $aggregations; + return $this; } /** diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php index 0688534dc4ad9..6d8c4ecd71127 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php @@ -735,7 +735,7 @@ protected function _validatePath($path) */ protected function _sanitizePath($path) { - return rtrim(preg_replace('~[/\\\]+~', '/', $this->_directory->getDriver()->getRealPath($path)), '/'); + return rtrim(preg_replace('~[/\\\]+~', '/', $this->_directory->getDriver()->getRealPathSafety($path)), '/'); } /** diff --git a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php index f6b709a5c96c9..40ed379e9d7e2 100644 --- a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php +++ b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php @@ -65,6 +65,16 @@ class SaveTest extends \PHPUnit\Framework\TestCase */ protected $saveController; + /** + * @var \Magento\Cms\Model\BlockFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $blockFactory; + + /** + * @var \Magento\Cms\Api\BlockRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $blockRepository; + /** * @var int */ @@ -129,11 +139,22 @@ protected function setUp() ->method('getResultRedirectFactory') ->willReturn($this->resultRedirectFactory); + $this->blockFactory = $this->getMockBuilder(\Magento\Cms\Model\BlockFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + + $this->blockRepository = $this->getMockBuilder(\Magento\Cms\Api\BlockRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->saveController = $this->objectManager->getObject( \Magento\Cms\Controller\Adminhtml\Block\Save::class, [ 'context' => $this->contextMock, 'dataPersistor' => $this->dataPersistorMock, + 'blockFactory' => $this->blockFactory, + 'blockRepository' => $this->blockRepository, ] ); } @@ -158,26 +179,24 @@ public function testSaveAction() ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->atLeastOnce()) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $this->blockMock->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willReturn($this->blockMock); + $this->blockMock->expects($this->once())->method('setData'); - $this->blockMock->expects($this->once())->method('save'); + $this->blockRepository->expects($this->once())->method('save')->with($this->blockMock); $this->dataPersistorMock->expects($this->any()) ->method('clear') ->with('cms_block'); $this->messageManagerMock->expects($this->once()) - ->method('addSuccess') + ->method('addSuccessMessage') ->with(__('You saved the block.')); $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/') ->willReturnSelf(); @@ -204,20 +223,17 @@ public function testSaveActionNoId() ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->atLeastOnce()) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $this->blockMock->expects($this->any()) - ->method('getId') - ->willReturn(false); + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Error message'))); $this->messageManagerMock->expects($this->once()) - ->method('addError') + ->method('addErrorMessage') ->with(__('This block no longer exists.')); $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/') ->willReturnSelf(); @@ -237,22 +253,20 @@ public function testSaveAndContinue() ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->atLeastOnce()) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $this->blockMock->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willReturn($this->blockMock); + $this->blockMock->expects($this->once())->method('setData'); - $this->blockMock->expects($this->once())->method('save'); + $this->blockRepository->expects($this->once())->method('save')->with($this->blockMock); $this->messageManagerMock->expects($this->once()) - ->method('addSuccess') + ->method('addSuccessMessage') ->with(__('You saved the block.')); $this->dataPersistorMock->expects($this->any()) @@ -279,24 +293,24 @@ public function testSaveActionThrowsException() ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->atLeastOnce()) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $this->blockMock->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willReturn($this->blockMock); + $this->blockMock->expects($this->once())->method('setData'); - $this->blockMock->expects($this->once())->method('save')->willThrowException(new \Exception('Error message.')); + $this->blockRepository->expects($this->once())->method('save') + ->with($this->blockMock) + ->willThrowException(new \Exception('Error message.')); $this->messageManagerMock->expects($this->never()) - ->method('addSuccess'); + ->method('addSuccessMessage'); $this->messageManagerMock->expects($this->once()) - ->method('addException'); + ->method('addExceptionMessage'); $this->dataPersistorMock->expects($this->any()) ->method('set') diff --git a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php index 03a8fc0969064..a98dd392f6263 100644 --- a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php +++ b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php @@ -153,12 +153,7 @@ public function testSaveAction() ->method('create') ->willReturn($page); - $page->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $page->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->pageRepository->expects($this->once())->method('getById')->with($this->pageId)->willReturn($page); $page->expects($this->once())->method('setData'); $this->pageRepository->expects($this->once())->method('save')->with($page); @@ -182,6 +177,36 @@ public function testSaveActionWithoutData() $this->assertSame($this->resultRedirect, $this->saveController->execute()); } + public function testSaveActionNoId() + { + $this->requestMock->expects($this->any())->method('getPostValue')->willReturn(['page_id' => 1]); + $this->requestMock->expects($this->atLeastOnce()) + ->method('getParam') + ->willReturnMap( + [ + ['page_id', null, 1], + ['back', null, false], + ] + ); + + $page = $this->getMockBuilder(\Magento\Cms\Model\Page::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->pageFactory->expects($this->atLeastOnce()) + ->method('create') + ->willReturn($page); + $this->pageRepository->expects($this->once()) + ->method('getById') + ->with($this->pageId) + ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Error message'))); + $this->messageManagerMock->expects($this->once()) + ->method('addErrorMessage') + ->with(__('This page no longer exists.')); + $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/') ->willReturnSelf(); + $this->assertSame($this->resultRedirect, $this->saveController->execute()); + } + public function testSaveAndContinue() { $this->requestMock->expects($this->any())->method('getPostValue')->willReturn(['page_id' => $this->pageId]); @@ -204,12 +229,7 @@ public function testSaveAndContinue() ->method('create') ->willReturn($page); - $page->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $page->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->pageRepository->expects($this->once())->method('getById')->with($this->pageId)->willReturn($page); $page->expects($this->once())->method('setData'); $this->pageRepository->expects($this->once())->method('save')->with($page); @@ -251,12 +271,7 @@ public function testSaveActionThrowsException() ->method('create') ->willReturn($page); - $page->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $page->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->pageRepository->expects($this->once())->method('getById')->with($this->pageId)->willReturn($page); $page->expects($this->once())->method('setData'); $this->pageRepository->expects($this->once())->method('save')->with($page) ->willThrowException(new \Exception('Error message.')); diff --git a/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php b/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php index 67401797502ce..3fba04cd5604d 100644 --- a/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php +++ b/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php @@ -230,6 +230,15 @@ public function testConvertIdToPathNodeRoot() $this->assertEquals($this->imagesHelper->getStorageRoot(), $this->imagesHelper->convertIdToPath($pathId)); } + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Path is invalid + */ + public function testConvertIdToPathInvalid() + { + $this->imagesHelper->convertIdToPath('Ly4uLy4uLy4uLy4uLy4uL3dvcms-'); + } + /** * @param string $fileName * @param int $maxLength diff --git a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php index a2178489e1298..fae7a25dddcc2 100644 --- a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php +++ b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php @@ -114,20 +114,13 @@ class StorageTest extends \PHPUnit\Framework\TestCase protected function setUp() { $this->filesystemMock = $this->createMock(\Magento\Framework\Filesystem::class); - $this->driverMock = $this->getMockForAbstractClass( - \Magento\Framework\Filesystem\DriverInterface::class, - [], - '', - false, - false, - true, - ['getRealPath'] - ); - $this->driverMock->expects($this->any())->method('getRealPath')->will($this->returnArgument(0)); + $this->driverMock = $this->getMockBuilder(\Magento\Framework\Filesystem\DriverInterface::class) + ->setMethods(['getRealPathSafety']) + ->getMockForAbstractClass(); $this->directoryMock = $this->createPartialMock( \Magento\Framework\Filesystem\Directory\Write::class, - ['delete', 'getDriver', 'create'] + ['delete', 'getDriver', 'create', 'getRelativePath', 'isExist'] ); $this->directoryMock->expects( $this->any() @@ -151,7 +144,7 @@ protected function setUp() $this->adapterFactoryMock = $this->createMock(\Magento\Framework\Image\AdapterFactory::class); $this->imageHelperMock = $this->createPartialMock( \Magento\Cms\Helper\Wysiwyg\Images::class, - ['getStorageRoot'] + ['getStorageRoot', 'getCurrentPath'] ); $this->imageHelperMock->expects( $this->any() @@ -182,7 +175,10 @@ protected function setUp() $this->uploaderFactoryMock = $this->getMockBuilder(\Magento\MediaStorage\Model\File\UploaderFactory::class) ->disableOriginalConstructor() ->getMock(); - $this->sessionMock = $this->createMock(\Magento\Backend\Model\Session::class); + $this->sessionMock = $this->getMockBuilder(\Magento\Backend\Model\Session::class) + ->setMethods(['getCurrentPath']) + ->disableOriginalConstructor() + ->getMock(); $this->backendUrlMock = $this->createMock(\Magento\Backend\Model\Url::class); $this->coreFileStorageMock = $this->getMockBuilder(\Magento\MediaStorage\Helper\File\Storage\Database::class) @@ -240,6 +236,7 @@ public function testDeleteDirectoryOverRoot() \Magento\Framework\Exception\LocalizedException::class, sprintf('Directory %s is not under storage root path.', self::INVALID_DIRECTORY_OVER_ROOT) ); + $this->driverMock->expects($this->atLeastOnce())->method('getRealPathSafety')->will($this->returnArgument(0)); $this->imagesStorage->deleteDirectory(self::INVALID_DIRECTORY_OVER_ROOT); } @@ -252,6 +249,7 @@ public function testDeleteRootDirectory() \Magento\Framework\Exception\LocalizedException::class, sprintf('We can\'t delete root directory %s right now.', self::STORAGE_ROOT_DIR) ); + $this->driverMock->expects($this->atLeastOnce())->method('getRealPathSafety')->will($this->returnArgument(0)); $this->imagesStorage->deleteDirectory(self::STORAGE_ROOT_DIR); } diff --git a/app/code/Magento/Cms/composer.json b/app/code/Magento/Cms/composer.json index d3ccb07c8e8d3..191c5626db561 100644 --- a/app/code/Magento/Cms/composer.json +++ b/app/code/Magento/Cms/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-cms", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-theme": "100.2.*", "magento/module-widget": "101.0.*", @@ -18,7 +18,7 @@ "magento/module-cms-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "102.0.2", + "version": "102.0.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cms/etc/di.xml b/app/code/Magento/Cms/etc/di.xml index 8309e3b5b6150..d262ebca1591c 100644 --- a/app/code/Magento/Cms/etc/di.xml +++ b/app/code/Magento/Cms/etc/di.xml @@ -14,6 +14,7 @@ + diff --git a/app/code/Magento/CmsUrlRewrite/composer.json b/app/code/Magento/CmsUrlRewrite/composer.json index dbb116e73151b..bb712ceefb787 100644 --- a/app/code/Magento/CmsUrlRewrite/composer.json +++ b/app/code/Magento/CmsUrlRewrite/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-cms-url-rewrite", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-cms": "102.0.*", "magento/module-url-rewrite": "101.0.*", diff --git a/app/code/Magento/CmsUrlRewrite/etc/adminhtml/di.xml b/app/code/Magento/CmsUrlRewrite/etc/di.xml similarity index 100% rename from app/code/Magento/CmsUrlRewrite/etc/adminhtml/di.xml rename to app/code/Magento/CmsUrlRewrite/etc/di.xml diff --git a/app/code/Magento/Config/Console/Command/ConfigSet/ConfigSetProcessorFactory.php b/app/code/Magento/Config/Console/Command/ConfigSet/ConfigSetProcessorFactory.php index e005747ea5ed5..92cd0b0a14e93 100644 --- a/app/code/Magento/Config/Console/Command/ConfigSet/ConfigSetProcessorFactory.php +++ b/app/code/Magento/Config/Console/Command/ConfigSet/ConfigSetProcessorFactory.php @@ -27,7 +27,14 @@ class ConfigSetProcessorFactory * lock - save and lock configuration */ const TYPE_DEFAULT = 'default'; + + /** + * @deprecated + * @see TYPE_LOCK_ENV or TYPE_LOCK_CONFIG + */ const TYPE_LOCK = 'lock'; + const TYPE_LOCK_ENV = 'lock-env'; + const TYPE_LOCK_CONFIG = 'lock-config'; /**#@-*/ /**#@-*/ diff --git a/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php b/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php index 2f5c10037ef06..d7d513bfad423 100644 --- a/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php +++ b/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php @@ -72,7 +72,7 @@ public function process($path, $value, $scope, $scopeCode) throw new CouldNotSaveException( __( 'The value you set has already been locked. To change the value, use the --%1 option.', - ConfigSetCommand::OPTION_LOCK + ConfigSetCommand::OPTION_LOCK_ENV ) ); } diff --git a/app/code/Magento/Config/Console/Command/ConfigSet/LockProcessor.php b/app/code/Magento/Config/Console/Command/ConfigSet/LockProcessor.php index 0bd28f0f78d96..6fe2adde3c41e 100644 --- a/app/code/Magento/Config/Console/Command/ConfigSet/LockProcessor.php +++ b/app/code/Magento/Config/Console/Command/ConfigSet/LockProcessor.php @@ -16,7 +16,8 @@ /** * Processes file lock flow of config:set command. - * This processor saves the value of configuration and lock it for editing in Admin interface. + * This processor saves the value of configuration into app/etc/env.php + * and locks it for editing in Admin interface. * * {@inheritdoc} */ @@ -49,23 +50,30 @@ class LockProcessor implements ConfigSetProcessorInterface * @var ConfigPathResolver */ private $configPathResolver; + /** + * @var string + */ + private $target; /** * @param PreparedValueFactory $preparedValueFactory The factory for prepared value * @param DeploymentConfig\Writer $writer The deployment configuration writer * @param ArrayManager $arrayManager An array manager for different manipulations with arrays * @param ConfigPathResolver $configPathResolver The resolver for configuration paths according to source type + * @param string $target */ public function __construct( PreparedValueFactory $preparedValueFactory, DeploymentConfig\Writer $writer, ArrayManager $arrayManager, - ConfigPathResolver $configPathResolver + ConfigPathResolver $configPathResolver, + $target = ConfigFilePool::APP_ENV ) { $this->preparedValueFactory = $preparedValueFactory; $this->deploymentConfigWriter = $writer; $this->arrayManager = $arrayManager; $this->configPathResolver = $configPathResolver; + $this->target = $target; } /** @@ -97,7 +105,7 @@ public function process($path, $value, $scope, $scopeCode) * we'll write value just after all validations are triggered. */ $this->deploymentConfigWriter->saveConfig( - [ConfigFilePool::APP_ENV => $this->arrayManager->set($configPath, [], $value)], + [$this->target => $this->arrayManager->set($configPath, [], $value)], false ); } diff --git a/app/code/Magento/Config/Console/Command/ConfigSet/ProcessorFacade.php b/app/code/Magento/Config/Console/Command/ConfigSet/ProcessorFacade.php index 06a01c6686bfd..fcd7c0d5335b1 100644 --- a/app/code/Magento/Config/Console/Command/ConfigSet/ProcessorFacade.php +++ b/app/code/Magento/Config/Console/Command/ConfigSet/ProcessorFacade.php @@ -9,6 +9,7 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Scope\ValidatorInterface; use Magento\Config\Model\Config\PathValidator; +use Magento\Framework\Config\File\ConfigFilePool; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\ConfigurationMismatchException; use Magento\Framework\Exception\CouldNotSaveException; @@ -98,12 +99,35 @@ public function __construct( * @param boolean $lock The lock flag * @return string Processor response message * @throws ValidatorException If some validation is wrong - * @throws CouldNotSaveException If cannot save config value - * @throws ConfigurationMismatchException If processor can not be instantiated * @since 100.2.0 + * @deprecated + * @see processWithLockTarget() */ public function process($path, $value, $scope, $scopeCode, $lock) { + return $this->processWithLockTarget($path, $value, $scope, $scopeCode, $lock); + } + + /** + * Processes config:set command with the option to set a target file. + * + * @param string $path The configuration path in format section/group/field_name + * @param string $value The configuration value + * @param string $scope The configuration scope (default, website, or store) + * @param string $scopeCode The scope code + * @param boolean $lock The lock flag + * @param string $lockTarget + * @return string Processor response message + * @throws ValidatorException If some validation is wrong + */ + public function processWithLockTarget( + $path, + $value, + $scope, + $scopeCode, + $lock, + $lockTarget = ConfigFilePool::APP_ENV + ) { try { $this->scopeValidator->isValid($scope, $scopeCode); $this->pathValidator->validate($path); @@ -111,14 +135,24 @@ public function process($path, $value, $scope, $scopeCode, $lock) throw new ValidatorException(__($exception->getMessage()), $exception); } - $processor = $lock - ? $this->configSetProcessorFactory->create(ConfigSetProcessorFactory::TYPE_LOCK) - : $this->configSetProcessorFactory->create(ConfigSetProcessorFactory::TYPE_DEFAULT); - $message = $lock - ? 'Value was saved and locked.' - : 'Value was saved.'; + $processor = + $lock + ? ( $lockTarget == ConfigFilePool::APP_ENV + ? $this->configSetProcessorFactory->create(ConfigSetProcessorFactory::TYPE_LOCK_ENV) + : $this->configSetProcessorFactory->create(ConfigSetProcessorFactory::TYPE_LOCK_CONFIG) + ) + : $this->configSetProcessorFactory->create(ConfigSetProcessorFactory::TYPE_DEFAULT) + ; + + $message = + $lock + ? ( $lockTarget == ConfigFilePool::APP_ENV + ? 'Value was saved in app/etc/env.php and locked.' + : 'Value was saved in app/etc/config.php and locked.' + ) + : 'Value was saved.'; - // The processing flow depends on --lock option. + // The processing flow depends on --lock and --share options. $processor->process($path, $value, $scope, $scopeCode); $this->hash->regenerate(System::CONFIG_TYPE); diff --git a/app/code/Magento/Config/Console/Command/ConfigSetCommand.php b/app/code/Magento/Config/Console/Command/ConfigSetCommand.php index 1df1b3c4bed14..cb79daddbf5f9 100644 --- a/app/code/Magento/Config/Console/Command/ConfigSetCommand.php +++ b/app/code/Magento/Config/Console/Command/ConfigSetCommand.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Config\Console\Command; use Magento\Config\App\Config\Type\System; @@ -10,6 +11,7 @@ use Magento\Deploy\Model\DeploymentConfig\ChangeDetector; use Magento\Framework\App\DeploymentConfig; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Config\File\ConfigFilePool; use Magento\Framework\Console\Cli; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -34,6 +36,8 @@ class ConfigSetCommand extends Command const OPTION_SCOPE = 'scope'; const OPTION_SCOPE_CODE = 'scope-code'; const OPTION_LOCK = 'lock'; + const OPTION_LOCK_ENV = 'lock-env'; + const OPTION_LOCK_CONFIG = 'lock-config'; /**#@-*/ /**#@-*/ @@ -108,11 +112,24 @@ protected function configure() InputArgument::OPTIONAL, 'Scope code (required only if scope is not \'default\')' ), + new InputOption( + static::OPTION_LOCK_ENV, + 'le', + InputOption::VALUE_NONE, + 'Lock value which prevents modification in the Admin (will be saved in app/etc/env.php)' + ), + new InputOption( + static::OPTION_LOCK_CONFIG, + 'lc', + InputOption::VALUE_NONE, + 'Lock and share value with other installations, prevents modification in the Admin ' + . '(will be saved in app/etc/config.php)' + ), new InputOption( static::OPTION_LOCK, 'l', InputOption::VALUE_NONE, - 'Lock value which prevents modification in the Admin' + 'Deprecated, use the --' . static::OPTION_LOCK_ENV . ' option instead.' ), ]); @@ -146,12 +163,23 @@ protected function execute(InputInterface $input, OutputInterface $output) try { $message = $this->emulatedAreaProcessor->process(function () use ($input) { - return $this->processorFacadeFactory->create()->process( + + $lock = $input->getOption(static::OPTION_LOCK_ENV) + || $input->getOption(static::OPTION_LOCK_CONFIG) + || $input->getOption(static::OPTION_LOCK); + + $lockTargetPath = ConfigFilePool::APP_ENV; + if ($input->getOption(static::OPTION_LOCK_CONFIG)) { + $lockTargetPath = ConfigFilePool::APP_CONFIG; + } + + return $this->processorFacadeFactory->create()->processWithLockTarget( $input->getArgument(static::ARG_PATH), $input->getArgument(static::ARG_VALUE), $input->getOption(static::OPTION_SCOPE), $input->getOption(static::OPTION_SCOPE_CODE), - $input->getOption(static::OPTION_LOCK) + $lock, + $lockTargetPath ); }); diff --git a/app/code/Magento/Config/Model/Config/Importer.php b/app/code/Magento/Config/Model/Config/Importer.php index 70ffdaec829b2..e65a90c593e84 100644 --- a/app/code/Magento/Config/Model/Config/Importer.php +++ b/app/code/Magento/Config/Model/Config/Importer.php @@ -129,8 +129,10 @@ public function import(array $data) // Invoke saving of new values. $this->saveProcessor->process($changedData); - $this->flagManager->saveFlag(static::FLAG_CODE, $data); }); + + $this->scope->setCurrentScope($currentScope); + $this->flagManager->saveFlag(static::FLAG_CODE, $data); } catch (\Exception $e) { throw new InvalidTransitionException(__('%1', $e->getMessage()), $e); } finally { diff --git a/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php b/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php index 92bc61b3d65e5..115a372e6150a 100644 --- a/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php +++ b/app/code/Magento/Config/Model/Config/Structure/ConcealInProductionConfigList.php @@ -42,36 +42,14 @@ class ConcealInProductionConfigList implements ElementVisibilityInterface */ private $state; - /** - * - * The list of form element paths which ignore visibility status. - * - * E.g. - * - * ```php - * [ - * 'general/country/default' => '', - * ]; - * ``` - * - * It means that: - * - field 'default' in group Country Options (in section General) will be showed, even if all group(section) - * will be hidden. - * - * @var array - */ - private $exemptions = []; - /** * @param State $state The object that has information about the state of the system * @param array $configs The list of form element paths with concrete visibility status. - * @param array $exemptions The list of form element paths which ignore visibility status. */ - public function __construct(State $state, array $configs = [], array $exemptions = []) + public function __construct(State $state, array $configs = []) { $this->state = $state; $this->configs = $configs; - $this->exemptions = $exemptions; } /** @@ -80,21 +58,10 @@ public function __construct(State $state, array $configs = [], array $exemptions */ public function isHidden($path) { - $result = false; $path = $this->normalizePath($path); - if ($this->state->getMode() === State::MODE_PRODUCTION - && preg_match('/(?(?
    .*?)\/.*?)\/.*?/', $path, $match)) { - $group = $match['group']; - $section = $match['section']; - $exemptions = array_keys($this->exemptions); - foreach ($this->configs as $configPath => $value) { - if ($value === static::HIDDEN && strpos($path, $configPath) !==false) { - $result = empty(array_intersect([$section, $group, $path], $exemptions)); - } - } - } - - return $result; + return $this->state->getMode() === State::MODE_PRODUCTION + && !empty($this->configs[$path]) + && $this->configs[$path] === static::HIDDEN; } /** diff --git a/app/code/Magento/Config/Model/Config/Structure/Reader.php b/app/code/Magento/Config/Model/Config/Structure/Reader.php index 5916649588bcb..c83c2e1ae1320 100644 --- a/app/code/Magento/Config/Model/Config/Structure/Reader.php +++ b/app/code/Magento/Config/Model/Config/Structure/Reader.php @@ -124,6 +124,7 @@ protected function _readFiles($fileList) * Processing nodes of the document before merging * * @param string $content + * @throws \Magento\Framework\Config\Dom\ValidationException * @return string */ protected function processingDocument($content) @@ -131,7 +132,12 @@ protected function processingDocument($content) $object = new DataObject(); $document = new \DOMDocument(); - $document->loadXML($content); + try { + $document->loadXML($content); + } catch (\Exception $e) { + throw new \Magento\Framework\Config\Dom\ValidationException($e->getMessage()); + } + $this->compiler->compile($document->documentElement, $object, $object); return $document->saveXML(); diff --git a/app/code/Magento/Config/Model/ResourceModel/Config.php b/app/code/Magento/Config/Model/ResourceModel/Config.php index d8ea2410ce860..594a9df719daa 100644 --- a/app/code/Magento/Config/Model/ResourceModel/Config.php +++ b/app/code/Magento/Config/Model/ResourceModel/Config.php @@ -5,6 +5,8 @@ */ namespace Magento\Config\Model\ResourceModel; +use Magento\Framework\App\Config\ScopeConfigInterface; + /** * Core Resource Resource Model * @@ -34,7 +36,7 @@ protected function _construct() * @param int $scopeId * @return $this */ - public function saveConfig($path, $value, $scope, $scopeId) + public function saveConfig($path, $value, $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT, $scopeId = 0) { $connection = $this->getConnection(); $select = $connection->select()->from( @@ -70,7 +72,7 @@ public function saveConfig($path, $value, $scope, $scopeId) * @param int $scopeId * @return $this */ - public function deleteConfig($path, $scope, $scopeId) + public function deleteConfig($path, $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT, $scopeId = 0) { $connection = $this->getConnection(); $connection->delete( diff --git a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ConfigSetProcessorFactoryTest.php b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ConfigSetProcessorFactoryTest.php index 1fa0310ca62eb..a8f40106eb564 100644 --- a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ConfigSetProcessorFactoryTest.php +++ b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ConfigSetProcessorFactoryTest.php @@ -40,7 +40,7 @@ protected function setUp() $this->model = new ConfigSetProcessorFactory( $this->objectManagerMock, [ - ConfigSetProcessorFactory::TYPE_LOCK => LockProcessor::class, + ConfigSetProcessorFactory::TYPE_LOCK_ENV => LockProcessor::class, ConfigSetProcessorFactory::TYPE_DEFAULT => DefaultProcessor::class, 'wrongType' => \stdClass::class, ] @@ -58,7 +58,7 @@ public function testCreate() $this->assertInstanceOf( ConfigSetProcessorInterface::class, - $this->model->create(ConfigSetProcessorFactory::TYPE_LOCK) + $this->model->create(ConfigSetProcessorFactory::TYPE_LOCK_ENV) ); } diff --git a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php index 066b0fbe84b50..984e0fe842687 100644 --- a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php +++ b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php @@ -166,7 +166,9 @@ private function configMockForProcessTest($path, $scope, $scopeCode) /** * @expectedException \Magento\Framework\Exception\LocalizedException - * @expectedExceptionMessage The value you set has already been locked. To change the value, use the --lock option. + * @codingStandardsIgnoreStart + * @expectedExceptionMessage The value you set has already been locked. To change the value, use the --lock-env option. + * @codingStandardsIgnoreEnd */ public function testProcessLockedValue() { diff --git a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockConfigProcessorTest.php b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockConfigProcessorTest.php new file mode 100644 index 0000000000000..c727184efb4fc --- /dev/null +++ b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockConfigProcessorTest.php @@ -0,0 +1,220 @@ +preparedValueFactory = $this->getMockBuilder(PreparedValueFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->deploymentConfigWriterMock = $this->getMockBuilder(DeploymentConfig\Writer::class) + ->disableOriginalConstructor() + ->getMock(); + $this->arrayManagerMock = $this->getMockBuilder(ArrayManager::class) + ->disableOriginalConstructor() + ->getMock(); + $this->configPathResolver = $this->getMockBuilder(ConfigPathResolver::class) + ->disableOriginalConstructor() + ->getMock(); + $this->valueMock = $this->getMockBuilder(Value::class) + ->setMethods(['validateBeforeSave', 'beforeSave', 'setValue', 'getValue', 'afterSave']) + ->disableOriginalConstructor() + ->getMock(); + + $this->model = new LockProcessor( + $this->preparedValueFactory, + $this->deploymentConfigWriterMock, + $this->arrayManagerMock, + $this->configPathResolver, + ConfigFilePool::APP_CONFIG + ); + } + + /** + * Tests process of share flow. + * + * @param string $path + * @param string $value + * @param string $scope + * @param string|null $scopeCode + * @dataProvider processDataProvider + */ + public function testProcess($path, $value, $scope, $scopeCode) + { + $this->preparedValueFactory->expects($this->once()) + ->method('create') + ->with($path, $value, $scope, $scopeCode) + ->willReturn($this->valueMock); + $this->configPathResolver->expects($this->once()) + ->method('resolve') + ->willReturn('system/default/test/test/test'); + $this->arrayManagerMock->expects($this->once()) + ->method('set') + ->with('system/default/test/test/test', [], $value) + ->willReturn([ + 'system' => [ + 'default' => [ + 'test' => [ + 'test' => [ + 'test' => $value + ] + ] + ] + ] + ]); + $this->valueMock->expects($this->once()) + ->method('getValue') + ->willReturn($value); + $this->deploymentConfigWriterMock->expects($this->once()) + ->method('saveConfig') + ->with( + [ + ConfigFilePool::APP_CONFIG => [ + 'system' => [ + 'default' => [ + 'test' => [ + 'test' => [ + 'test' => $value + ] + ] + ] + ] + ] + ], + false + ); + $this->valueMock->expects($this->once()) + ->method('validateBeforeSave'); + $this->valueMock->expects($this->once()) + ->method('beforeSave'); + $this->valueMock->expects($this->once()) + ->method('afterSave'); + + $this->model->process($path, $value, $scope, $scopeCode); + } + + /** + * @return array + */ + public function processDataProvider() + { + return [ + ['test/test/test', 'value', ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null], + ['test/test/test', 'value', ScopeInterface::SCOPE_WEBSITE, 'base'], + ['test/test/test', 'value', ScopeInterface::SCOPE_STORE, 'test'], + ]; + } + + /** + * @expectedException \Magento\Framework\Exception\LocalizedException + * @expectedExceptionMessage Filesystem is not writable. + */ + public function testProcessNotReadableFs() + { + $path = 'test/test/test'; + $value = 'value'; + + $this->preparedValueFactory->expects($this->once()) + ->method('create') + ->willReturn($this->valueMock); + $this->valueMock->expects($this->once()) + ->method('getValue') + ->willReturn($value); + $this->configPathResolver->expects($this->once()) + ->method('resolve') + ->willReturn('system/default/test/test/test'); + $this->arrayManagerMock->expects($this->once()) + ->method('set') + ->with('system/default/test/test/test', [], $value) + ->willReturn(null); + $this->deploymentConfigWriterMock->expects($this->once()) + ->method('saveConfig') + ->willThrowException(new FileSystemException(__('Filesystem is not writable.'))); + + $this->model->process($path, $value, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null); + } + + /** + * @expectedException \Exception + * @expectedExceptionMessage Invalid values + */ + public function testCustomException() + { + $path = 'test/test/test'; + $value = 'value'; + + $this->configPathResolver->expects($this->once()) + ->method('resolve') + ->willReturn('system/default/test/test/test'); + $this->preparedValueFactory->expects($this->once()) + ->method('create') + ->willReturn($this->valueMock); + $this->arrayManagerMock->expects($this->never()) + ->method('set'); + $this->valueMock->expects($this->once()) + ->method('getValue'); + $this->valueMock->expects($this->once()) + ->method('afterSave') + ->willThrowException(new \Exception('Invalid values')); + $this->deploymentConfigWriterMock->expects($this->never()) + ->method('saveConfig'); + + $this->model->process($path, $value, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null); + } +} diff --git a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockProcessorTest.php b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockEnvProcessorTest.php similarity index 98% rename from app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockProcessorTest.php rename to app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockEnvProcessorTest.php index 4535e9ad888c2..4e0248f886028 100644 --- a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockProcessorTest.php +++ b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/LockEnvProcessorTest.php @@ -23,7 +23,7 @@ * @see LockProcessor * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class LockProcessorTest extends \PHPUnit\Framework\TestCase +class LockEnvProcessorTest extends \PHPUnit\Framework\TestCase { /** * @var LockProcessor @@ -81,7 +81,8 @@ protected function setUp() $this->preparedValueFactory, $this->deploymentConfigWriterMock, $this->arrayManagerMock, - $this->configPathResolver + $this->configPathResolver, + ConfigFilePool::APP_ENV ); } diff --git a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ProcessorFacadeTest.php b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ProcessorFacadeTest.php index 0f5852c322bdd..ac4dda2a98517 100644 --- a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ProcessorFacadeTest.php +++ b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/ProcessorFacadeTest.php @@ -11,6 +11,7 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Scope\ValidatorInterface; use Magento\Config\Model\Config\PathValidator; +use Magento\Framework\Config\File\ConfigFilePool; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\ValidatorException; use Magento\Framework\Exception\CouldNotSaveException; @@ -122,7 +123,13 @@ public function testProcess() $this->assertSame( 'Value was saved.', - $this->model->process('test/test/test', 'test', ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, false) + $this->model->processWithLockTarget( + 'test/test/test', + 'test', + ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + null, + false + ) ); } @@ -138,7 +145,13 @@ public function testProcessWithValidatorException(LocalizedException $exception) ->method('isValid') ->willThrowException($exception); - $this->model->process('test/test/test', 'test', ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, false); + $this->model->processWithLockTarget( + 'test/test/test', + 'test', + ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + null, + false + ); } /** @@ -173,7 +186,13 @@ public function testProcessWithConfigurationMismatchException() $this->configMock->expects($this->never()) ->method('clean'); - $this->model->process('test/test/test', 'test', ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, false); + $this->model->processWithLockTarget( + 'test/test/test', + 'test', + ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + null, + false + ); } /** @@ -199,17 +218,50 @@ public function testProcessWithCouldNotSaveException() $this->configMock->expects($this->never()) ->method('clean'); - $this->model->process('test/test/test', 'test', ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, false); + $this->model->processWithLockTarget( + 'test/test/test', + 'test', + ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + null, + false + ); + } + + public function testExecuteLockEnv() + { + $this->scopeValidatorMock->expects($this->once()) + ->method('isValid') + ->willReturn(true); + $this->configSetProcessorFactoryMock->expects($this->once()) + ->method('create') + ->with(ConfigSetProcessorFactory::TYPE_LOCK_ENV) + ->willReturn($this->processorMock); + $this->processorMock->expects($this->once()) + ->method('process') + ->with('test/test/test', 'test', ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null); + $this->configMock->expects($this->once()) + ->method('clean'); + + $this->assertSame( + 'Value was saved in app/etc/env.php and locked.', + $this->model->processWithLockTarget( + 'test/test/test', + 'test', + ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + null, + true + ) + ); } - public function testExecuteLock() + public function testExecuteLockConfig() { $this->scopeValidatorMock->expects($this->once()) ->method('isValid') ->willReturn(true); $this->configSetProcessorFactoryMock->expects($this->once()) ->method('create') - ->with(ConfigSetProcessorFactory::TYPE_LOCK) + ->with(ConfigSetProcessorFactory::TYPE_LOCK_CONFIG) ->willReturn($this->processorMock); $this->processorMock->expects($this->once()) ->method('process') @@ -218,8 +270,15 @@ public function testExecuteLock() ->method('clean'); $this->assertSame( - 'Value was saved and locked.', - $this->model->process('test/test/test', 'test', ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, true) + 'Value was saved in app/etc/config.php and locked.', + $this->model->processWithLockTarget( + 'test/test/test', + 'test', + ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + null, + true, + ConfigFilePool::APP_CONFIG + ) ); } } diff --git a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSetCommandTest.php b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSetCommandTest.php index 39f9c47361352..4f7327486d64a 100644 --- a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSetCommandTest.php +++ b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSetCommandTest.php @@ -94,7 +94,7 @@ public function testExecute() ->method('create') ->willReturn($this->processorFacadeMock); $this->processorFacadeMock->expects($this->once()) - ->method('process') + ->method('processWithLockTarget') ->willReturn('Some message'); $this->emulatedAreProcessorMock->expects($this->once()) ->method('process') diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/ImporterTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/ImporterTest.php index 0fdf4532462ac..4d8eec0aa76ba 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/ImporterTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/ImporterTest.php @@ -156,6 +156,9 @@ public function testImport() $this->scopeMock->expects($this->at(2)) ->method('setCurrentScope') ->with('oldScope'); + $this->scopeMock->expects($this->at(3)) + ->method('setCurrentScope') + ->with('oldScope'); $this->flagManagerMock->expects($this->once()) ->method('saveFlag') ->with(Importer::FLAG_CODE, $data); diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php index fa78d5dde652c..5cad923264e00 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/ConcealInProductionConfigListTest.php @@ -33,13 +33,9 @@ protected function setUp() 'third/path' => 'no', 'third/path/field' => ConcealInProductionConfigList::DISABLED, 'first/path/field' => 'no', - 'fourth' => ConcealInProductionConfigList::HIDDEN, - ]; - $exemptions = [ - 'fourth/path/value' => '', ]; - $this->model = new ConcealInProductionConfigList($this->stateMock, $configs, $exemptions); + $this->model = new ConcealInProductionConfigList($this->stateMock, $configs); } /** @@ -100,10 +96,8 @@ public function hiddenDataProvider() ['first/path', State::MODE_PRODUCTION, false], ['first/path', State::MODE_DEFAULT, false], ['some/path', State::MODE_PRODUCTION, false], - ['second/path/field', State::MODE_PRODUCTION, true], + ['second/path', State::MODE_PRODUCTION, true], ['second/path', State::MODE_DEVELOPER, false], - ['fourth/path/value', State::MODE_PRODUCTION, false], - ['fourth/path/test', State::MODE_PRODUCTION, true], ]; } } diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Element/Dependency/MapperTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Element/Dependency/MapperTest.php index 1c758bfcbefaa..2f081ea4285b9 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Element/Dependency/MapperTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Structure/Element/Dependency/MapperTest.php @@ -98,7 +98,8 @@ public function testGetDependenciesWhenDependentIsInvisible($isValueSatisfy) { $expected = []; $rowData = array_values($this->_testData); - for ($i = 0; $i < count($this->_testData); ++$i) { + $count = count($this->_testData); + for ($i = 0; $i < $count; ++$i) { $data = $rowData[$i]; $dependentPath = 'some path ' . $i; $field = $this->_getField( @@ -158,7 +159,8 @@ public function testGetDependenciesIsVisible() { $expected = []; $rowData = array_values($this->_testData); - for ($i = 0; $i < count($this->_testData); ++$i) { + $count = count($this->_testData); + for ($i = 0; $i < $count; ++$i) { $data = $rowData[$i]; $field = $this->_getField( true, diff --git a/app/code/Magento/Config/composer.json b/app/code/Magento/Config/composer.json index 0160339d50209..5b47de40862e9 100644 --- a/app/code/Magento/Config/composer.json +++ b/app/code/Magento/Config/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-config", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/framework": "101.0.*", "magento/module-store": "100.2.*", "magento/module-cron": "100.2.*", @@ -13,7 +13,7 @@ "magento/module-deploy": "100.2.*" }, "type": "magento2-module", - "version": "101.0.2", + "version": "101.0.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Config/etc/di.xml b/app/code/Magento/Config/etc/di.xml index bcddd8ceaf27a..a5dd18097fb47 100644 --- a/app/code/Magento/Config/etc/di.xml +++ b/app/code/Magento/Config/etc/di.xml @@ -296,10 +296,21 @@ Magento\Config\Console\Command\ConfigSet\DefaultProcessor - Magento\Config\Console\Command\ConfigSet\LockProcessor + Magento\Config\Console\Command\ConfigSet\VirtualLockEnvProcessor + Magento\Config\Console\Command\ConfigSet\VirtualLockConfigProcessor + + + app_env + + + + + app_config + + diff --git a/app/code/Magento/Config/etc/module.xml b/app/code/Magento/Config/etc/module.xml index b64cbe2b72623..cdf31ab7a5d19 100644 --- a/app/code/Magento/Config/etc/module.xml +++ b/app/code/Magento/Config/etc/module.xml @@ -6,5 +6,9 @@ */ --> - + + + + + diff --git a/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml b/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml index 93af2cfa653f8..cf235d368b9bc 100644 --- a/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml +++ b/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml @@ -21,7 +21,7 @@ $_colspan = $block->isAddAfter() ? 2 : 1; getColumns() as $columnName => $column): ?>
- + diff --git a/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php b/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php index 4ca21d554ccd9..7da97a77c1716 100644 --- a/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php @@ -489,6 +489,13 @@ protected function _parseVariations($rowData) $additionalRows = []; if (empty($rowData['configurable_variations'])) { return $additionalRows; + } elseif(!empty($rowData['store_view_code'])) { + throw new LocalizedException( + __( + 'Product with assigned super attributes should not have specified "%1" value', + 'store_view_code' + ) + ); } $variations = explode(ImportProduct::PSEUDO_MULTI_LINE_SEPARATOR, $rowData['configurable_variations']); foreach ($variations as $variation) { diff --git a/app/code/Magento/ConfigurableImportExport/composer.json b/app/code/Magento/ConfigurableImportExport/composer.json index 7fddf1081cddf..cd6c4854c1ad7 100644 --- a/app/code/Magento/ConfigurableImportExport/composer.json +++ b/app/code/Magento/ConfigurableImportExport/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-configurable-import-export", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-catalog": "102.0.*", "magento/module-catalog-import-export": "100.2.*", "magento/module-eav": "101.0.*", @@ -11,7 +11,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.0", + "version": "100.2.1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php index b5d02f64e6eb5..a80a15b59c2ce 100644 --- a/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php @@ -74,6 +74,11 @@ class Configurable extends \Magento\Catalog\Block\Product\View\AbstractView */ private $customerSession; + /** + * @var \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices + */ + private $variationPrices; + /** * @param \Magento\Catalog\Block\Product\Context $context * @param \Magento\Framework\Stdlib\ArrayUtils $arrayUtils @@ -86,6 +91,7 @@ class Configurable extends \Magento\Catalog\Block\Product\View\AbstractView * @param array $data * @param Format|null $localeFormat * @param Session|null $customerSession + * @param \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices|null $variationPrices * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -99,7 +105,8 @@ public function __construct( ConfigurableAttributeData $configurableAttributeData, array $data = [], Format $localeFormat = null, - Session $customerSession = null + Session $customerSession = null, + \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices $variationPrices = null ) { $this->priceCurrency = $priceCurrency; $this->helper = $helper; @@ -109,6 +116,9 @@ public function __construct( $this->configurableAttributeData = $configurableAttributeData; $this->localeFormat = $localeFormat ?: ObjectManager::getInstance()->get(Format::class); $this->customerSession = $customerSession ?: ObjectManager::getInstance()->get(Session::class); + $this->variationPrices = $variationPrices ?: ObjectManager::getInstance()->get( + \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices::class + ); parent::__construct( $context, @@ -126,7 +136,7 @@ public function __construct( public function getCacheKeyInfo() { $parentData = parent::getCacheKeyInfo(); - $parentData[] = $this->priceCurrency->getCurrencySymbol(); + $parentData[] = $this->priceCurrency->getCurrency()->getCode(); $parentData[] = $this->customerSession->getCustomerGroupId(); return $parentData; } @@ -178,6 +188,7 @@ public function getAllowProducts() } $this->setAllowProducts($products); } + return $this->getData('allow_products'); } @@ -211,9 +222,6 @@ public function getJsonConfig() $store = $this->getCurrentStore(); $currentProduct = $this->getProduct(); - $regularPrice = $currentProduct->getPriceInfo()->getPrice('regular_price'); - $finalPrice = $currentProduct->getPriceInfo()->getPrice('final_price'); - $options = $this->helper->getOptions($currentProduct, $this->getAllowProducts()); $attributesData = $this->configurableAttributeData->getAttributesData($currentProduct, $options); @@ -223,17 +231,7 @@ public function getJsonConfig() 'currencyFormat' => $store->getCurrentCurrency()->getOutputFormat(), 'optionPrices' => $this->getOptionPrices(), 'priceFormat' => $this->localeFormat->getPriceFormat(), - 'prices' => [ - 'oldPrice' => [ - 'amount' => $this->localeFormat->getNumber($regularPrice->getAmount()->getValue()), - ], - 'basePrice' => [ - 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getBaseAmount()), - ], - 'finalPrice' => [ - 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getValue()), - ], - ], + 'prices' => $this->variationPrices->getFormattedPrices($this->getProduct()->getPriceInfo()), 'productId' => $currentProduct->getId(), 'chooseText' => __('Choose an Option...'), 'images' => $this->getOptionImages(), diff --git a/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php b/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php index 0a4fc20578ed9..3a9ed653305c5 100644 --- a/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php +++ b/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php @@ -26,6 +26,7 @@ class ConfigurableItem extends DefaultItem * @param \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool * @param \Magento\Checkout\Helper\Data $checkoutHelper * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig + * @param \Magento\Framework\Escaper|null $escaper */ public function __construct( \Magento\Catalog\Helper\Image $imageHelper, @@ -33,14 +34,16 @@ public function __construct( \Magento\Framework\UrlInterface $urlBuilder, \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool, \Magento\Checkout\Helper\Data $checkoutHelper, - \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig + \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, + \Magento\Framework\Escaper $escaper = null ) { parent::__construct( $imageHelper, $msrpHelper, $urlBuilder, $configurationPool, - $checkoutHelper + $checkoutHelper, + $escaper ); $this->_scopeConfig = $scopeConfig; } diff --git a/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php b/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php new file mode 100644 index 0000000000000..68c574194817c --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php @@ -0,0 +1,58 @@ +configurableType = $configurableType; + $this->productRepository = $productRepository; + } + + /** + * Add parent identities to product identities + * + * @param Product $subject + * @param array $identities + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetIdentities(Product $subject, array $identities): array + { + $identities = (array) $identities; + + foreach ($this->configurableType->getParentIdsByChild($subject->getId()) as $parentId) { + $parentProduct = $this->productRepository->getById($parentId); + $identities = array_merge($identities, (array) $parentProduct->getIdentities()); + } + + return array_unique($identities); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php deleted file mode 100644 index ac42e320f3ad9..0000000000000 --- a/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php +++ /dev/null @@ -1,51 +0,0 @@ -catalogProductTypeConfigurable = $catalogProductTypeConfigurable; - } - - /** - * {@inheritdoc} - */ - public function getTags($object) - { - if (!is_object($object)) { - throw new \InvalidArgumentException('Provided argument is not an object'); - } - - if (!($object instanceof \Magento\Catalog\Model\Product)) { - throw new \InvalidArgumentException('Provided argument must be a product'); - } - - $result = $object->getIdentities(); - - foreach ($this->catalogProductTypeConfigurable->getParentIdsByChild($object->getId()) as $parentId) { - $result[] = \Magento\Catalog\Model\Product::CACHE_TAG . '_' . $parentId; - } - return $result; - } -} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php b/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php index d42d4ccafdd01..fb0735018b686 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php +++ b/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php @@ -10,6 +10,8 @@ use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable as ResourceModelConfigurable; use Magento\Framework\EntityManager\Operation\ExtensionInterface; +use Magento\ConfigurableProduct\Api\Data\OptionInterface; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute; /** * Class SaveHandler @@ -76,7 +78,7 @@ public function execute($entity, $arguments = []) } /** - * Save attributes for configurable product + * Save only newly created attributes for configurable product * * @param ProductInterface $product * @param array $attributes @@ -85,26 +87,57 @@ public function execute($entity, $arguments = []) private function saveConfigurableProductAttributes(ProductInterface $product, array $attributes) { $ids = []; + $existingAttributeIds = []; + foreach ($this->optionRepository->getList($product->getSku()) as $option) { + $existingAttributeIds[$option->getAttributeId()] = $option; + } /** @var \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute $attribute */ foreach ($attributes as $attribute) { - $attribute->setId(null); - $ids[] = $this->optionRepository->save($product->getSku(), $attribute); + if (!in_array($attribute->getAttributeId(), array_keys($existingAttributeIds)) + || $this->isOptionChanged($existingAttributeIds[$attribute->getAttributeId()], $attribute) + ) { + $attribute->setId(null); + $ids[] = $this->optionRepository->save($product->getSku(), $attribute); + } } - return $ids; } /** - * Remove product attributes + * Remove product attributes which no longer used * * @param ProductInterface $product * @return void */ private function deleteConfigurableProductAttributes(ProductInterface $product) { - $list = $this->optionRepository->getList($product->getSku()); - foreach ($list as $item) { - $this->optionRepository->deleteById($product->getSku(), $item->getId()); + $newAttributeIds = []; + foreach ($product->getExtensionAttributes()->getConfigurableProductOptions() as $option) { + $newAttributeIds[$option->getAttributeId()] = $option; + } + foreach ($this->optionRepository->getList($product->getSku()) as $option) { + if (!in_array($option->getAttributeId(), array_keys($newAttributeIds)) + || $this->isOptionChanged($option, $newAttributeIds[$option->getAttributeId()]) + ) { + $this->optionRepository->deleteById($product->getSku(), $option->getId()); + } + } + } + + /** + * Check if existing option is changed + * + * @param OptionInterface $option + * @param Attribute $attribute + * @return bool + */ + private function isOptionChanged(OptionInterface $option, Attribute $attribute) + { + if ($option->getLabel() == $attribute->getLabel() + && $option->getPosition() == $attribute->getPosition() + ) { + return false; } + return true; } } diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php index fbaa4e60c29cc..2bfee9fc6e794 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php @@ -10,11 +10,11 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\Data\ProductInterfaceFactory; use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\ConfigurableProduct\Model\Product\Type\Collection\SalableProcessor; use Magento\Catalog\Model\Config; +use Magento\Catalog\Model\Product\Gallery\ReadHandler as GalleryReadHandler; +use Magento\ConfigurableProduct\Model\Product\Type\Collection\SalableProcessor; use Magento\Framework\App\ObjectManager; use Magento\Framework\EntityManager\MetadataPool; -use Magento\Catalog\Model\Product\Gallery\ReadHandler as GalleryReadHandler; /** * Configurable product type implementation @@ -267,7 +267,6 @@ public function __construct( $productRepository, $serializer ); - } /** @@ -454,6 +453,10 @@ public function getConfigurableAttributes($product) ['group' => 'CONFIGURABLE', 'method' => __METHOD__] ); if (!$product->hasData($this->_configurableAttributes)) { + // for new product do not load configurable attributes + if (!$product->getId()) { + return []; + } $configurableAttributes = $this->getConfigurableAttributeCollection($product); $this->extensionAttributesJoinProcessor->process($configurableAttributes); $configurableAttributes->orderByPosition()->load(); @@ -1277,6 +1280,8 @@ public function getSalableUsedProducts(\Magento\Catalog\Model\Product $product, * Load collection on sub-products for specified configurable product * * Load collection of sub-products, apply result to specified configurable product and store result to cache + * Please note $salableOnly parameter is used for backwards compatibility because of deprecated method + * getSalableUsedProducts * Number of loaded sub-products depends on $salableOnly parameter * $salableOnly = true - result array contains only salable sub-products * $salableOnly = false - result array contains all sub-products @@ -1293,7 +1298,7 @@ private function loadUsedProducts(\Magento\Catalog\Model\Product $product, $cach if (!$product->hasData($dataFieldName)) { $usedProducts = $this->readUsedProductsCacheData($cacheKey); if ($usedProducts === null) { - $collection = $this->getConfiguredUsedProductCollection($product); + $collection = $this->getConfiguredUsedProductCollection($product, false); if ($salableOnly) { $collection = $this->salableProcessor->process($collection); } @@ -1387,17 +1392,37 @@ private function getUsedProductsCacheKey($keyParts) * Retrieve related products collection with additional configuration * * @param \Magento\Catalog\Model\Product $product + * @param bool $skipStockFilter * @return \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\Collection */ - private function getConfiguredUsedProductCollection(\Magento\Catalog\Model\Product $product) - { + private function getConfiguredUsedProductCollection( + \Magento\Catalog\Model\Product $product, + $skipStockFilter = true + ) { $collection = $this->getUsedProductCollection($product); + + if ($skipStockFilter) { + $collection->setFlag('has_stock_status_filter', true); + } + $collection - ->setFlag('has_stock_status_filter', true) - ->addAttributeToSelect($this->getCatalogConfig()->getProductAttributes()) + ->addAttributeToSelect($this->getAttributesForCollection($product)) ->addFilterByRequiredOptions() ->setStoreId($product->getStoreId()); + $collection->addMediaGalleryData(); + $collection->addTierPriceData(); + + return $collection; + } + + /** + * @return array + */ + private function getAttributesForCollection(\Magento\Catalog\Model\Product $product) + { + $productAttributes = $this->getCatalogConfig()->getProductAttributes(); + $requiredAttributes = [ 'name', 'price', @@ -1408,14 +1433,14 @@ private function getConfiguredUsedProductCollection(\Magento\Catalog\Model\Produ 'visibility', 'media_gallery' ]; - foreach ($requiredAttributes as $attributeCode) { - $collection->addAttributeToSelect($attributeCode); - } - foreach ($this->getUsedProductAttributes($product) as $usedProductAttribute) { - $collection->addAttributeToSelect($usedProductAttribute->getAttributeCode()); - } - $collection->addMediaGalleryData(); - $collection->addTierPriceData(); - return $collection; + + $usedAttributes = array_map( + function($attr) { + return $attr->getAttributeCode(); + }, + $this->getUsedProductAttributes($product) + ); + + return array_unique(array_merge($productAttributes, $requiredAttributes, $usedAttributes)); } } diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Variations/Prices.php b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Variations/Prices.php new file mode 100644 index 0000000000000..a60730b06fad2 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Variations/Prices.php @@ -0,0 +1,51 @@ +localeFormat = $localeFormat; + } + + /** + * Get product prices for configurable variations + * + * @param \Magento\Framework\Pricing\PriceInfo\Base $priceInfo + * @return array + */ + public function getFormattedPrices(\Magento\Framework\Pricing\PriceInfo\Base $priceInfo) + { + $regularPrice = $priceInfo->getPrice('regular_price'); + $finalPrice = $priceInfo->getPrice('final_price'); + + return [ + 'oldPrice' => [ + 'amount' => $this->localeFormat->getNumber($regularPrice->getAmount()->getValue()), + ], + 'basePrice' => [ + 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getBaseAmount()), + ], + 'finalPrice' => [ + 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getValue()), + ], + ]; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Attribute/OptionSelectBuilder.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Attribute/OptionSelectBuilder.php index 958d802682d52..5d9eed0a188fc 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Attribute/OptionSelectBuilder.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Attribute/OptionSelectBuilder.php @@ -91,6 +91,12 @@ public function getSelect(AbstractAttribute $superAttribute, int $productId, Sco ] ), [] + )->joinInner( + ['attribute_option' => $this->attributeResource->getTable('eav_attribute_option')], + 'attribute_option.option_id = entity_value.value', + [] + )->order( + 'attribute_option.sort_order ASC' )->where( 'super_attribute.product_id = ?', $productId diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php index a30ec81528dd3..a8fb60a4dcb22 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php @@ -1,54 +1,17 @@ storeResolver = $storeResolver ?: \Magento\Framework\App\ObjectManager::getInstance()->get( - StoreResolverInterface::class - ); - } - /** * @param null|int|array $entityIds * @return \Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\Configurable @@ -58,9 +21,10 @@ protected function reindex($entityIds = null) if ($this->hasEntity() || !empty($entityIds)) { $this->prepareFinalPriceDataForType($entityIds, $this->getTypeId()); $this->_applyCustomOption(); - $this->_applyConfigurableOption(); + $this->_applyConfigurableOption($entityIds); $this->_movePriceDataToIndexTable($entityIds); } + return $this; } @@ -110,74 +74,58 @@ protected function _prepareConfigurableOptionPriceTable() * Calculate minimal and maximal prices for configurable product options * and apply it to final price * + * @param null|int|array $entityIds * @return \Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\Configurable - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function _applyConfigurableOption() + protected function _applyConfigurableOption($entityIds = null) { $metadata = $this->getMetadataPool()->getMetadata(ProductInterface::class); $connection = $this->getConnection(); - $coaTable = $this->_getConfigurableOptionAggregateTable(); $copTable = $this->_getConfigurableOptionPriceTable(); + $finalPriceTable = $this->_getDefaultFinalPriceTable(); $linkField = $metadata->getLinkField(); - $this->_prepareConfigurableOptionAggregateTable(); $this->_prepareConfigurableOptionPriceTable(); - $subSelect = $this->getSelect(); - $subSelect->join( + $select = $connection->select()->from( + ['i' => $this->getIdxTable()], + [] + )->join( ['l' => $this->getTable('catalog_product_super_link')], - 'l.product_id = e.entity_id', + 'l.product_id = i.entity_id', [] )->join( ['le' => $this->getTable('catalog_product_entity')], 'le.' . $linkField . ' = l.parent_id', - ['parent_id' => 'entity_id'] - )->join( - ['i' => $this->_getDefaultFinalPriceTable()], - 'le.entity_id = i.entity_id', [] - ); - - $select = $connection->select(); - $select - ->from(['sub' => new \Zend_Db_Expr('(' . (string)$subSelect . ')')], '') - ->columns([ - 'sub.parent_id', - 'sub.entity_id', - 'sub.customer_group_id', - 'sub.website_id', - 'sub.price', - 'sub.tier_price' - ]); - - $query = $select->insertFromSelect($coaTable); - $connection->query($query); - - $select = $connection->select()->from( - [$coaTable], + )->columns( [ - 'parent_id', + 'le.entity_id', 'customer_group_id', 'website_id', - 'MIN(price)', - 'MAX(price)', + 'MIN(final_price)', + 'MAX(final_price)', 'MIN(tier_price)', + ] )->group( - ['parent_id', 'customer_group_id', 'website_id'] + ['le.entity_id', 'customer_group_id', 'website_id'] ); + if ($entityIds !== null) { + $select->where('le.entity_id IN (?)', $entityIds); + } $query = $select->insertFromSelect($copTable); $connection->query($query); - $table = ['i' => $this->_getDefaultFinalPriceTable()]; + $table = ['i' => $finalPriceTable]; $select = $connection->select()->join( ['io' => $copTable], 'i.entity_id = io.entity_id AND i.customer_group_id = io.customer_group_id' . ' AND i.website_id = io.website_id', [] ); + // adds price of custom option, that was applied in DefaultPrice::_applyCustomOption $select->columns( [ 'min_price' => new \Zend_Db_Expr('i.min_price - i.orig_price + io.min_price'), @@ -189,7 +137,6 @@ protected function _applyConfigurableOption() $query = $select->crossUpdateFromSelect($table); $connection->query($query); - $connection->delete($coaTable); $connection->delete($copTable); return $this; diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/LinkedProductSelectBuilderComposite.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/LinkedProductSelectBuilderComposite.php new file mode 100644 index 0000000000000..de616a43d92ae --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/LinkedProductSelectBuilderComposite.php @@ -0,0 +1,43 @@ +linkedProductSelectBuilder = $linkedProductSelectBuilder; + } + + /** + * {@inheritdoc} + */ + public function build($productId) + { + $selects = []; + foreach ($this->linkedProductSelectBuilder as $productSelectBuilder) { + $selects = array_merge($selects, $productSelectBuilder->build($productId)); + } + + return $selects; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php index 3c9689a1c4eb9..3611d95f0c6ac 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php @@ -17,6 +17,7 @@ use Magento\ConfigurableProduct\Model\ResourceModel\Attribute\OptionProvider; use Magento\Framework\App\ScopeResolverInterface; use Magento\Framework\App\ObjectManager; +use Magento\Framework\DB\Adapter\AdapterInterface; class Configurable extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb { @@ -109,27 +110,34 @@ public function saveProducts($mainProduct, array $productIds) } $productId = $mainProduct->getData($this->optionProvider->getProductEntityLinkField()); + $select = $this->getConnection()->select()->from( + ['t' => $this->getMainTable()], + ['product_id'] + )->where( + 't.parent_id = ?', + $productId + ); - $data = []; - foreach ($productIds as $id) { - $data[] = ['product_id' => (int) $id, 'parent_id' => (int) $productId]; - } + $existingProductIds = $this->getConnection()->fetchCol($select); + $insertProductIds = array_diff($productIds, $existingProductIds); + $deleteProductIds = array_diff($existingProductIds, $productIds); - if (!empty($data)) { - $this->getConnection()->insertOnDuplicate( + if (!empty($insertProductIds)) { + $insertData = []; + foreach ($insertProductIds as $id) { + $insertData[] = ['product_id' => (int) $id, 'parent_id' => (int) $productId]; + } + $this->getConnection()->insertMultiple( $this->getMainTable(), - $data, - ['product_id', 'parent_id'] + $insertData ); } - $where = ['parent_id = ?' => $productId]; - if (!empty($productIds)) { - $where['product_id NOT IN(?)'] = $productIds; + if (!empty($deleteProductIds)) { + $where = ['parent_id = ?' => $productId, 'product_id IN (?)' => $deleteProductIds]; + $this->getConnection()->delete($this->getMainTable(), $where); } - $this->getConnection()->delete($this->getMainTable(), $where); - // configurable product relations should be added to relation table $this->catalogProductRelation->processRelations($productId, $productIds); @@ -165,10 +173,13 @@ public function getChildrenIds($parentId, $required = true) $parentId ); - $childrenIds = [0 => []]; - foreach ($this->getConnection()->fetchAll($select) as $row) { - $childrenIds[0][$row['product_id']] = $row['product_id']; - } + $childrenIds = [ + 0 => array_column( + $this->getConnection()->fetchAll($select), + 'product_id', + 'product_id' + ) + ]; return $childrenIds; } @@ -181,7 +192,6 @@ public function getChildrenIds($parentId, $required = true) */ public function getParentIdsByChild($childId) { - $parentIds = []; $select = $this->getConnection() ->select() ->from(['l' => $this->getMainTable()], []) @@ -190,10 +200,7 @@ public function getParentIdsByChild($childId) 'e.' . $this->optionProvider->getProductEntityLinkField() . ' = l.parent_id', ['e.entity_id'] )->where('l.product_id IN(?)', $childId); - - foreach ($this->getConnection()->fetchAll($select) as $row) { - $parentIds[] = $row['entity_id']; - } + $parentIds = $this->getConnection()->fetchCol($select); return $parentIds; } diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute.php index 7ea83099f2589..e93c44893bf58 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable/Attribute.php @@ -8,8 +8,8 @@ namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable; use Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute as ConfigurableAttribute; +use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Store\Model\Store; -use Magento\Store\Model\StoreManagerInterface; class Attribute extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb { @@ -85,22 +85,30 @@ public function saveLabel($attribute) 'store_id' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, ]; $valueId = $connection->fetchOne($select, $bind); + if ($valueId) { - $storeId = (int)$attribute->getStoreId() ?: $this->_storeManager->getStore()->getId(); + $connection->insertOnDuplicate( + $this->_labelTable, + [ + 'product_super_attribute_id' => (int)$attribute->getId(), + 'store_id' => (int)$attribute->getStoreId() ?: $this->_storeManager->getStore()->getId(), + 'use_default' => (int)$attribute->getUseDefault(), + 'value' => $attribute->getLabel(), + ], + ['value', 'use_default'] + ); } else { // if attribute label not exists, always store on default store (0) - $storeId = Store::DEFAULT_STORE_ID; + $connection->insert( + $this->_labelTable, + [ + 'product_super_attribute_id' => (int)$attribute->getId(), + 'store_id' => Store::DEFAULT_STORE_ID, + 'use_default' => (int)$attribute->getUseDefault(), + 'value' => $attribute->getLabel(), + ] + ); } - $connection->insertOnDuplicate( - $this->_labelTable, - [ - 'product_super_attribute_id' => (int)$attribute->getId(), - 'use_default' => (int)$attribute->getUseDefault(), - 'store_id' => $storeId, - 'value' => $attribute->getLabel(), - ], - ['value', 'use_default'] - ); return $this; } diff --git a/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php b/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php index efddb278df36c..8a7e846c0e9f1 100644 --- a/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php +++ b/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php @@ -44,9 +44,7 @@ public function afterIsSalable( \Magento\Framework\Pricing\SaleableInterface $salableItem ) { if ($salableItem->getTypeId() == 'configurable' && $result) { - if (!$this->lowestPriceOptionsProvider->getProducts($salableItem)) { - $result = false; - } + $result = $salableItem->isSalable(); } return $result; diff --git a/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php b/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php index 66bc3db7ee89d..781bbde66360f 100644 --- a/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php +++ b/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php @@ -9,6 +9,8 @@ use Magento\Catalog\Model\ResourceModel\Product\LinkedProductSelectBuilderInterface; use Magento\Framework\App\ResourceConnection; use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\App\ObjectManager; /** * Retrieve list of products where each product contains lower price than others at least for one possible price type @@ -31,7 +33,12 @@ class LowestPriceOptionsProvider implements LowestPriceOptionsProviderInterface private $collectionFactory; /** - * Key is product id. Value is array of prepared linked products + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * Key is product id and store id. Value is array of prepared linked products * * @var array */ @@ -41,15 +48,19 @@ class LowestPriceOptionsProvider implements LowestPriceOptionsProviderInterface * @param ResourceConnection $resourceConnection * @param LinkedProductSelectBuilderInterface $linkedProductSelectBuilder * @param CollectionFactory $collectionFactory + * @param StoreManagerInterface $storeManager */ public function __construct( ResourceConnection $resourceConnection, LinkedProductSelectBuilderInterface $linkedProductSelectBuilder, - CollectionFactory $collectionFactory + CollectionFactory $collectionFactory, + StoreManagerInterface $storeManager = null ) { $this->resource = $resourceConnection; $this->linkedProductSelectBuilder = $linkedProductSelectBuilder; $this->collectionFactory = $collectionFactory; + $this->storeManager = $storeManager + ?: ObjectManager::getInstance()->get(StoreManagerInterface::class); } /** @@ -57,18 +68,19 @@ public function __construct( */ public function getProducts(ProductInterface $product) { - if (!isset($this->linkedProductMap[$product->getId()])) { + $key = $this->storeManager->getStore()->getId() . '-' . $product->getId(); + if (!isset($this->linkedProductMap[$key])) { $productIds = $this->resource->getConnection()->fetchCol( '(' . implode(') UNION (', $this->linkedProductSelectBuilder->build($product->getId())) . ')' ); - $this->linkedProductMap[$product->getId()] = $this->collectionFactory->create() + $this->linkedProductMap[$key] = $this->collectionFactory->create() ->addAttributeToSelect( ['price', 'special_price', 'special_from_date', 'special_to_date', 'tax_class_id'] ) ->addIdFilter($productIds) ->getItems(); } - return $this->linkedProductMap[$product->getId()]; + return $this->linkedProductMap[$key]; } } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php index 1908d897be6da..b45306d670bff 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php @@ -48,6 +48,11 @@ class ConfigurableTest extends \PHPUnit\Framework\TestCase */ private $priceCurrency; + /** + * @var \Magento\Directory\Model\Currency|\PHPUnit_Framework_MockObject_MockObject + */ + private $currency; + /** * @var \Magento\ConfigurableProduct\Model\ConfigurableAttributeData|\PHPUnit_Framework_MockObject_MockObject */ @@ -73,6 +78,11 @@ class ConfigurableTest extends \PHPUnit\Framework\TestCase */ private $customerSession; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $variationPricesMock; + protected function setUp() { $this->mockContextObject(); @@ -122,6 +132,9 @@ protected function setUp() $this->context->expects($this->once()) ->method('getResolver') ->willReturn($fileResolverMock); + $this->currency = $this->getMockBuilder(\Magento\Directory\Model\Currency::class) + ->disableOriginalConstructor() + ->getMock(); $this->configurableAttributeData = $this->getMockBuilder( \Magento\ConfigurableProduct\Model\ConfigurableAttributeData::class ) @@ -136,6 +149,10 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->variationPricesMock = $this->createMock( + \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices::class + ); + $this->block = new \Magento\ConfigurableProduct\Block\Product\View\Type\Configurable( $this->context, $this->arrayUtils, @@ -147,7 +164,8 @@ protected function setUp() $this->configurableAttributeData, [], $this->localeFormat, - $this->customerSession + $this->customerSession, + $this->variationPricesMock ); } @@ -192,10 +210,10 @@ public function cacheKeyProvider() : array 2 => null, 'base_url' => null, 'template' => null, - 3 => '$', + 3 => 'USD', 4 => null, ], - '$', + 'USD', null, ] ]; @@ -223,7 +241,10 @@ public function testGetCacheKeyInfo(array $expected, string $priceCurrency = nul ->method('getStore') ->willReturn($storeMock); $this->priceCurrency->expects($this->once()) - ->method('getCurrencySymbol') + ->method('getCurrency') + ->willReturn($this->currency); + $this->currency->expects($this->once()) + ->method('getCode') ->willReturn($priceCurrency); $this->customerSession->expects($this->once()) ->method('getCustomerGroupId') @@ -249,12 +270,8 @@ public function testGetJsonConfig() 'getAmount', ]) ->getMockForAbstractClass(); - $priceMock->expects($this->any()) - ->method('getAmount') - ->willReturn($amountMock); - + $priceMock->expects($this->any())->method('getAmount')->willReturn($amountMock); $tierPriceMock = $this->getTierPriceMock($amountMock, $priceQty, $percentage); - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); @@ -272,27 +289,16 @@ public function testGetJsonConfig() ['tier_price', $tierPriceMock], ]); - $productMock->expects($this->any()) - ->method('getTypeInstance') - ->willReturn($productTypeMock); - $productMock->expects($this->any()) - ->method('getPriceInfo') - ->willReturn($priceInfoMock); - $productMock->expects($this->any()) - ->method('isSaleable') - ->willReturn(true); - $productMock->expects($this->any()) - ->method('getId') - ->willReturn($productId); + $productMock->expects($this->any())->method('getTypeInstance')->willReturn($productTypeMock); + $productMock->expects($this->any())->method('getPriceInfo')->willReturn($priceInfoMock); + $productMock->expects($this->any())->method('isSaleable')->willReturn(true); + $productMock->expects($this->any())->method('getId')->willReturn($productId); $this->helper->expects($this->any()) ->method('getOptions') ->with($productMock, [$productMock]) ->willReturn([]); - - $this->product->expects($this->any()) - ->method('getSkipSaleableCheck') - ->willReturn(true); + $this->product->expects($this->any())->method('getSkipSaleableCheck')->willReturn(true); $attributesData = [ 'attributes' => [], @@ -304,9 +310,7 @@ public function testGetJsonConfig() ->with($productMock, []) ->willReturn($attributesData); - $this->localeFormat->expects($this->any()) - ->method('getPriceFormat') - ->willReturn([]); + $this->localeFormat->expects($this->atLeastOnce())->method('getPriceFormat')->willReturn([]); $this->localeFormat->expects($this->any()) ->method('getNumber') ->willReturnMap([ @@ -315,16 +319,29 @@ public function testGetJsonConfig() [$percentage, $percentage], ]); + $this->variationPricesMock->expects($this->once()) + ->method('getFormattedPrices') + ->with($priceInfoMock) + ->willReturn( + [ + 'oldPrice' => [ + 'amount' => $amount, + ], + 'basePrice' => [ + 'amount' => $amount, + ], + 'finalPrice' => [ + 'amount' => $amount, + ], + ] + ); + $expectedArray = $this->getExpectedArray($productId, $amount, $priceQty, $percentage); $expectedJson = json_encode($expectedArray); - $this->jsonEncoder->expects($this->once()) - ->method('encode') - ->with($expectedArray) - ->willReturn($expectedJson); + $this->jsonEncoder->expects($this->once())->method('encode')->with($expectedArray)->willReturn($expectedJson); $this->block->setData('product', $productMock); - $result = $this->block->getJsonConfig(); $this->assertEquals($expectedJson, $result); } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php new file mode 100644 index 0000000000000..d29f163ee1129 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php @@ -0,0 +1,77 @@ +configurableTypeMock = $this->getMockBuilder(Configurable::class) + ->disableOriginalConstructor() + ->getMock(); + $this->productRepositoryMock = $this->getMockBuilder(ProductRepositoryInterface::class) + ->getMock(); + + $this->plugin = new ProductIdentitiesExtender($this->configurableTypeMock, $this->productRepositoryMock); + } + + public function testAfterGetIdentities() + { + $productId = 1; + $productIdentity = 'cache_tag_1'; + $productMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + $parentProductId = 2; + $parentProductIdentity = 'cache_tag_2'; + $parentProductMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + + $productMock->expects($this->once()) + ->method('getId') + ->willReturn($productId); + $this->configurableTypeMock->expects($this->once()) + ->method('getParentIdsByChild') + ->with($productId) + ->willReturn([$parentProductId]); + $this->productRepositoryMock->expects($this->once()) + ->method('getById') + ->with($parentProductId) + ->willReturn($parentProductMock); + $parentProductMock->expects($this->once()) + ->method('getIdentities') + ->willReturn([$parentProductIdentity]); + + $productIdentities = $this->plugin->afterGetIdentities($productMock, [$productIdentity]); + $this->assertEquals([$productIdentity, $parentProductIdentity], $productIdentities); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php deleted file mode 100644 index a3f1435f84d2f..0000000000000 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php +++ /dev/null @@ -1,66 +0,0 @@ -typeResource = $this->createMock( - \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable::class - ); - - $this->model = new Configurable($this->typeResource); - } - - public function testGetWithScalar() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Provided argument is not an object'); - $this->model->getTags('scalar'); - } - - public function testGetTagsWithObject() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Provided argument must be a product'); - $this->model->getTags(new \stdClass()); - } - - public function testGetTagsWithVariation() - { - $product = $this->createMock(\Magento\Catalog\Model\Product::class); - - $identities = ['id1', 'id2']; - - $product->expects($this->once()) - ->method('getIdentities') - ->willReturn($identities); - - $parentId = 4; - $this->typeResource->expects($this->once()) - ->method('getParentIdsByChild') - ->willReturn([$parentId]); - - $expected = array_merge($identities, [\Magento\Catalog\Model\Product::CACHE_TAG . '_' . $parentId]); - - $this->assertEquals($expected, $this->model->getTags($product)); - } -} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php index 6fda5b867ccef..851595422f596 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php @@ -88,6 +88,7 @@ public function testExecuteWithInvalidProductType() public function testExecuteWithEmptyExtensionAttributes() { $sku = 'test'; + $configurableProductLinks = [1, 2, 3]; $product = $this->getMockBuilder(Product::class) ->disableOriginalConstructor() ->setMethods(['getTypeId', 'getExtensionAttributes', 'getSku']) @@ -105,16 +106,16 @@ public function testExecuteWithEmptyExtensionAttributes() ->disableOriginalConstructor() ->getMockForAbstractClass(); - $product->expects(static::once()) + $product->expects(static::atLeastOnce()) ->method('getExtensionAttributes') ->willReturn($extensionAttributes); - $extensionAttributes->expects(static::exactly(2)) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductOptions') ->willReturn([]); - $extensionAttributes->expects(static::once()) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductLinks') - ->willReturn([]); + ->willReturn($configurableProductLinks); $this->optionRepository->expects(static::once()) ->method('getList') @@ -133,7 +134,10 @@ public function testExecuteWithEmptyExtensionAttributes() public function testExecute() { $sku = 'config-1'; - $id = 25; + $idOld = 25; + $idNew = 26; + $attributeIdOld = 11; + $attributeIdNew = 22; $configurableProductLinks = [1, 2, 3]; $product = $this->getMockBuilder(Product::class) @@ -143,7 +147,7 @@ public function testExecute() $product->expects(static::once()) ->method('getTypeId') ->willReturn(ConfigurableModel::TYPE_CODE); - $product->expects(static::exactly(3)) + $product->expects(static::exactly(4)) ->method('getSku') ->willReturn($sku); @@ -156,30 +160,36 @@ public function testExecute() ->method('getExtensionAttributes') ->willReturn($extensionAttributes); - $attribute = $this->getMockBuilder(Attribute::class) + $attributeNew = $this->getMockBuilder(Attribute::class) ->disableOriginalConstructor() ->setMethods(['getAttributeId', 'loadByProductAndAttribute', 'setId', 'getId']) ->getMock(); - $this->processSaveOptions($attribute, $sku, $id); - - $option = $this->getMockForAbstractClass(OptionInterface::class); - $option->expects(static::once()) + $attributeNew->expects(static::atLeastOnce()) + ->method('getAttributeId') + ->willReturn($attributeIdNew); + $this->processSaveOptions($attributeNew, $sku, $idNew); + + $optionOld = $this->getMockForAbstractClass(OptionInterface::class); + $optionOld->expects(static::atLeastOnce()) + ->method('getAttributeId') + ->willReturn($attributeIdOld); + $optionOld->expects(static::atLeastOnce()) ->method('getId') - ->willReturn($id); + ->willReturn($idOld); - $list = [$option]; - $this->optionRepository->expects(static::once()) + $list = [$optionOld]; + $this->optionRepository->expects(static::atLeastOnce()) ->method('getList') ->with($sku) ->willReturn($list); $this->optionRepository->expects(static::once()) ->method('deleteById') - ->with($sku, $id); + ->with($sku, $idOld); $configurableAttributes = [ - $attribute + $attributeNew ]; - $extensionAttributes->expects(static::exactly(2)) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductOptions') ->willReturn($configurableAttributes); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/Configurable/Variations/PricesTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/Configurable/Variations/PricesTest.php new file mode 100644 index 0000000000000..6d7067666989c --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/Configurable/Variations/PricesTest.php @@ -0,0 +1,60 @@ +localeFormatMock = $this->createMock(\Magento\Framework\Locale\Format::class); + $this->model = new \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices( + $this->localeFormatMock + ); + } + + public function testGetFormattedPrices() + { + $expected = [ + 'oldPrice' => [ + 'amount' => 500 + ], + 'basePrice' => [ + 'amount' => 1000 + ], + 'finalPrice' => [ + 'amount' => 500 + ] + ]; + $priceInfoMock = $this->createMock(\Magento\Framework\Pricing\PriceInfo\Base::class); + $priceMock = $this->createMock(\Magento\Framework\Pricing\Price\PriceInterface::class); + $priceInfoMock->expects($this->atLeastOnce())->method('getPrice')->willReturn($priceMock); + + $amountMock = $this->createMock(\Magento\Framework\Pricing\Amount\AmountInterface::class); + $amountMock->expects($this->atLeastOnce())->method('getValue')->willReturn(500); + $amountMock->expects($this->atLeastOnce())->method('getBaseAmount')->willReturn(1000); + $priceMock->expects($this->atLeastOnce())->method('getAmount')->willReturn($amountMock); + + $this->localeFormatMock->expects($this->atLeastOnce()) + ->method('getNumber') + ->withConsecutive([500], [1000], [500]) + ->will($this->onConsecutiveCalls(500, 1000, 500)); + + $this->assertEquals($expected, $this->model->getFormattedPrices($priceInfoMock)); + } +} 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 ea136dd037baf..d1cf77f03a7bd 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 @@ -8,20 +8,20 @@ use Magento\Catalog\Api\Data\ProductExtensionInterface; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\Config; -use Magento\ConfigurableProduct\Model\Product\Type\Configurable; -use Magento\Framework\EntityManager\EntityMetadata; -use Magento\Framework\EntityManager\MetadataPool; -use Magento\Customer\Model\Session; -use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Attribute\CollectionFactory; -use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Attribute\Collection; use Magento\Catalog\Model\Product\Configuration\Item\Option\OptionInterface; -use Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface; +use Magento\ConfigurableProduct\Model\Product\Type\Collection\SalableProcessor; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\ConfigurableProduct\Model\Product\Type\Configurable\AttributeFactory; +use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Attribute\Collection; +use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Attribute\CollectionFactory; +use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\Collection as ProductCollection; use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\ConfigurableFactory; +use Magento\Customer\Model\Session; use Magento\Eav\Model\Entity\Attribute\Frontend\AbstractFrontend; use Magento\Eav\Model\Entity\Attribute\Source\AbstractSource; -use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\Collection as ProductCollection; -use Magento\ConfigurableProduct\Model\Product\Type\Collection\SalableProcessor; +use Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface; +use Magento\Framework\EntityManager\EntityMetadata; +use Magento\Framework\EntityManager\MetadataPool; /** * @SuppressWarnings(PHPMD.LongVariable) @@ -379,7 +379,7 @@ public function testGetUsedProducts() ['_cache_instance_used_product_attributes', null, []] ] ); - + $this->catalogConfig->expects($this->any())->method('getProductAttributes')->willReturn([]); $productCollection->expects($this->atLeastOnce())->method('addAttributeToSelect')->willReturnSelf(); $productCollection->expects($this->once())->method('setProductFilter')->willReturnSelf(); $productCollection->expects($this->atLeastOnce())->method('setFlag')->willReturnSelf(); @@ -508,17 +508,34 @@ public function getConfigurableAttributesAsArrayDataProvider() ]; } - public function testGetConfigurableAttributes() + public function testGetConfigurableAttributesNewProduct() + { + $configurableAttributes = '_cache_instance_configurable_attributes'; + + /** @var \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject $product */ + $product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) + ->setMethods(['hasData', 'getId']) + ->disableOriginalConstructor() + ->getMock(); + + $product->expects($this->once())->method('hasData')->with($configurableAttributes)->willReturn(false); + $product->expects($this->once())->method('getId')->willReturn(null); + + $this->assertEquals([], $this->model->getConfigurableAttributes($product)); + } + + public function testGetConfigurableAttributes() { $configurableAttributes = '_cache_instance_configurable_attributes'; /** @var \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject $product */ $product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->setMethods(['getData', 'hasData', 'setData']) + ->setMethods(['getData', 'hasData', 'setData', 'getId']) ->disableOriginalConstructor() ->getMock(); $product->expects($this->once())->method('hasData')->with($configurableAttributes)->willReturn(false); + $product->expects($this->once())->method('getId')->willReturn(1); $attributeCollection = $this->getMockBuilder(Collection::class) ->setMethods(['setProductFilter', 'orderByPosition', 'load']) diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Attribute/OptionSelectBuilderTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Attribute/OptionSelectBuilderTest.php index 235c16c9b556c..9802c97372bbb 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Attribute/OptionSelectBuilderTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Attribute/OptionSelectBuilderTest.php @@ -66,7 +66,7 @@ protected function setUp() ->disableOriginalConstructor() ->getMockForAbstractClass(); $this->select = $this->getMockBuilder(Select::class) - ->setMethods(['from', 'joinInner', 'joinLeft', 'where', 'columns']) + ->setMethods(['from', 'joinInner', 'joinLeft', 'where', 'columns', 'order']) ->disableOriginalConstructor() ->getMock(); $this->connectionMock->expects($this->atLeastOnce()) @@ -112,10 +112,28 @@ public function testGetSelect() { $this->select->expects($this->exactly(1))->method('from')->willReturnSelf(); $this->select->expects($this->exactly(1))->method('columns')->willReturnSelf(); - $this->select->expects($this->exactly(5))->method('joinInner')->willReturnSelf(); + $this->select->expects($this->exactly(6))->method('joinInner')->willReturnSelf(); $this->select->expects($this->exactly(3))->method('joinLeft')->willReturnSelf(); + $this->select->expects($this->exactly(1))->method('order')->willReturnSelf(); $this->select->expects($this->exactly(2))->method('where')->willReturnSelf(); + $this->attributeResourceMock->expects($this->exactly(9)) + ->method('getTable') + ->will( + $this->returnValueMap( + [ + ['catalog_product_super_attribute', 'catalog_product_super_attribute value'], + ['catalog_product_entity', 'catalog_product_entity value'], + ['catalog_product_super_link', 'catalog_product_super_link value'], + ['eav_attribute', 'eav_attribute value'], + ['catalog_product_entity', 'catalog_product_entity value'], + ['catalog_product_super_attribute_label', 'catalog_product_super_attribute_label value'], + ['eav_attribute_option', 'eav_attribute_option value'], + ['eav_attribute_option_value', 'eav_attribute_option_value value'] + ] + ) + ); + $this->abstractAttributeMock->expects($this->atLeastOnce()) ->method('getAttributeId') ->willReturn('getAttributeId value'); @@ -138,10 +156,27 @@ public function testGetSelectWithBackendModel() { $this->select->expects($this->exactly(1))->method('from')->willReturnSelf(); $this->select->expects($this->exactly(0))->method('columns')->willReturnSelf(); - $this->select->expects($this->exactly(5))->method('joinInner')->willReturnSelf(); + $this->select->expects($this->exactly(6))->method('joinInner')->willReturnSelf(); $this->select->expects($this->exactly(1))->method('joinLeft')->willReturnSelf(); + $this->select->expects($this->exactly(1))->method('order')->willReturnSelf(); $this->select->expects($this->exactly(2))->method('where')->willReturnSelf(); + $this->attributeResourceMock->expects($this->exactly(7)) + ->method('getTable') + ->will( + $this->returnValueMap( + [ + ['catalog_product_super_attribute', 'catalog_product_super_attribute value'], + ['catalog_product_entity', 'catalog_product_entity value'], + ['catalog_product_super_link', 'catalog_product_super_link value'], + ['eav_attribute', 'eav_attribute value'], + ['catalog_product_entity', 'catalog_product_entity value'], + ['catalog_product_super_attribute_label', 'catalog_product_super_attribute_label value'], + ['eav_attribute_option', 'eav_attribute_option value'] + ] + ) + ); + $this->abstractAttributeMock->expects($this->atLeastOnce()) ->method('getAttributeId') ->willReturn('getAttributeId value'); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/Configurable/AttributeTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/Configurable/AttributeTest.php index a6c7f00c2dfbe..e7b033ff84fd0 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/Configurable/AttributeTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/Configurable/AttributeTest.php @@ -53,7 +53,7 @@ protected function setUp() ); } - public function testSaveLabel() + public function testSaveNewLabel() { $attributeId = 4354; @@ -70,7 +70,7 @@ public function testSaveLabel() ] )->willReturn(0); - $this->connection->expects($this->once())->method('insertOnDuplicate')->with( + $this->connection->expects($this->once())->method('insert')->with( 'catalog_product_super_attribute_label', [ 'product_super_attribute_id' => $attributeId, @@ -79,12 +79,48 @@ public function testSaveLabel() 'value' => 'test', ] ); - $attributeMode = $this->getMockBuilder(AttributeModel::class)->setMethods( + $attributeMock = $this->getMockBuilder(AttributeModel::class)->setMethods( ['getId', 'getUseDefault', 'getLabel'] )->disableOriginalConstructor()->getMock(); - $attributeMode->expects($this->any())->method('getId')->willReturn($attributeId); - $attributeMode->expects($this->any())->method('getUseDefault')->willReturn(0); - $attributeMode->expects($this->any())->method('getLabel')->willReturn('test'); - $this->assertEquals($this->attribute, $this->attribute->saveLabel($attributeMode)); + $attributeMock->expects($this->atLeastOnce())->method('getId')->willReturn($attributeId); + $attributeMock->expects($this->atLeastOnce())->method('getUseDefault')->willReturn(0); + $attributeMock->expects($this->atLeastOnce())->method('getLabel')->willReturn('test'); + $this->assertEquals($this->attribute, $this->attribute->saveLabel($attributeMock)); + } + + public function testSaveExistingLabel() + { + $attributeId = 4354; + + $select = $this->getMockBuilder(Select::class)->disableOriginalConstructor()->getMock(); + $this->connection->expects($this->once())->method('select')->willReturn($select); + $select->expects($this->once())->method('from')->willReturnSelf(); + $select->expects($this->at(1))->method('where')->willReturnSelf(); + $select->expects($this->at(2))->method('where')->willReturnSelf(); + $this->connection->expects($this->once())->method('fetchOne')->with( + $select, + [ + 'product_super_attribute_id' => $attributeId, + 'store_id' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, + ] + )->willReturn(1); + + $this->connection->expects($this->once())->method('insertOnDuplicate')->with( + 'catalog_product_super_attribute_label', + [ + 'product_super_attribute_id' => $attributeId, + 'use_default' => 0, + 'store_id' => 1, + 'value' => 'test', + ] + ); + $attributeMock = $this->getMockBuilder(AttributeModel::class)->setMethods( + ['getId', 'getUseDefault', 'getLabel', 'getStoreId'] + )->disableOriginalConstructor()->getMock(); + $attributeMock->expects($this->atLeastOnce())->method('getId')->willReturn($attributeId); + $attributeMock->expects($this->atLeastOnce())->method('getStoreId')->willReturn(1); + $attributeMock->expects($this->atLeastOnce())->method('getUseDefault')->willReturn(0); + $attributeMock->expects($this->atLeastOnce())->method('getLabel')->willReturn('test'); + $this->assertEquals($this->attribute, $this->attribute->saveLabel($attributeMock)); } } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/ConfigurableTest.php index 5a494d1c7a19b..cda9c300cd1d9 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/ConfigurableTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/Type/ConfigurableTest.php @@ -142,19 +142,48 @@ public function testSaveProducts() $this->optionProvider->expects($this->once()) ->method('getProductEntityLinkField') ->willReturnSelf(); - $this->connectionMock->expects($this->once()) - ->method('insertOnDuplicate') - ->willReturnSelf(); - $this->resource->expects($this->any())->method('getConnection')->willReturn($this->connectionMock); $this->resource->expects($this->any())->method('getTableName')->willReturn('table name'); - $statement = $this->getMockBuilder(\Zend_Db_Statement::class)->disableOriginalConstructor()->getMock(); - $statement->method('fetchAll')->willReturn([1]); + $select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->setMethods(['from', 'where']) + ->disableOriginalConstructor() + ->getMock(); + $select->expects($this->exactly(1))->method('from')->willReturnSelf(); + $select->expects($this->exactly(1))->method('where')->willReturnSelf(); + + $this->connectionMock->expects($this->atLeastOnce()) + ->method('select') + ->willReturn($select); + + $existingProductIds = [1, 2]; + $this->connectionMock->expects($this->once()) + ->method('fetchCol') + ->with($select) + ->willReturn($existingProductIds); + + $this->connectionMock->expects($this->once()) + ->method('insertMultiple') + ->with( + 'table name', + [ + ['product_id' => 3, 'parent_id' => 3], + ['product_id' => 4, 'parent_id' => 3], + ] + ) + ->willReturnSelf(); + + $this->connectionMock->expects($this->once()) + ->method('delete') + ->with( + 'table name', + ['parent_id = ?' => 3, 'product_id IN (?)' => [1]] + ) + ->willReturnSelf(); $this->assertSame( $this->configurable, - $this->configurable->saveProducts($this->product, [1, 2, 3]) + $this->configurable->saveProducts($this->product, [2, 3, 4]) ); } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php index ceeb242a750a2..7c83645a9fda3 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php @@ -9,6 +9,9 @@ use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\ResourceModel\Product\LinkedProductSelectBuilderInterface; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\Store; class LowestPriceOptionsProviderTest extends \PHPUnit\Framework\TestCase { @@ -42,6 +45,16 @@ class LowestPriceOptionsProviderTest extends \PHPUnit\Framework\TestCase */ private $productCollection; + /** + * @var StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $storeManagerMock; + + /** + * @var StoreInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $storeMock; + protected function setUp() { $this->connection = $this @@ -68,6 +81,11 @@ protected function setUp() ->setMethods(['create']) ->getMock(); $this->collectionFactory->expects($this->once())->method('create')->willReturn($this->productCollection); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); + $this->storeMock = $this->getMockBuilder(StoreInterface::class) + ->setMethods(['getId']) + ->getMockForAbstractClass(); $objectManager = new ObjectManager($this); $this->model = $objectManager->getObject( @@ -76,6 +94,7 @@ protected function setUp() 'resourceConnection' => $this->resourceConnection, 'linkedProductSelectBuilder' => $this->linkedProductSelectBuilder, 'collectionFactory' => $this->collectionFactory, + 'storeManager' => $this->storeManagerMock, ] ); } @@ -94,6 +113,13 @@ public function testGetProducts() ->willReturnSelf(); $this->productCollection->expects($this->once())->method('addIdFilter')->willReturnSelf(); $this->productCollection->expects($this->once())->method('getItems')->willReturn($linkedProducts); + $this->storeManagerMock->expects($this->any()) + ->method('getStore') + ->with(Store::DEFAULT_STORE_ID) + ->willReturn($this->storeMock); + $this->storeMock->expects($this->any()) + ->method('getId') + ->willReturn(Store::DEFAULT_STORE_ID); $this->assertEquals($linkedProducts, $this->model->getProducts($product)); } diff --git a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php index 9150215e2c41e..c685e75bcc719 100644 --- a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php +++ b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php @@ -17,6 +17,8 @@ use Magento\Framework\Json\Helper\Data as JsonHelper; use Magento\Framework\Locale\CurrencyInterface; use Magento\Framework\UrlInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Escaper; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -83,6 +85,11 @@ class AssociatedProducts */ protected $imageHelper; + /** + * @var Escaper + */ + private $escaper; + /** * @param LocatorInterface $locator * @param UrlInterface $urlBuilder @@ -93,6 +100,8 @@ class AssociatedProducts * @param CurrencyInterface $localeCurrency * @param JsonHelper $jsonHelper * @param ImageHelper $imageHelper + * @param Escaper $escaper + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( LocatorInterface $locator, @@ -103,7 +112,8 @@ public function __construct( VariationMatrix $variationMatrix, CurrencyInterface $localeCurrency, JsonHelper $jsonHelper, - ImageHelper $imageHelper + ImageHelper $imageHelper, + Escaper $escaper = null ) { $this->locator = $locator; $this->urlBuilder = $urlBuilder; @@ -114,6 +124,7 @@ public function __construct( $this->localeCurrency = $localeCurrency; $this->jsonHelper = $jsonHelper; $this->imageHelper = $imageHelper; + $this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class); } /** @@ -280,9 +291,9 @@ protected function prepareVariations() 'product_link' => '' . $product->getName() . '', - 'sku' => $product->getSku(), - 'name' => $product->getName(), + ) . '" target="_blank">' . $this->escaper->escapeHtml($product->getName()) . '', + 'sku' => $this->escaper->escapeHtml($product->getSku()), + 'name' => $this->escaper->escapeHtml($product->getName()), 'qty' => $this->getProductStockQty($product), 'price' => $price, 'price_string' => $currency->toCurrency(sprintf("%f", $price)), diff --git a/app/code/Magento/ConfigurableProduct/composer.json b/app/code/Magento/ConfigurableProduct/composer.json index 04725604a0859..511efb560525a 100644 --- a/app/code/Magento/ConfigurableProduct/composer.json +++ b/app/code/Magento/ConfigurableProduct/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-configurable-product", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-catalog": "102.0.*", "magento/module-catalog-inventory": "100.2.*", @@ -24,7 +24,7 @@ "magento/module-product-links-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ConfigurableProduct/etc/di.xml b/app/code/Magento/ConfigurableProduct/etc/di.xml index 3f04081eaf645..8abebb5a373e1 100644 --- a/app/code/Magento/ConfigurableProduct/etc/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/di.xml @@ -168,13 +168,6 @@ - - - - \Magento\ConfigurableProduct\Model\Product\Cache\Tag\Configurable - - - @@ -196,6 +189,13 @@ Magento\Catalog\Model\Indexer\Product\Full + + + + Magento\Catalog\Model\ResourceModel\Product\Indexer\LinkedProductSelectBuilderByIndexPrice + + + Magento\ConfigurableProduct\Model\ResourceModel\Product\LinkedProductSelectBuilder @@ -204,9 +204,13 @@ Magento\ConfigurableProduct\Model\ResourceModel\Product\StockStatusBaseSelectProcessor + LinkedProductSelectBuilderByIndexMinPrice + + + diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js index 24fc24363562b..be290e49a43c3 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js @@ -357,12 +357,12 @@ define([ var element; _.each(this.disabledAttributes, function (attribute) { - registry.get('index = ' + attribute).disabled(false); + registry.get('code = ' + attribute, 'index = ' + attribute).disabled(false); }); this.disabledAttributes = []; _.each(attributes, function (attribute) { - element = registry.get('index = ' + attribute.code); + element = registry.get('code = ' + attribute.code, 'index = ' + attribute.code); if (!_.isUndefined(element)) { element.disabled(true); diff --git a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js index 64aefc27dc080..37b7c7c41b216 100644 --- a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js +++ b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js @@ -1,15 +1,18 @@ define([ 'jquery', + 'underscore', 'Magento_Customer/js/customer-data' -], function ($, customerData) { +], function ($, _, customerData) { 'use strict'; var selectors = { formSelector: '#product_addtocart_form', - productIdSelector: '#product_addtocart_form [name="product"]' + productIdSelector: '#product_addtocart_form [name="product"]', + itemIdSelector: '#product_addtocart_form [name="item"]' }, cartData = customerData.get('cart'), productId = $(selectors.productIdSelector).val(), + itemId = $(selectors.itemIdSelector).val(), /** * set productOptions according to cart data from customer-data @@ -24,7 +27,9 @@ define([ return false; } changedProductOptions = data.items.find(function (item) { - return item['product_id'] === productId; + if (item['item_id'] === itemId) { + return item['product_id'] === productId; + } }); changedProductOptions = changedProductOptions && changedProductOptions.options && changedProductOptions.options.reduce(function (obj, val) { diff --git a/app/code/Magento/ConfigurableProductSales/Model/Order/Reorder/OrderedProductAvailabilityChecker.php b/app/code/Magento/ConfigurableProductSales/Model/Order/Reorder/OrderedProductAvailabilityChecker.php index dceb5767edae9..42d7d91fb90e8 100644 --- a/app/code/Magento/ConfigurableProductSales/Model/Order/Reorder/OrderedProductAvailabilityChecker.php +++ b/app/code/Magento/ConfigurableProductSales/Model/Order/Reorder/OrderedProductAvailabilityChecker.php @@ -45,7 +45,7 @@ public function __construct( public function isAvailable(Item $item) { $buyRequest = $item->getBuyRequest(); - $superAttribute = $buyRequest->getData()['super_attribute']; + $superAttribute = $buyRequest->getData()['super_attribute'] ?? []; $connection = $this->getConnection(); $select = $connection->select(); $orderItemParentId = $item->getParentItem()->getProductId(); diff --git a/app/code/Magento/ConfigurableProductSales/composer.json b/app/code/Magento/ConfigurableProductSales/composer.json index eaa97d8394321..aa983a8b83458 100644 --- a/app/code/Magento/ConfigurableProductSales/composer.json +++ b/app/code/Magento/ConfigurableProductSales/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-configurable-product-sales", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-catalog": "102.0.*", "magento/module-sales": "101.0.*", "magento/module-store": "100.2.*", @@ -12,7 +12,7 @@ "magento/module-configurable-product": "100.2.*" }, "type": "magento2-module", - "version": "100.2.0", + "version": "100.2.1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Contact/composer.json b/app/code/Magento/Contact/composer.json index effec9f15a756..b3ddd25f8d42f 100644 --- a/app/code/Magento/Contact/composer.json +++ b/app/code/Magento/Contact/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-contact", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-config": "101.0.*", "magento/module-store": "100.2.*", "magento/module-customer": "101.0.*", diff --git a/app/code/Magento/Cookie/composer.json b/app/code/Magento/Cookie/composer.json index 131b4684b8f1f..4f2d3f818bcd5 100644 --- a/app/code/Magento/Cookie/composer.json +++ b/app/code/Magento/Cookie/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-cookie", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/framework": "101.0.*" }, diff --git a/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php b/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php index f772a6c0c8493..93d86e3c48c9b 100644 --- a/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php +++ b/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php @@ -13,6 +13,8 @@ use Magento\Framework\Console\Cli; use Magento\Framework\Event\ObserverInterface; use \Magento\Cron\Model\Schedule; +use Magento\Framework\Profiler\Driver\Standard\Stat; +use Magento\Framework\Profiler\Driver\Standard\StatFactory; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -56,6 +58,16 @@ class ProcessCronQueueObserver implements ObserverInterface */ const SECONDS_IN_MINUTE = 60; + /** + * How long to wait for cron group to become unlocked + */ + const LOCK_TIMEOUT = 5; + + /** + * Static lock prefix for cron group locking + */ + const LOCK_PREFIX = 'CRON_GROUP_'; + /** * @var \Magento\Cron\Model\ResourceModel\Schedule\Collection */ @@ -116,15 +128,20 @@ class ProcessCronQueueObserver implements ObserverInterface */ private $state; + /** + * @var \Magento\Framework\Lock\LockManagerInterface + */ + private $lockManager; + /** * @var array */ private $invalid = []; /** - * @var array + * @var Stat */ - private $jobs; + private $statProfiler; /** * @param \Magento\Framework\ObjectManagerInterface $objectManager @@ -138,6 +155,7 @@ class ProcessCronQueueObserver implements ObserverInterface * @param \Magento\Framework\Process\PhpExecutableFinderFactory $phpExecutableFinderFactory * @param \Psr\Log\LoggerInterface $logger * @param \Magento\Framework\App\State $state + * @param StatFactory $statFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -151,7 +169,9 @@ public function __construct( \Magento\Framework\Stdlib\DateTime\DateTime $dateTime, \Magento\Framework\Process\PhpExecutableFinderFactory $phpExecutableFinderFactory, \Psr\Log\LoggerInterface $logger, - \Magento\Framework\App\State $state + \Magento\Framework\App\State $state, + StatFactory $statFactory, + \Magento\Framework\Lock\LockManagerInterface $lockManager ) { $this->_objectManager = $objectManager; $this->_scheduleFactory = $scheduleFactory; @@ -164,6 +184,8 @@ public function __construct( $this->phpExecutableFinder = $phpExecutableFinderFactory->create(); $this->logger = $logger; $this->state = $state; + $this->statProfiler = $statFactory->create(); + $this->lockManager = $lockManager; } /** @@ -179,27 +201,26 @@ public function __construct( */ public function execute(\Magento\Framework\Event\Observer $observer) { - $pendingJobs = $this->_getPendingSchedules(); + $currentTime = $this->dateTime->gmtTimestamp(); $jobGroupsRoot = $this->_config->getJobs(); + // sort jobs groups to start from used in separated process + uksort( + $jobGroupsRoot, + function ($a, $b) { + return $this->getCronGroupConfigurationValue($b, 'use_separate_process') + - $this->getCronGroupConfigurationValue($a, 'use_separate_process'); + } + ); $phpPath = $this->phpExecutableFinder->find() ?: 'php'; foreach ($jobGroupsRoot as $groupId => $jobsRoot) { - $this->_cleanup($groupId); - $this->_generate($groupId); - if ($this->_request->getParam('group') !== null - && $this->_request->getParam('group') !== '\'' . ($groupId) . '\'' - && $this->_request->getParam('group') !== $groupId - ) { + if (!$this->isGroupInFilter($groupId)) { continue; } - if (($this->_request->getParam(self::STANDALONE_PROCESS_STARTED) !== '1') && ( - $this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/use_separate_process', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ) == 1 - ) + if ($this->_request->getParam(self::STANDALONE_PROCESS_STARTED) !== '1' + && $this->getCronGroupConfigurationValue($groupId, 'use_separate_process') == 1 ) { $this->_shell->execute( $phpPath . ' %s cron:run --group=' . $groupId . ' --' . Cli::INPUT_KEY_BOOTSTRAP . '=' @@ -211,42 +232,43 @@ public function execute(\Magento\Framework\Event\Observer $observer) continue; } - /** @var \Magento\Cron\Model\Schedule $schedule */ - foreach ($pendingJobs as $schedule) { - $jobConfig = isset($jobsRoot[$schedule->getJobCode()]) ? $jobsRoot[$schedule->getJobCode()] : null; - if (!$jobConfig) { - continue; + $this->lockGroup( + $groupId, + function ($groupId) use ($currentTime, $jobsRoot) { + $this->cleanupJobs($groupId, $currentTime); + $this->generateSchedules($groupId); + $this->processPendingJobs($groupId, $jobsRoot, $currentTime); } + ); + } + } - $scheduledTime = strtotime($schedule->getScheduledAt()); - if ($scheduledTime > $currentTime) { - continue; - } + /** + * Lock group + * + * It should be taken by standalone (child) process, not by the parent process. + * + * @param int $groupId + * @param callable $callback + * + * @return void + */ + private function lockGroup($groupId, callable $callback) + { - try { - if ($schedule->tryLockJob()) { - $this->_runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $groupId); - } - } catch (\Exception $e) { - $schedule->setMessages($e->getMessage()); - if ($schedule->getStatus() === Schedule::STATUS_ERROR) { - $this->logger->critical($e); - } - if ($schedule->getStatus() === Schedule::STATUS_MISSED - && $this->state->getMode() === State::MODE_DEVELOPER - ) { - $this->logger->info( - sprintf( - "%s Schedule Id: %s Job Code: %s", - $schedule->getMessages(), - $schedule->getScheduleId(), - $schedule->getJobCode() - ) - ); - } - } - $schedule->save(); - } + if (!$this->lockManager->lock(self::LOCK_PREFIX . $groupId, self::LOCK_TIMEOUT)) { + $this->logger->warning( + sprintf( + "Could not acquire lock for cron group: %s, skipping run", + $groupId + ) + ); + return; + } + try { + $callback($groupId); + } finally { + $this->lockManager->unlock(self::LOCK_PREFIX . $groupId); } } @@ -263,14 +285,12 @@ public function execute(\Magento\Framework\Event\Observer $observer) */ protected function _runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $groupId) { - $scheduleLifetime = (int)$this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/' . self::XML_PATH_SCHEDULE_LIFETIME, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); + $jobCode = $schedule->getJobCode(); + $scheduleLifetime = $this->getCronGroupConfigurationValue($groupId, self::XML_PATH_SCHEDULE_LIFETIME); $scheduleLifetime = $scheduleLifetime * self::SECONDS_IN_MINUTE; if ($scheduledTime < $currentTime - $scheduleLifetime) { $schedule->setStatus(Schedule::STATUS_MISSED); - throw new \Exception('Too late for the schedule'); + throw new \Exception(sprintf('Cron Job %s is missed at %s', $jobCode, $schedule->getScheduledAt())); } if (!isset($jobConfig['instance'], $jobConfig['method'])) { @@ -288,17 +308,73 @@ protected function _runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $schedule->setExecutedAt(strftime('%Y-%m-%d %H:%M:%S', $this->dateTime->gmtTimestamp()))->save(); + $this->startProfiling(); try { + $this->logger->info(sprintf('Cron Job %s is run', $jobCode)); call_user_func_array($callback, [$schedule]); - } catch (\Exception $e) { + } catch (\Throwable $e) { $schedule->setStatus(Schedule::STATUS_ERROR); + $this->logger->error(sprintf( + 'Cron Job %s has an error: %s. Statistics: %s', + $jobCode, + $e->getMessage(), + $this->getProfilingStat() + )); + if (!$e instanceof \Exception) { + $e = new \RuntimeException( + 'Error when running a cron job', + 0, + $e + ); + } throw $e; + } finally { + $this->stopProfiling(); } $schedule->setStatus(Schedule::STATUS_SUCCESS)->setFinishedAt(strftime( '%Y-%m-%d %H:%M:%S', $this->dateTime->gmtTimestamp() )); + + $this->logger->info(sprintf( + 'Cron Job %s is successfully finished. Statistics: %s', + $jobCode, + $this->getProfilingStat() + )); + } + + /** + * Starts profiling + * + * @return void + */ + private function startProfiling() + { + $this->statProfiler->clear(); + $this->statProfiler->start('job', microtime(true), memory_get_usage(true), memory_get_usage()); + } + + /** + * Stops profiling + * + * @return void + */ + private function stopProfiling() + { + $this->statProfiler->stop('job', microtime(true), memory_get_usage(true), memory_get_usage()); + } + + /** + * Retrieves statistics in the JSON format + * + * @return string + */ + private function getProfilingStat() + { + $stat = $this->statProfiler->get('job'); + unset($stat[Stat::START]); + return json_encode($stat); } /** @@ -306,15 +382,13 @@ protected function _runJob($scheduledTime, $currentTime, $jobConfig, $schedule, * * @return \Magento\Cron\Model\ResourceModel\Schedule\Collection */ - protected function _getPendingSchedules() + private function getPendingSchedules($groupId) { - if (!$this->_pendingSchedules) { - $this->_pendingSchedules = $this->_scheduleFactory->create()->getCollection()->addFieldToFilter( - 'status', - Schedule::STATUS_PENDING - )->load(); - } - return $this->_pendingSchedules; + $jobs = $this->_config->getJobs(); + $pendingJobs = $this->_scheduleFactory->create()->getCollection(); + $pendingJobs->addFieldToFilter('status', Schedule::STATUS_PENDING); + $pendingJobs->addFieldToFilter('job_code', ['in' => array_keys($jobs[$groupId])]); + return $pendingJobs; } /** @@ -323,22 +397,32 @@ protected function _getPendingSchedules() * @param string $groupId * @return $this */ - protected function _generate($groupId) + private function generateSchedules($groupId) { /** * check if schedule generation is needed */ $lastRun = (int)$this->_cache->load(self::CACHE_KEY_LAST_SCHEDULE_GENERATE_AT . $groupId); - $rawSchedulePeriod = (int)$this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/' . self::XML_PATH_SCHEDULE_GENERATE_EVERY, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE + $rawSchedulePeriod = (int)$this->getCronGroupConfigurationValue( + $groupId, + self::XML_PATH_SCHEDULE_GENERATE_EVERY ); $schedulePeriod = $rawSchedulePeriod * self::SECONDS_IN_MINUTE; if ($lastRun > $this->dateTime->gmtTimestamp() - $schedulePeriod) { return $this; } - $schedules = $this->_getPendingSchedules(); + /** + * save time schedules generation was ran with no expiration + */ + $this->_cache->save( + $this->dateTime->gmtTimestamp(), + self::CACHE_KEY_LAST_SCHEDULE_GENERATE_AT . $groupId, + ['crontab'], + null + ); + + $schedules = $this->getPendingSchedules($groupId); $exists = []; /** @var Schedule $schedule */ foreach ($schedules as $schedule) { @@ -348,21 +432,11 @@ protected function _generate($groupId) /** * generate global crontab jobs */ - $jobs = $this->getJobs(); + $jobs = $this->_config->getJobs(); $this->invalid = []; $this->_generateJobs($jobs[$groupId], $exists, $groupId); $this->cleanupScheduleMismatches(); - /** - * save time schedules generation was ran with no expiration - */ - $this->_cache->save( - $this->dateTime->gmtTimestamp(), - self::CACHE_KEY_LAST_SCHEDULE_GENERATE_AT . $groupId, - ['crontab'], - null - ); - return $this; } @@ -372,7 +446,7 @@ protected function _generate($groupId) * @param array $jobs * @param array $exists * @param string $groupId - * @return $this + * @return void */ protected function _generateJobs($jobs, $exists, $groupId) { @@ -385,77 +459,60 @@ protected function _generateJobs($jobs, $exists, $groupId) $timeInterval = $this->getScheduleTimeInterval($groupId); $this->saveSchedule($jobCode, $cronExpression, $timeInterval, $exists); } - return $this; } /** * Clean expired jobs * - * @param string $groupId - * @return $this + * @param $groupId + * @param $currentTime + * @return void */ - protected function _cleanup($groupId) + private function cleanupJobs($groupId, $currentTime) { - $this->cleanupDisabledJobs($groupId); - // check if history cleanup is needed $lastCleanup = (int)$this->_cache->load(self::CACHE_KEY_LAST_HISTORY_CLEANUP_AT . $groupId); - $historyCleanUp = (int)$this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/' . self::XML_PATH_HISTORY_CLEANUP_EVERY, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); + $historyCleanUp = (int)$this->getCronGroupConfigurationValue($groupId, self::XML_PATH_HISTORY_CLEANUP_EVERY); if ($lastCleanup > $this->dateTime->gmtTimestamp() - $historyCleanUp * self::SECONDS_IN_MINUTE) { return $this; } - - // check how long the record should stay unprocessed before marked as MISSED - $scheduleLifetime = (int)$this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/' . self::XML_PATH_SCHEDULE_LIFETIME, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE + // save time history cleanup was ran with no expiration + $this->_cache->save( + $this->dateTime->gmtTimestamp(), + self::CACHE_KEY_LAST_HISTORY_CLEANUP_AT . $groupId, + ['crontab'], + null ); - $scheduleLifetime = $scheduleLifetime * self::SECONDS_IN_MINUTE; - /** - * @var \Magento\Cron\Model\ResourceModel\Schedule\Collection $history - */ - $history = $this->_scheduleFactory->create()->getCollection()->addFieldToFilter( - 'status', - ['in' => [Schedule::STATUS_SUCCESS, Schedule::STATUS_MISSED, Schedule::STATUS_ERROR]] - )->load(); + $this->cleanupDisabledJobs($groupId); - $historySuccess = (int)$this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/' . self::XML_PATH_HISTORY_SUCCESS, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); - $historyFailure = (int)$this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/' . self::XML_PATH_HISTORY_FAILURE, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); + $historySuccess = (int)$this->getCronGroupConfigurationValue($groupId, self::XML_PATH_HISTORY_SUCCESS); + $historyFailure = (int)$this->getCronGroupConfigurationValue($groupId, self::XML_PATH_HISTORY_FAILURE); $historyLifetimes = [ Schedule::STATUS_SUCCESS => $historySuccess * self::SECONDS_IN_MINUTE, Schedule::STATUS_MISSED => $historyFailure * self::SECONDS_IN_MINUTE, Schedule::STATUS_ERROR => $historyFailure * self::SECONDS_IN_MINUTE, + Schedule::STATUS_PENDING => max($historyFailure, $historySuccess) * self::SECONDS_IN_MINUTE, ]; - $now = $this->dateTime->gmtTimestamp(); - /** @var Schedule $record */ - foreach ($history as $record) { - $checkTime = $record->getExecutedAt() ? strtotime($record->getExecutedAt()) : - strtotime($record->getScheduledAt()) + $scheduleLifetime; - if ($checkTime < $now - $historyLifetimes[$record->getStatus()]) { - $record->delete(); - } + $jobs = $this->_config->getJobs()[$groupId]; + $scheduleResource = $this->_scheduleFactory->create()->getResource(); + $connection = $scheduleResource->getConnection(); + $count = 0; + foreach ($historyLifetimes as $time) { + $count += $connection->delete( + $scheduleResource->getMainTable(), + [ + 'status = ?' => Schedule::STATUS_PENDING, + 'job_code in (?)' => array_keys($jobs), + 'created_at < ?' => $connection->formatDate($currentTime - $time) + ] + ); } - // save time history cleanup was ran with no expiration - $this->_cache->save( - $this->dateTime->gmtTimestamp(), - self::CACHE_KEY_LAST_HISTORY_CLEANUP_AT . $groupId, - ['crontab'], - null - ); - - return $this; + if ($count) { + $this->logger->info(sprintf('%d cron jobs were cleaned', $count)); + } } /** @@ -486,7 +543,7 @@ protected function saveSchedule($jobCode, $cronExpression, $timeInterval, $exist for ($time = $currentTime; $time < $timeAhead; $time += self::SECONDS_IN_MINUTE) { $scheduledAt = strftime('%Y-%m-%d %H:%M:00', $time); $alreadyScheduled = !empty($exists[$jobCode . '/' . $scheduledAt]); - $schedule = $this->generateSchedule($jobCode, $cronExpression, $time); + $schedule = $this->createSchedule($jobCode, $cronExpression, $time); $valid = $schedule->trySchedule(); if (!$valid) { if ($alreadyScheduled) { @@ -510,7 +567,7 @@ protected function saveSchedule($jobCode, $cronExpression, $timeInterval, $exist * @param int $time * @return Schedule */ - protected function generateSchedule($jobCode, $cronExpression, $time) + protected function createSchedule($jobCode, $cronExpression, $time) { $schedule = $this->_scheduleFactory->create() ->setCronExpr($cronExpression) @@ -528,10 +585,7 @@ protected function generateSchedule($jobCode, $cronExpression, $time) */ protected function getScheduleTimeInterval($groupId) { - $scheduleAheadFor = (int)$this->_scopeConfig->getValue( - 'system/cron/' . $groupId . '/' . self::XML_PATH_SCHEDULE_AHEAD_FOR, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); + $scheduleAheadFor = (int)$this->getCronGroupConfigurationValue($groupId, self::XML_PATH_SCHEDULE_AHEAD_FOR); $scheduleAheadFor = $scheduleAheadFor * self::SECONDS_IN_MINUTE; return $scheduleAheadFor; @@ -546,17 +600,27 @@ protected function getScheduleTimeInterval($groupId) */ private function cleanupDisabledJobs($groupId) { - $jobs = $this->getJobs(); + $jobs = $this->_config->getJobs(); + $jobsToCleanup = []; foreach ($jobs[$groupId] as $jobCode => $jobConfig) { if (!$this->getCronExpression($jobConfig)) { /** @var \Magento\Cron\Model\ResourceModel\Schedule $scheduleResource */ - $scheduleResource = $this->_scheduleFactory->create()->getResource(); - $scheduleResource->getConnection()->delete($scheduleResource->getMainTable(), [ - 'status=?' => Schedule::STATUS_PENDING, - 'job_code=?' => $jobCode, - ]); + $jobsToCleanup[] = $jobCode; } } + + if (count($jobsToCleanup) > 0) { + $scheduleResource = $this->_scheduleFactory->create()->getResource(); + $count = $scheduleResource->getConnection()->delete( + $scheduleResource->getMainTable(), + [ + 'status = ?' => Schedule::STATUS_PENDING, + 'job_code in (?)' => $jobsToCleanup, + ] + ); + + $this->logger->info(sprintf('%d cron jobs were cleaned', $count)); + } } /** @@ -586,12 +650,12 @@ private function getCronExpression($jobConfig) */ private function cleanupScheduleMismatches() { + /** @var \Magento\Cron\Model\ResourceModel\Schedule $scheduleResource */ + $scheduleResource = $this->_scheduleFactory->create()->getResource(); foreach ($this->invalid as $jobCode => $scheduledAtList) { - /** @var \Magento\Cron\Model\ResourceModel\Schedule $scheduleResource */ - $scheduleResource = $this->_scheduleFactory->create()->getResource(); $scheduleResource->getConnection()->delete($scheduleResource->getMainTable(), [ - 'status=?' => Schedule::STATUS_PENDING, - 'job_code=?' => $jobCode, + 'status = ?' => Schedule::STATUS_PENDING, + 'job_code = ?' => $jobCode, 'scheduled_at in (?)' => $scheduledAtList, ]); } @@ -599,13 +663,87 @@ private function cleanupScheduleMismatches() } /** - * @return array + * Get CronGroup Configuration Value + * + * @param $groupId + * @return int + */ + private function getCronGroupConfigurationValue($groupId, $path) + { + return $this->_scopeConfig->getValue( + 'system/cron/' . $groupId . '/' . $path, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Is Group In Filter + * + * @param $groupId + * @return bool + */ + private function isGroupInFilter($groupId): bool + { + return !($this->_request->getParam('group') !== null + && trim($this->_request->getParam('group'), "'") !== $groupId); + } + + /** + * Process pending jobs + * + * @param $groupId + * @param $jobsRoot + * @param $currentTime */ - private function getJobs() + private function processPendingJobs($groupId, $jobsRoot, $currentTime) { - if ($this->jobs === null) { - $this->jobs = $this->_config->getJobs(); + $procesedJobs = []; + $pendingJobs = $this->getPendingSchedules($groupId); + /** @var \Magento\Cron\Model\Schedule $schedule */ + foreach ($pendingJobs as $schedule) { + if (isset($procesedJobs[$schedule->getJobCode()])) { + // process only on job per run + continue; + } + $jobConfig = isset($jobsRoot[$schedule->getJobCode()]) ? $jobsRoot[$schedule->getJobCode()] : null; + if (!$jobConfig) { + continue; + } + + $scheduledTime = strtotime($schedule->getScheduledAt()); + if ($scheduledTime > $currentTime) { + continue; + } + + try { + if ($schedule->tryLockJob()) { + $this->_runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $groupId); + } + } catch (\Exception $e) { + $this->processError($schedule, $e); + } + if ($schedule->getStatus() === Schedule::STATUS_SUCCESS) { + $procesedJobs[$schedule->getJobCode()] = true; + } + $schedule->save(); + } + } + + /** + * @param Schedule $schedule + * @param \Exception $exception + * @return void + */ + private function processError(\Magento\Cron\Model\Schedule $schedule, \Exception $exception) + { + $schedule->setMessages($exception->getMessage()); + if ($schedule->getStatus() === Schedule::STATUS_ERROR) { + $this->logger->critical($exception); + } + if ($schedule->getStatus() === Schedule::STATUS_MISSED + && $this->state->getMode() === State::MODE_DEVELOPER + ) { + $this->logger->info($schedule->getMessages()); } - return $this->jobs; } } diff --git a/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php b/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php index c50afa0e6f0d1..6954fe49fdc43 100644 --- a/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php +++ b/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php @@ -12,8 +12,27 @@ class CronJobException { + /** + * @var \Throwable|null + */ + private $exception; + + /** + * @param \Throwable|null $exception + */ + public function __construct(\Throwable $exception = null) + { + $this->exception = $exception; + } + + /** + * @throws \Throwable + */ public function execute() { - throw new \Exception('Test exception'); + if (!$this->exception) { + $this->exception = new \Exception('Test exception'); + } + throw $this->exception; } } diff --git a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php index 0db6a598fb56f..d14249e6b0e57 100644 --- a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php +++ b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php @@ -8,6 +8,7 @@ use Magento\Cron\Model\Schedule; use Magento\Cron\Observer\ProcessCronQueueObserver as ProcessCronQueueObserver; use Magento\Framework\App\State; +use Magento\Framework\Profiler\Driver\Standard\StatFactory; /** * Class \Magento\Cron\Test\Unit\Model\ObserverTest @@ -84,6 +85,11 @@ class ProcessCronQueueObserverTest extends \PHPUnit\Framework\TestCase */ protected $appStateMock; + /** + * @var \Magento\Framework\Lock\LockManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $lockManagerMock; + /** * @var \Magento\Cron\Model\ResourceModel\Schedule|\PHPUnit_Framework_MockObject_MockObject */ @@ -116,6 +122,7 @@ protected function setUp() )->disableOriginalConstructor()->getMock(); $this->_collection->expects($this->any())->method('addFieldToFilter')->will($this->returnSelf()); $this->_collection->expects($this->any())->method('load')->will($this->returnSelf()); + $this->_scheduleFactory = $this->getMockBuilder( \Magento\Cron\Model\ScheduleFactory::class )->setMethods( @@ -135,6 +142,12 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->lockManagerMock = $this->getMockBuilder(\Magento\Framework\Lock\LockManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->lockManagerMock->method('lock')->willReturn(true); + $this->lockManagerMock->method('unlock')->willReturn(true); + $this->observer = $this->createMock(\Magento\Framework\Event\Observer::class); $this->dateTimeMock = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime\DateTime::class) @@ -159,6 +172,15 @@ protected function setUp() $this->scheduleResource->method('getConnection')->willReturn($connection); $connection->method('delete')->willReturn(1); + $this->statFactory = $this->getMockBuilder(StatFactory::class) + ->setMethods(['create']) + ->getMockForAbstractClass(); + + $this->stat = $this->getMockBuilder(\Magento\Framework\Profiler\Driver\Standard\Stat::class) + ->disableOriginalConstructor() + ->getMock(); + $this->statFactory->expects($this->any())->method('create')->willReturn($this->stat); + $this->_observer = new ProcessCronQueueObserver( $this->_objectManager, $this->_scheduleFactory, @@ -170,41 +192,23 @@ protected function setUp() $this->dateTimeMock, $phpExecutableFinderFactory, $this->loggerMock, - $this->appStateMock + $this->appStateMock, + $this->statFactory, + $this->lockManagerMock ); } - /** - * Test case without saved cron jobs in data base - */ - public function testDispatchNoPendingJobs() - { - $lastRun = $this->time + 10000000; - $this->_cache->expects($this->any())->method('load')->will($this->returnValue($lastRun)); - $this->_scopeConfig->expects($this->any())->method('getValue')->will($this->returnValue(0)); - - $this->_config->expects($this->once())->method('getJobs')->will($this->returnValue([])); - - $scheduleMock = $this->getMockBuilder( - \Magento\Cron\Model\Schedule::class - )->disableOriginalConstructor()->getMock(); - $scheduleMock->expects($this->any())->method('getCollection')->will($this->returnValue($this->_collection)); - $this->_scheduleFactory->expects($this->once())->method('create')->will($this->returnValue($scheduleMock)); - - $this->_observer->execute($this->observer); - } - /** * Test case for not existed cron jobs in files but in data base is presented */ public function testDispatchNoJobConfig() { $lastRun = $this->time + 10000000; - $this->_cache->expects($this->any())->method('load')->will($this->returnValue($lastRun)); - $this->_scopeConfig->expects($this->any())->method('getValue')->will($this->returnValue(0)); + $this->_cache->expects($this->atLeastOnce())->method('load')->will($this->returnValue($lastRun)); + $this->_scopeConfig->expects($this->atLeastOnce())->method('getValue')->will($this->returnValue(0)); $this->_config->expects( - $this->any() + $this->atLeastOnce() )->method( 'getJobs' )->will( @@ -212,16 +216,21 @@ public function testDispatchNoJobConfig() ); $schedule = $this->createPartialMock(\Magento\Cron\Model\Schedule::class, ['getJobCode', '__wakeup']); - $schedule->expects($this->once())->method('getJobCode')->will($this->returnValue('not_existed_job_code')); + $schedule->expects($this->atLeastOnce()) + ->method('getJobCode') + ->will($this->returnValue('not_existed_job_code')); $this->_collection->addItem($schedule); $scheduleMock = $this->getMockBuilder( \Magento\Cron\Model\Schedule::class )->disableOriginalConstructor()->getMock(); - $scheduleMock->expects($this->any())->method('getCollection')->will($this->returnValue($this->_collection)); - $scheduleMock->expects($this->any())->method('getResource')->will($this->returnValue($this->scheduleResource)); - $this->_scheduleFactory->expects($this->any())->method('create')->will($this->returnValue($scheduleMock)); + $scheduleMock->expects($this->atLeastOnce()) + ->method('getCollection') + ->will($this->returnValue($this->_collection)); + $this->_scheduleFactory->expects($this->atLeastOnce()) + ->method('create') + ->will($this->returnValue($scheduleMock)); $this->_observer->execute($this->observer); } @@ -240,11 +249,13 @@ public function testDispatchCanNotLock() $schedule = $this->getMockBuilder( \Magento\Cron\Model\Schedule::class )->setMethods( - ['getJobCode', 'tryLockJob', 'getScheduledAt', '__wakeup', 'save'] + ['getJobCode', 'tryLockJob', 'getScheduledAt', '__wakeup', 'save', 'setFinishedAt'] )->disableOriginalConstructor()->getMock(); $schedule->expects($this->any())->method('getJobCode')->will($this->returnValue('test_job1')); - $schedule->expects($this->once())->method('getScheduledAt')->will($this->returnValue($dateScheduledAt)); + $schedule->expects($this->atLeastOnce())->method('getScheduledAt')->will($this->returnValue($dateScheduledAt)); $schedule->expects($this->once())->method('tryLockJob')->will($this->returnValue(false)); + $schedule->expects($this->never())->method('setFinishedAt'); + $abstractModel = $this->createMock(\Magento\Framework\Model\AbstractModel::class); $schedule->expects($this->any())->method('save')->will($this->returnValue($abstractModel)); $this->_collection->addItem($schedule); @@ -262,7 +273,9 @@ public function testDispatchCanNotLock() )->disableOriginalConstructor()->getMock(); $scheduleMock->expects($this->any())->method('getCollection')->will($this->returnValue($this->_collection)); $scheduleMock->expects($this->any())->method('getResource')->will($this->returnValue($this->scheduleResource)); - $this->_scheduleFactory->expects($this->exactly(2))->method('create')->will($this->returnValue($scheduleMock)); + $this->_scheduleFactory->expects($this->atLeastOnce()) + ->method('create') + ->will($this->returnValue($scheduleMock)); $this->_observer->execute($this->observer); } @@ -272,10 +285,8 @@ public function testDispatchCanNotLock() */ public function testDispatchExceptionTooLate() { - $exceptionMessage = 'Too late for the schedule'; - $scheduleId = 42; + $exceptionMessage = 'Cron Job test_job1 is missed at 2017-07-30 15:00:00'; $jobCode = 'test_job1'; - $exception = $exceptionMessage . ' Schedule Id: ' . $scheduleId . ' Job Code: ' . $jobCode; $lastRun = $this->time + 10000000; $this->_cache->expects($this->any())->method('load')->willReturn($lastRun); @@ -299,25 +310,25 @@ public function testDispatchExceptionTooLate() 'getScheduleId', ] )->disableOriginalConstructor()->getMock(); - $schedule->expects($this->any())->method('getJobCode')->willReturn($jobCode); - $schedule->expects($this->once())->method('getScheduledAt')->willReturn($dateScheduledAt); + $schedule->expects($this->atLeastOnce())->method('getJobCode')->willReturn($jobCode); + $schedule->expects($this->atLeastOnce())->method('getScheduledAt')->willReturn($dateScheduledAt); $schedule->expects($this->once())->method('tryLockJob')->willReturn(true); $schedule->expects( - $this->once() + $this->any() )->method( 'setStatus' )->with( $this->equalTo(\Magento\Cron\Model\Schedule::STATUS_MISSED) )->willReturnSelf(); $schedule->expects($this->once())->method('setMessages')->with($this->equalTo($exceptionMessage)); - $schedule->expects($this->any())->method('getStatus')->willReturn(Schedule::STATUS_MISSED); - $schedule->expects($this->once())->method('getMessages')->willReturn($exceptionMessage); - $schedule->expects($this->once())->method('getScheduleId')->willReturn($scheduleId); + $schedule->expects($this->atLeastOnce())->method('getStatus')->willReturn(Schedule::STATUS_MISSED); + $schedule->expects($this->atLeastOnce())->method('getMessages')->willReturn($exceptionMessage); $schedule->expects($this->once())->method('save'); $this->appStateMock->expects($this->once())->method('getMode')->willReturn(State::MODE_DEVELOPER); - $this->loggerMock->expects($this->once())->method('info')->with($exception); + $this->loggerMock->expects($this->once())->method('info') + ->with('Cron Job test_job1 is missed at 2017-07-30 15:00:00'); $this->_collection->addItem($schedule); @@ -333,7 +344,7 @@ public function testDispatchExceptionTooLate() ->disableOriginalConstructor()->getMock(); $scheduleMock->expects($this->any())->method('getCollection')->willReturn($this->_collection); $scheduleMock->expects($this->any())->method('getResource')->will($this->returnValue($this->scheduleResource)); - $this->_scheduleFactory->expects($this->exactly(2))->method('create')->willReturn($scheduleMock); + $this->_scheduleFactory->expects($this->atLeastOnce())->method('create')->willReturn($scheduleMock); $this->_observer->execute($this->observer); } @@ -388,7 +399,7 @@ public function testDispatchExceptionNoCallback() )->disableOriginalConstructor()->getMock(); $scheduleMock->expects($this->any())->method('getCollection')->will($this->returnValue($this->_collection)); $scheduleMock->expects($this->any())->method('getResource')->will($this->returnValue($this->scheduleResource)); - $this->_scheduleFactory->expects($this->exactly(2))->method('create')->will($this->returnValue($scheduleMock)); + $this->_scheduleFactory->expects($this->once())->method('create')->will($this->returnValue($scheduleMock)); $this->_observer->execute($this->observer); } @@ -453,7 +464,7 @@ public function testDispatchExceptionInCallback( )->disableOriginalConstructor()->getMock(); $scheduleMock->expects($this->any())->method('getCollection')->will($this->returnValue($this->_collection)); $scheduleMock->expects($this->any())->method('getResource')->will($this->returnValue($this->scheduleResource)); - $this->_scheduleFactory->expects($this->exactly(2))->method('create')->will($this->returnValue($scheduleMock)); + $this->_scheduleFactory->expects($this->once())->method('create')->will($this->returnValue($scheduleMock)); $this->_objectManager ->expects($this->once()) ->method('create') @@ -468,6 +479,7 @@ public function testDispatchExceptionInCallback( */ public function dispatchExceptionInCallbackDataProvider() { + $throwable = new \TypeError(); return [ 'non-callable callback' => [ 'Not_Existed_Class', @@ -483,6 +495,19 @@ public function dispatchExceptionInCallbackDataProvider() 2, new \Exception(__('Test exception')) ], + 'throwable in execution' => [ + 'CronJobException', + new \Magento\Cron\Test\Unit\Model\CronJobException( + $throwable + ), + 'Error when running a cron job', + 2, + new \RuntimeException( + 'Error when running a cron job', + 0, + $throwable + ) + ], ]; } @@ -515,23 +540,22 @@ public function testDispatchRunJob() $scheduleMethods )->disableOriginalConstructor()->getMock(); $schedule->expects($this->any())->method('getJobCode')->will($this->returnValue('test_job1')); - $schedule->expects($this->once())->method('getScheduledAt')->will($this->returnValue($dateScheduledAt)); - $schedule->expects($this->once())->method('tryLockJob')->will($this->returnValue(true)); + $schedule->expects($this->atLeastOnce())->method('getScheduledAt')->will($this->returnValue($dateScheduledAt)); + $schedule->expects($this->atLeastOnce())->method('tryLockJob')->will($this->returnValue(true)); + $schedule->expects($this->any())->method('setFinishedAt')->willReturnSelf(); // cron start to execute some job $schedule->expects($this->any())->method('setExecutedAt')->will($this->returnSelf()); - $schedule->expects($this->at(5))->method('save'); + $schedule->expects($this->atLeastOnce())->method('save'); // cron end execute some job $schedule->expects( - $this->at(6) + $this->atLeastOnce() )->method( 'setStatus' )->with( $this->equalTo(\Magento\Cron\Model\Schedule::STATUS_SUCCESS) - )->will( - $this->returnSelf() - ); + )->willReturnSelf(); $schedule->expects($this->at(8))->method('save'); @@ -550,7 +574,7 @@ public function testDispatchRunJob() )->disableOriginalConstructor()->getMock(); $scheduleMock->expects($this->any())->method('getCollection')->will($this->returnValue($this->_collection)); $scheduleMock->expects($this->any())->method('getResource')->will($this->returnValue($this->scheduleResource)); - $this->_scheduleFactory->expects($this->exactly(2))->method('create')->will($this->returnValue($scheduleMock)); + $this->_scheduleFactory->expects($this->once(2))->method('create')->will($this->returnValue($scheduleMock)); $testCronJob = $this->getMockBuilder('CronJob')->setMethods(['execute'])->getMock(); $testCronJob->expects($this->atLeastOnce())->method('execute')->with($schedule); @@ -585,6 +609,8 @@ public function testDispatchNotGenerate() )->will( $this->returnValue(['test_group' => []]) ); + $this->_config->expects($this->at(2))->method('getJobs')->will($this->returnValue($jobConfig)); + $this->_config->expects($this->at(3))->method('getJobs')->will($this->returnValue($jobConfig)); $this->_request->expects($this->any())->method('getParam')->will($this->returnValue('test_group')); $this->_cache->expects( $this->at(0) @@ -654,6 +680,8 @@ public function testDispatchGenerate() ]; $this->_config->expects($this->at(0))->method('getJobs')->willReturn($jobConfig); $this->_config->expects($this->at(1))->method('getJobs')->willReturn($jobs); + $this->_config->expects($this->at(2))->method('getJobs')->willReturn($jobs); + $this->_config->expects($this->at(3))->method('getJobs')->willReturn($jobs); $this->_request->expects($this->any())->method('getParam')->willReturn('default'); $this->_cache->expects( $this->at(0) @@ -730,7 +758,7 @@ public function testDispatchCleanup() $this->_request->expects($this->any())->method('getParam')->will($this->returnValue('test_group')); $this->_collection->addItem($schedule); - $this->_config->expects($this->exactly(2))->method('getJobs')->will($this->returnValue($jobConfig)); + $this->_config->expects($this->atLeastOnce())->method('getJobs')->will($this->returnValue($jobConfig)); $this->_cache->expects($this->at(0))->method('load')->will($this->returnValue($this->time + 10000000)); $this->_cache->expects($this->at(1))->method('load')->will($this->returnValue($this->time - 10000000)); @@ -757,7 +785,7 @@ public function testDispatchCleanup() )->setMethods(['getCollection', 'getResource'])->disableOriginalConstructor()->getMock(); $scheduleMock->expects($this->any())->method('getCollection')->will($this->returnValue($collection)); $scheduleMock->expects($this->any())->method('getResource')->will($this->returnValue($this->scheduleResource)); - $this->_scheduleFactory->expects($this->at(1))->method('create')->will($this->returnValue($scheduleMock)); + $this->_scheduleFactory->expects($this->any())->method('create')->will($this->returnValue($scheduleMock)); $this->_observer->execute($this->observer); } @@ -781,55 +809,17 @@ public function testMissedJobsCleanedInTime() $this->_cache->expects($this->at(2))->method('load')->will($this->returnValue($this->time + 10000000)); $this->_scheduleFactory->expects($this->at(2))->method('create')->will($this->returnValue($scheduleMock)); - // This item was scheduled 2 days and 2 hours ago - $dateScheduledAt = date('Y-m-d H:i:s', $this->time - 180000); - /** @var \Magento\Cron\Model\Schedule|\PHPUnit_Framework_MockObject_MockObject $schedule1 */ - $schedule1 = $this->getMockBuilder( - \Magento\Cron\Model\Schedule::class - )->disableOriginalConstructor()->setMethods( - ['getExecutedAt', 'getScheduledAt', 'getStatus', 'delete', '__wakeup'] - )->getMock(); - $schedule1->expects($this->any())->method('getExecutedAt')->will($this->returnValue(null)); - $schedule1->expects($this->any())->method('getScheduledAt')->will($this->returnValue($dateScheduledAt)); - $schedule1->expects($this->any())->method('getStatus')->will($this->returnValue(Schedule::STATUS_MISSED)); - //we expect this job be deleted from the list - $schedule1->expects($this->once())->method('delete')->will($this->returnValue(true)); - $this->_collection->addItem($schedule1); - - // This item was scheduled 1 day ago - $dateScheduledAt = date('Y-m-d H:i:s', $this->time - 86400); - $schedule2 = $this->getMockBuilder( - \Magento\Cron\Model\Schedule::class - )->disableOriginalConstructor()->setMethods( - ['getExecutedAt', 'getScheduledAt', 'getStatus', 'delete', '__wakeup'] - )->getMock(); - $schedule2->expects($this->any())->method('getExecutedAt')->will($this->returnValue(null)); - $schedule2->expects($this->any())->method('getScheduledAt')->will($this->returnValue($dateScheduledAt)); - $schedule2->expects($this->any())->method('getStatus')->will($this->returnValue(Schedule::STATUS_MISSED)); - //we don't expect this job be deleted from the list - $schedule2->expects($this->never())->method('delete'); - $this->_collection->addItem($schedule2); - - $this->_config->expects($this->exactly(2))->method('getJobs')->will($this->returnValue($jobConfig)); - - $this->_scopeConfig->expects($this->at(0))->method('getValue') - ->with($this->equalTo('system/cron/test_group/history_cleanup_every')) - ->will($this->returnValue(10)); - $this->_scopeConfig->expects($this->at(1))->method('getValue') - ->with($this->equalTo('system/cron/test_group/schedule_lifetime')) - ->will($this->returnValue(2*24*60)); - $this->_scopeConfig->expects($this->at(2))->method('getValue') - ->with($this->equalTo('system/cron/test_group/history_success_lifetime')) - ->will($this->returnValue(0)); - $this->_scopeConfig->expects($this->at(3))->method('getValue') - ->with($this->equalTo('system/cron/test_group/history_failure_lifetime')) - ->will($this->returnValue(0)); - $this->_scopeConfig->expects($this->at(4))->method('getValue') - ->with($this->equalTo('system/cron/test_group/schedule_generate_every')) - ->will($this->returnValue(0)); - $this->_scopeConfig->expects($this->at(5))->method('getValue') - ->with($this->equalTo('system/cron/test_group/use_separate_process')) - ->will($this->returnValue(0)); + $this->_config->expects($this->atLeastOnce())->method('getJobs')->will($this->returnValue($jobConfig)); + + $this->_scopeConfig->expects($this->any())->method('getValue') + ->willReturnMap([ + ['system/cron/test_group/use_separate_process', 0], + ['system/cron/test_group/history_cleanup_every', 10], + ['system/cron/test_group/schedule_lifetime', 2*24*60], + ['system/cron/test_group/history_success_lifetime', 0], + ['system/cron/test_group/history_failure_lifetime', 0], + ['system/cron/test_group/schedule_generate_every', 0], + ]); $this->_collection->expects($this->any())->method('addFieldToFilter')->will($this->returnSelf()); $this->_collection->expects($this->any())->method('load')->will($this->returnSelf()); diff --git a/app/code/Magento/Cron/composer.json b/app/code/Magento/Cron/composer.json index ef6b580bfe7d0..9b94886399f5c 100644 --- a/app/code/Magento/Cron/composer.json +++ b/app/code/Magento/Cron/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-cron", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/framework": "101.0.*" }, @@ -10,7 +10,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CurrencySymbol/composer.json b/app/code/Magento/CurrencySymbol/composer.json index e3548dba538e3..b735415b88930 100644 --- a/app/code/Magento/CurrencySymbol/composer.json +++ b/app/code/Magento/CurrencySymbol/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-currency-symbol", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-config": "101.0.*", "magento/module-store": "100.2.*", "magento/module-page-cache": "100.2.*", diff --git a/app/code/Magento/Customer/Api/AccountDelegationInterface.php b/app/code/Magento/Customer/Api/AccountDelegationInterface.php new file mode 100644 index 0000000000000..f1b3cba769f8a --- /dev/null +++ b/app/code/Magento/Customer/Api/AccountDelegationInterface.php @@ -0,0 +1,33 @@ +getSortOrder() < $secondLink->getSortOrder()); + if ($firstLink->getSortOrder() == $secondLink->getSortOrder()) { + return 0; + } + + return ($firstLink->getSortOrder() < $secondLink->getSortOrder()) ? 1 : -1; } } diff --git a/app/code/Magento/Customer/Controller/Account/Index.php b/app/code/Magento/Customer/Controller/Account/Index.php index f734660fc3a77..2ecf79d35b11f 100644 --- a/app/code/Magento/Customer/Controller/Account/Index.php +++ b/app/code/Magento/Customer/Controller/Account/Index.php @@ -35,9 +35,6 @@ public function __construct( */ public function execute() { - /** @var \Magento\Framework\View\Result\Page $resultPage */ - $resultPage = $this->resultPageFactory->create(); - $resultPage->getConfig()->getTitle()->set(__('My Account')); - return $resultPage; + return $this->resultPageFactory->create(); } } diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php b/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php index 44eba83d96d7e..12732f81f78a0 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php @@ -13,6 +13,9 @@ use Magento\Customer\Model\Metadata\Form; use Magento\Framework\Exception\LocalizedException; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class Save extends \Magento\Customer\Controller\Adminhtml\Index { /** @@ -268,6 +271,15 @@ public function execute() $this->_addSessionErrorMessages($messages); $this->_getSession()->setCustomerFormData($originalRequestData); $returnToEdit = true; + } catch (\Magento\Framework\Exception\AbstractAggregateException $exception) { + $errors = $exception->getErrors(); + $messages = []; + foreach ($errors as $error) { + $messages[] = $error->getMessage(); + } + $this->_addSessionErrorMessages($messages); + $this->_getSession()->setCustomerFormData($originalRequestData); + $returnToEdit = true; } catch (LocalizedException $exception) { $this->_addSessionErrorMessages($exception->getMessage()); $this->_getSession()->setCustomerFormData($originalRequestData); diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php b/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php index 711fab9e608bf..c515e1151f7e6 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php @@ -132,30 +132,18 @@ public function __construct( */ public function execute() { - $file = null; - $plain = false; - if ($this->getRequest()->getParam('file')) { - // download file - $file = $this->urlDecoder->decode( - $this->getRequest()->getParam('file') - ); - } elseif ($this->getRequest()->getParam('image')) { - // show plain image - $file = $this->urlDecoder->decode( - $this->getRequest()->getParam('image') - ); - $plain = true; - } else { - throw new NotFoundException(__('Page not found.')); - } + list($file, $plain) = $this->getFileParams(); /** @var \Magento\Framework\Filesystem $filesystem */ $filesystem = $this->_objectManager->get(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryRead(DirectoryList::MEDIA); $fileName = CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER . '/' . ltrim($file, '/'); $path = $directory->getAbsolutePath($fileName); - if (!$directory->isFile($fileName) - && !$this->_objectManager->get(\Magento\MediaStorage\Helper\File\Storage::class)->processStorageFile($path) + if (mb_strpos($path, '..') !== false + || (!$directory->isFile($fileName) + && !$this->_objectManager->get( + \Magento\MediaStorage\Helper\File\Storage::class + )->processStorageFile($path)) ) { throw new NotFoundException(__('Page not found.')); } @@ -198,4 +186,31 @@ public function execute() ); } } + + /** + * Get parameters from request. + * + * @return array + * @throws NotFoundException + */ + private function getFileParams() + { + if ($this->getRequest()->getParam('file')) { + // download file + $file = $this->urlDecoder->decode( + $this->getRequest()->getParam('file') + ); + + return [$file, false]; + } elseif ($this->getRequest()->getParam('image')) { + // show plain image + $file = $this->urlDecoder->decode( + $this->getRequest()->getParam('image') + ); + + return [$file, true]; + } else { + throw new NotFoundException(__('Page not found.')); + } + } } diff --git a/app/code/Magento/Customer/Controller/Section/Load.php b/app/code/Magento/Customer/Controller/Section/Load.php index 6e73e070c790d..7a2345c91750c 100644 --- a/app/code/Magento/Customer/Controller/Section/Load.php +++ b/app/code/Magento/Customer/Controller/Section/Load.php @@ -64,8 +64,8 @@ public function execute() { /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); - $resultJson->setHeader('Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store'); - $resultJson->setHeader('Pragma', 'no-cache'); + $resultJson->setHeader('Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store', true); + $resultJson->setHeader('Pragma', 'no-cache', true); try { $sectionNames = $this->getRequest()->getParam('sections'); $sectionNames = $sectionNames ? array_unique(\explode(',', $sectionNames)) : null; diff --git a/app/code/Magento/Customer/Model/Account/Redirect.php b/app/code/Magento/Customer/Model/Account/Redirect.php index 2e8d596474e96..6b897cb11f718 100644 --- a/app/code/Magento/Customer/Model/Account/Redirect.php +++ b/app/code/Magento/Customer/Model/Account/Redirect.php @@ -206,6 +206,10 @@ protected function processLoggedCustomer() $referer = $this->request->getParam(CustomerUrl::REFERER_QUERY_PARAM_NAME); if ($referer) { $referer = $this->urlDecoder->decode($referer); + preg_match('/logoutSuccess/', $referer, $matches, PREG_OFFSET_CAPTURE); + if (!empty($matches)) { + $referer = str_replace('logoutSuccess', '', $referer); + } if ($this->hostChecker->isOwnOrigin($referer)) { $this->applyRedirect($referer); } diff --git a/app/code/Magento/Customer/Model/AccountManagement.php b/app/code/Magento/Customer/Model/AccountManagement.php index 8ba44f86f5a0e..fa209cf1e865b 100644 --- a/app/code/Magento/Customer/Model/AccountManagement.php +++ b/app/code/Magento/Customer/Model/AccountManagement.php @@ -47,6 +47,9 @@ use Magento\Store\Model\ScopeInterface; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface as PsrLogger; +use Magento\Framework\Session\SessionManagerInterface; +use Magento\Framework\Session\SaveHandlerInterface; +use Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory; /** * Handle various customer account actions @@ -242,6 +245,21 @@ class AccountManagement implements AccountManagementInterface */ private $transportBuilder; + /** + * @var SessionManagerInterface + */ + private $sessionManager; + + /** + * @var SaveHandlerInterface + */ + private $saveHandler; + + /** + * @var CollectionFactory + */ + private $visitorCollectionFactory; + /** * @var DataObjectProcessor */ @@ -334,6 +352,10 @@ class AccountManagement implements AccountManagementInterface * @param CredentialsValidator|null $credentialsValidator * @param DateTimeFactory|null $dateTimeFactory * @param AccountConfirmation|null $accountConfirmation + * @param DateTimeFactory $dateTimeFactory + * @param SessionManagerInterface|null $sessionManager + * @param SaveHandlerInterface|null $saveHandler + * @param CollectionFactory|null $visitorCollectionFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -362,7 +384,10 @@ public function __construct( ExtensibleDataObjectConverter $extensibleDataObjectConverter, CredentialsValidator $credentialsValidator = null, DateTimeFactory $dateTimeFactory = null, - AccountConfirmation $accountConfirmation = null + AccountConfirmation $accountConfirmation = null, + SessionManagerInterface $sessionManager = null, + SaveHandlerInterface $saveHandler = null, + CollectionFactory $visitorCollectionFactory = null ) { $this->customerFactory = $customerFactory; $this->eventManager = $eventManager; @@ -392,6 +417,12 @@ public function __construct( $this->dateTimeFactory = $dateTimeFactory ?: ObjectManager::getInstance()->get(DateTimeFactory::class); $this->accountConfirmation = $accountConfirmation ?: ObjectManager::getInstance() ->get(AccountConfirmation::class); + $this->sessionManager = $sessionManager + ?: ObjectManager::getInstance()->get(SessionManagerInterface::class); + $this->saveHandler = $saveHandler + ?: ObjectManager::getInstance()->get(SaveHandlerInterface::class); + $this->visitorCollectionFactory = $visitorCollectionFactory + ?: ObjectManager::getInstance()->get(CollectionFactory::class); } /** @@ -544,34 +575,25 @@ public function initiatePasswordReset($email, $template, $websiteId = null) $this->getEmailNotification()->passwordResetConfirmation($customer); break; default: - $this->handleUnknownTemplate($template); - break; + throw new InputException(__( + 'Invalid value of "%value" provided for the %fieldName field. '. + 'Possible values: %template1 or %template2.', + [ + 'value' => $template, + 'fieldName' => 'template', + 'template1' => AccountManagement::EMAIL_REMINDER, + 'template2' => AccountManagement::EMAIL_RESET + ] + )); } + return true; } catch (MailException $e) { // If we are not able to send a reset password email, this should be ignored $this->logger->critical($e); } - return false; - } - /** - * Handle not supported template - * - * @param string $template - * @throws InputException - */ - private function handleUnknownTemplate($template) - { - throw new InputException(__( - 'Invalid value of "%value" provided for the %fieldName field. Possible values: %template1 or %template2.', - [ - 'value' => $template, - 'fieldName' => 'template', - 'template1' => AccountManagement::EMAIL_REMINDER, - 'template2' => AccountManagement::EMAIL_RESET - ] - )); + return false; } /** @@ -588,7 +610,10 @@ public function resetPassword($email, $resetToken, $newPassword) $customerSecure->setRpToken(null); $customerSecure->setRpTokenCreatedAt(null); $customerSecure->setPasswordHash($this->createPasswordHash($newPassword)); + $this->sessionManager->destroy(); + $this->destroyCustomerSessions($customer->getId()); $this->customerRepository->save($customer); + return true; } @@ -886,7 +911,9 @@ private function changePasswordForCustomer($customer, $currentPassword, $newPass $customerSecure->setRpTokenCreatedAt(null); $this->checkPasswordStrength($newPassword); $customerSecure->setPasswordHash($this->createPasswordHash($newPassword)); + $this->destroyCustomerSessions($customer->getId()); $this->customerRepository->save($customer); + return true; } @@ -1376,4 +1403,35 @@ private function getEmailNotification() return $this->emailNotification; } } + + /** + * Destroy all active customer sessions by customer id (current session will not be destroyed). + * Customer sessions which should be deleted are collecting from the "customer_visitor" table considering + * configured session lifetime. + * + * @param string|int $customerId + * @return void + */ + private function destroyCustomerSessions($customerId) + { + $sessionLifetime = $this->scopeConfig->getValue( + \Magento\Framework\Session\Config::XML_PATH_COOKIE_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + $dateTime = $this->dateTimeFactory->create(); + $activeSessionsTime = $dateTime->setTimestamp($dateTime->getTimestamp() - $sessionLifetime) + ->format(DateTime::DATETIME_PHP_FORMAT); + /** @var \Magento\Customer\Model\ResourceModel\Visitor\Collection $visitorCollection */ + $visitorCollection = $this->visitorCollectionFactory->create(); + $visitorCollection->addFieldToFilter('customer_id', $customerId); + $visitorCollection->addFieldToFilter('last_visit_at', ['from' => $activeSessionsTime]); + $visitorCollection->addFieldToFilter('session_id', ['neq' => $this->sessionManager->getSessionId()]); + /** @var \Magento\Customer\Model\Visitor $visitor */ + foreach ($visitorCollection->getItems() as $visitor) { + $sessionId = $visitor->getSessionId(); + $this->sessionManager->start(); + $this->saveHandler->destroy($sessionId); + $this->sessionManager->writeClose(); + } + } } diff --git a/app/code/Magento/Customer/Model/Address/AbstractAddress.php b/app/code/Magento/Customer/Model/Address/AbstractAddress.php index a6ba510932d3d..eb541850a1bc1 100644 --- a/app/code/Magento/Customer/Model/Address/AbstractAddress.php +++ b/app/code/Magento/Customer/Model/Address/AbstractAddress.php @@ -30,6 +30,7 @@ * @method string getPostcode() * @method bool getShouldIgnoreValidation() * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * * @api * @since 100.0.2 @@ -263,7 +264,7 @@ public function setStreet($street) * * @param array|string $key * @param null $value - * @return \Magento\Framework\DataObject + * @return $this */ public function setData($key, $value = null) { @@ -562,9 +563,7 @@ public function getDataModel($defaultBillingAddressId = null, $defaultShippingAd } /** - * Validate address attribute values - * - * + * Validate address attribute values. * * @return bool|array * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -619,23 +618,52 @@ public function validate() $errors[] = __('%fieldName is a required field.', ['fieldName' => 'postcode']); } - if (!\Zend_Validate::is($this->getCountryId(), 'NotEmpty')) { + $countryId = $this->getCountryId(); + if (!\Zend_Validate::is($countryId, 'NotEmpty')) { $errors[] = __('%fieldName is a required field.', ['fieldName' => 'countryId']); - } - - if ($this->getCountryModel()->getRegionCollection()->getSize() && !\Zend_Validate::is( - $this->getRegionId(), - 'NotEmpty' - ) && $this->_directoryData->isRegionRequired( - $this->getCountryId() - ) - ) { - $errors[] = __('%fieldName is a required field.', ['fieldName' => 'regionId']); + } else { + //Checking if such country exists. + if (!in_array($countryId, $this->_directoryData->getCountryCollection()->getAllIds(), true)) { + $errors[] = __( + 'Invalid value of "%value" provided for the %fieldName field.', + [ + 'fieldName' => 'countryId', + 'value' => htmlspecialchars($countryId) + ] + ); + } else { + //If country is valid then validating selected region ID. + $countryModel = $this->getCountryModel(); + $regionCollection = $countryModel->getRegionCollection(); + $region = $this->getRegion(); + $regionId = (string)$this->getRegionId(); + $allowedRegions = $regionCollection->getAllIds(); + $isRegionRequired = $this->_directoryData->isRegionRequired($countryId); + if ($isRegionRequired && empty($allowedRegions) && !\Zend_Validate::is($region, 'NotEmpty')) { + //If region is required for country and country doesn't provide regions list + //region must be provided. + $errors[] = __('%fieldName is a required field.', ['fieldName' => 'region']); + } elseif ($allowedRegions && !\Zend_Validate::is($regionId, 'NotEmpty') && $isRegionRequired) { + //If country actually has regions and requires you to + //select one then it must be selected. + $errors[] = __('%fieldName is a required field.', ['fieldName' => 'regionId']); + } elseif ($regionId && !in_array($regionId, $allowedRegions, true)) { + //If a region is selected then checking if it exists. + $errors[] = __( + 'Invalid value of "%value" provided for the %fieldName field.', + [ + 'fieldName' => 'regionId', + 'value' => htmlspecialchars($regionId) + ] + ); + } + } } if (empty($errors) || $this->getShouldIgnoreValidation()) { return true; } + return $errors; } diff --git a/app/code/Magento/Customer/Model/Customer/NotificationStorage.php b/app/code/Magento/Customer/Model/Customer/NotificationStorage.php index 7054324851f34..11e0b9b916559 100644 --- a/app/code/Magento/Customer/Model/Customer/NotificationStorage.php +++ b/app/code/Magento/Customer/Model/Customer/NotificationStorage.php @@ -5,6 +5,7 @@ */ namespace Magento\Customer\Model\Customer; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Cache\FrontendInterface; use Magento\Framework\Serialize\SerializerInterface; @@ -18,21 +19,21 @@ class NotificationStorage private $cache; /** - * @param FrontendInterface $cache - */ - - /** - * @param FrontendInterface $cache + * @var SerializerInterface */ private $serializer; /** * NotificationStorage constructor. * @param FrontendInterface $cache + * @param SerializerInterface $serializer */ - public function __construct(FrontendInterface $cache) - { + public function __construct( + FrontendInterface $cache, + SerializerInterface $serializer = null + ) { $this->cache = $cache; + $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); } /** @@ -45,7 +46,7 @@ public function __construct(FrontendInterface $cache) public function add($notificationType, $customerId) { $this->cache->save( - $this->getSerializer()->serialize([ + $this->serializer->serialize([ 'customer_id' => $customerId, 'notification_type' => $notificationType ]), @@ -88,19 +89,4 @@ private function getCacheKey($notificationType, $customerId) { return 'notification_' . $notificationType . '_' . $customerId; } - - /** - * Get serializer - * - * @return SerializerInterface - * @deprecated 100.2.0 - */ - private function getSerializer() - { - if ($this->serializer === null) { - $this->serializer = \Magento\Framework\App\ObjectManager::getInstance() - ->get(SerializerInterface::class); - } - return $this->serializer; - } } diff --git a/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php b/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php new file mode 100644 index 0000000000000..cb78fe3eafc96 --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php @@ -0,0 +1,56 @@ +redirectFactory = $redirectFactory; + $this->storage = $storage; + } + + /** + * @inheritDoc + */ + public function createRedirectForNew( + CustomerInterface $customer, + array $mixedData = null + ): Redirect { + $this->storage->storeNewOperation($customer, $mixedData); + + return $this->redirectFactory->create() + ->setPath('customer/account/create'); + } +} diff --git a/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php b/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php new file mode 100644 index 0000000000000..b94b56bacf379 --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php @@ -0,0 +1,56 @@ +customer = $customer; + $this->additionalData = $additionalData; + } + + /** + * @return CustomerInterface + */ + public function getCustomer(): CustomerInterface + { + return $this->customer; + } + + /** + * @return array + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } +} diff --git a/app/code/Magento/Customer/Model/Delegation/Storage.php b/app/code/Magento/Customer/Model/Delegation/Storage.php new file mode 100644 index 0000000000000..a28bb9174221b --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/Storage.php @@ -0,0 +1,155 @@ +newFactory = $newFactory; + $this->customerFactory = $customerFactory; + $this->addressFactory = $addressFactory; + $this->regionFactory = $regionFactory; + $this->logger = $logger; + $this->session = $session; + } + + /** + * Store data for new account operation. + * + * @param CustomerInterface $customer + * @param array $delegatedData + * + * @return void + */ + public function storeNewOperation( + CustomerInterface $customer, + array $delegatedData + ) { + /** @var Customer $customer */ + $customerData = $customer->__toArray(); + $addressesData = []; + if ($customer->getAddresses()) { + /** @var Address $address */ + foreach ($customer->getAddresses() as $address) { + $addressesData[] = $address->__toArray(); + } + } + $this->session->setCustomerFormData($customerData); + $this->session->setDelegatedNewCustomerData([ + 'customer' => $customerData, + 'addresses' => $addressesData, + 'delegated_data' => $delegatedData + ]); + } + + /** + * Retrieve delegated new operation data and mark it as used. + * + * @return NewOperation|null + */ + public function consumeNewOperation() + { + try { + $serialized = $this->session->getDelegatedNewCustomerData(true); + } catch (\Throwable $exception) { + $this->logger->error($exception); + $serialized = null; + } + if (!$serialized) { + return null; + } + + /** @var AddressInterface[] $addresses */ + $addresses = []; + foreach ($serialized['addresses'] as $addressData) { + if (isset($addressData['region'])) { + /** @var RegionInterface $region */ + $region = $this->regionFactory->create( + ['data' => $addressData['region']] + ); + $addressData['region'] = $region; + } + $addresses[] = $this->addressFactory->create( + ['data' => $addressData] + ); + } + $customerData = $serialized['customer']; + $customerData['addresses'] = $addresses; + + return $this->newFactory->create([ + 'customer' => $this->customerFactory->create( + ['data' => $customerData] + ), + 'additionalData' => $serialized['delegated_data'] + ]); + } +} diff --git a/app/code/Magento/Customer/Model/EmailNotification.php b/app/code/Magento/Customer/Model/EmailNotification.php index 14ae9a885c7b1..740dc33b72710 100644 --- a/app/code/Magento/Customer/Model/EmailNotification.php +++ b/app/code/Magento/Customer/Model/EmailNotification.php @@ -3,9 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Customer\Model; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Mail\Template\SenderResolverInterface; use Magento\Store\Model\StoreManagerInterface; use Magento\Framework\Mail\Template\TransportBuilder; use Magento\Customer\Helper\View as CustomerViewHelper; @@ -90,6 +93,11 @@ class EmailNotification implements EmailNotificationInterface */ private $scopeConfig; + /** + * @var SenderResolverInterface + */ + private $senderResolver; + /** * @param CustomerRegistry $customerRegistry * @param StoreManagerInterface $storeManager @@ -97,6 +105,7 @@ class EmailNotification implements EmailNotificationInterface * @param CustomerViewHelper $customerViewHelper * @param DataObjectProcessor $dataProcessor * @param ScopeConfigInterface $scopeConfig + * @param SenderResolverInterface|null $senderResolver */ public function __construct( CustomerRegistry $customerRegistry, @@ -104,7 +113,8 @@ public function __construct( TransportBuilder $transportBuilder, CustomerViewHelper $customerViewHelper, DataObjectProcessor $dataProcessor, - ScopeConfigInterface $scopeConfig + ScopeConfigInterface $scopeConfig, + SenderResolverInterface $senderResolver = null ) { $this->customerRegistry = $customerRegistry; $this->storeManager = $storeManager; @@ -112,6 +122,7 @@ public function __construct( $this->customerViewHelper = $customerViewHelper; $this->dataProcessor = $dataProcessor; $this->scopeConfig = $scopeConfig; + $this->senderResolver = $senderResolver ?: ObjectManager::getInstance()->get(SenderResolverInterface::class); } /** @@ -230,6 +241,7 @@ private function passwordReset(CustomerInterface $customer) * @param int|null $storeId * @param string $email * @return void + * @throws \Magento\Framework\Exception\MailException */ private function sendEmailTemplate( $customer, @@ -243,10 +255,17 @@ private function sendEmailTemplate( if ($email === null) { $email = $customer->getEmail(); } + + /** @var array $from */ + $from = $this->senderResolver->resolve( + $this->scopeConfig->getValue($sender, 'store', $storeId), + $storeId + ); + $transport = $this->transportBuilder->setTemplateIdentifier($templateId) ->setTemplateOptions(['area' => 'frontend', 'store' => $storeId]) ->setTemplateVars($templateParams) - ->setFrom($this->scopeConfig->getValue($sender, 'store', $storeId)) + ->setFrom($from) ->addTo($email, $this->customerViewHelper->getCustomerName($customer)) ->getTransport(); @@ -295,9 +314,9 @@ private function getWebsiteStoreId($customer, $defaultStoreId = null) */ public function passwordReminder(CustomerInterface $customer) { - $storeId = $this->getWebsiteStoreId($customer); + $storeId = $customer->getStoreId(); if (!$storeId) { - $storeId = $this->storeManager->getStore()->getId(); + $storeId = $this->getWebsiteStoreId($customer); } $customerEmailData = $this->getFullCustomerObject($customer); diff --git a/app/code/Magento/Customer/Model/FileUploader.php b/app/code/Magento/Customer/Model/FileUploader.php index b94eff6bdff44..c425ac06666c5 100644 --- a/app/code/Magento/Customer/Model/FileUploader.php +++ b/app/code/Magento/Customer/Model/FileUploader.php @@ -110,7 +110,7 @@ public function upload() $result = $fileProcessor->saveTemporaryFile($this->scope . '[' . $this->getAttributeCode() . ']'); // Update tmp_name param. Required for attribute validation! - $result['tmp_name'] = $result['path'] . '/' . ltrim($result['file'], '/'); + $result['tmp_name'] = ltrim($result['file'], '/'); $result['url'] = $fileProcessor->getViewUrl( FileProcessor::TMP_DIR . '/' . ltrim($result['name'], '/'), diff --git a/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php b/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php index 2c7b778f5f485..7f69ab3c02bcf 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php @@ -92,7 +92,7 @@ public function __construct( $this->addressFactory = $addressFactory; $this->addressRegistry = $addressRegistry; $this->customerRegistry = $customerRegistry; - $this->addressResource = $addressResourceModel; + $this->addressResourceModel = $addressResourceModel; $this->directoryData = $directoryData; $this->addressSearchResultsFactory = $addressSearchResultsFactory; $this->addressCollectionFactory = $addressCollectionFactory; @@ -236,7 +236,7 @@ public function delete(\Magento\Customer\Api\Data\AddressInterface $address) $address = $this->addressRegistry->retrieve($addressId); $customerModel = $this->customerRegistry->retrieve($address->getCustomerId()); $customerModel->getAddressesCollection()->clear(); - $this->addressResource->delete($address); + $this->addressResourceModel->delete($address); $this->addressRegistry->remove($addressId); return true; } @@ -254,7 +254,7 @@ public function deleteById($addressId) $address = $this->addressRegistry->retrieve($addressId); $customerModel = $this->customerRegistry->retrieve($address->getCustomerId()); $customerModel->getAddressesCollection()->removeItemByKey($addressId); - $this->addressResource->delete($address); + $this->addressResourceModel->delete($address); $this->addressRegistry->remove($addressId); return true; } diff --git a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php index e66caeeb33707..12d9dc9bcaa22 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php @@ -7,15 +7,19 @@ namespace Magento\Customer\Model\ResourceModel; use Magento\Customer\Api\CustomerMetadataInterface; +use Magento\Customer\Model\Delegation\Data\NewOperation; use Magento\Customer\Model\Customer\NotificationStorage; use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\Api\ImageProcessorInterface; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Customer\Model\Delegation\Storage as DelegatedStorage; +use Magento\Framework\App\ObjectManager; /** * Customer repository. * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) */ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInterface { @@ -94,6 +98,11 @@ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInte */ private $notificationStorage; + /** + * @var DelegatedStorage + */ + private $delegatedStorage; + /** * @param \Magento\Customer\Model\CustomerFactory $customerFactory * @param \Magento\Customer\Model\Data\CustomerSecureFactory $customerSecureFactory @@ -110,6 +119,7 @@ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInte * @param \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor * @param CollectionProcessorInterface $collectionProcessor * @param NotificationStorage $notificationStorage + * @param DelegatedStorage|null $delegatedStorage * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -127,7 +137,8 @@ public function __construct( ImageProcessorInterface $imageProcessor, \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor, CollectionProcessorInterface $collectionProcessor, - NotificationStorage $notificationStorage + NotificationStorage $notificationStorage, + DelegatedStorage $delegatedStorage = null ) { $this->customerFactory = $customerFactory; $this->customerSecureFactory = $customerSecureFactory; @@ -144,15 +155,21 @@ public function __construct( $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor; $this->collectionProcessor = $collectionProcessor; $this->notificationStorage = $notificationStorage; + $this->delegatedStorage = $delegatedStorage + ?? ObjectManager::getInstance()->get(DelegatedStorage::class); } /** * {@inheritdoc} * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $passwordHash = null) { + /** @var NewOperation|null $delegatedNewOperation */ + $delegatedNewOperation = !$customer->getId() + ? $this->delegatedStorage->consumeNewOperation() : null; $prevCustomerData = null; $prevCustomerDataArr = null; if ($customer->getId()) { @@ -174,14 +191,19 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa [], \Magento\Customer\Api\Data\CustomerInterface::class ); - $customer->setAddresses($origAddresses); - $customerModel = $this->customerFactory->create(['data' => $customerData]); + /** @var Customer $customerModel */ + $customerModel = $this->customerFactory->create( + ['data' => $customerData] + ); + //Model's actual ID field maybe different than "id" + //so "id" field from $customerData may be ignored. + $customerModel->setId($customer->getId()); + $storeId = $customerModel->getStoreId(); if ($storeId === null) { $customerModel->setStoreId($this->storeManager->getStore()->getId()); } - $customerModel->setId($customer->getId()); // Need to use attribute set or future updates can cause data loss if (!$customerModel->getAttributeSetId()) { @@ -198,24 +220,35 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $customerModel->setRpToken(null); $customerModel->setRpTokenCreatedAt(null); } - if (!array_key_exists('default_billing', $customerArr) && - null !== $prevCustomerDataArr && - array_key_exists('default_billing', $prevCustomerDataArr) + if (!array_key_exists('default_billing', $customerArr) + && null !== $prevCustomerDataArr + && array_key_exists('default_billing', $prevCustomerDataArr) ) { - $customerModel->setDefaultBilling($prevCustomerDataArr['default_billing']); + $customerModel->setDefaultBilling( + $prevCustomerDataArr['default_billing'] + ); } - - if (!array_key_exists('default_shipping', $customerArr) && - null !== $prevCustomerDataArr && - array_key_exists('default_shipping', $prevCustomerDataArr) + if (!array_key_exists('default_shipping', $customerArr) + && null !== $prevCustomerDataArr + && array_key_exists('default_shipping', $prevCustomerDataArr) ) { - $customerModel->setDefaultShipping($prevCustomerDataArr['default_shipping']); + $customerModel->setDefaultShipping( + $prevCustomerDataArr['default_shipping'] + ); } $customerModel->save(); $this->customerRegistry->push($customerModel); $customerId = $customerModel->getId(); + if (!$customer->getAddresses() + && $delegatedNewOperation + && $delegatedNewOperation->getCustomer()->getAddresses() + ) { + $customer->setAddresses( + $delegatedNewOperation->getCustomer()->getAddresses() + ); + } if ($customer->getAddresses() !== null) { if ($customer->getId()) { $existingAddresses = $this->getById($customer->getId())->getAddresses(); @@ -226,7 +259,6 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa } else { $existingAddressIds = []; } - $savedAddressIds = []; foreach ($customer->getAddresses() as $address) { $address->setCustomerId($customerId) @@ -236,7 +268,6 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $savedAddressIds[] = $address->getId(); } } - $addressIdsToDelete = array_diff($existingAddressIds, $savedAddressIds); foreach ($addressIdsToDelete as $addressId) { $this->addressRepository->deleteById($addressId); @@ -244,10 +275,17 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa } $this->customerRegistry->remove($customerId); $savedCustomer = $this->get($customer->getEmail(), $customer->getWebsiteId()); + $this->eventManager->dispatch( 'customer_save_after_data_object', - ['customer_data_object' => $savedCustomer, 'orig_customer_data_object' => $prevCustomerData] + [ + 'customer_data_object' => $savedCustomer, + 'orig_customer_data_object' => $prevCustomerData, + 'delegate_data' => $delegatedNewOperation + ? $delegatedNewOperation->getAdditionalData() : [] + ] ); + return $savedCustomer; } diff --git a/app/code/Magento/Customer/Model/Visitor.php b/app/code/Magento/Customer/Model/Visitor.php index b4bad240bc825..a0530389f902a 100644 --- a/app/code/Magento/Customer/Model/Visitor.php +++ b/app/code/Magento/Customer/Model/Visitor.php @@ -6,7 +6,8 @@ namespace Magento\Customer\Model; -use Magento\Framework\Indexer\StateInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\RequestSafetyInterface; /** * Class Visitor @@ -67,6 +68,11 @@ class Visitor extends \Magento\Framework\Model\AbstractModel */ protected $indexerRegistry; + /** + * @var RequestSafetyInterface + */ + private $requestSafety; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -95,7 +101,8 @@ public function __construct( \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, array $ignoredUserAgents = [], array $ignores = [], - array $data = [] + array $data = [], + RequestSafetyInterface $requestSafety = null ) { $this->session = $session; $this->httpHeader = $httpHeader; @@ -105,6 +112,7 @@ public function __construct( $this->scopeConfig = $scopeConfig; $this->dateTime = $dateTime; $this->indexerRegistry = $indexerRegistry; + $this->requestSafety = $requestSafety ?? ObjectManager::getInstance()->get(RequestSafetyInterface::class); } /** @@ -151,10 +159,17 @@ public function initByRequest($observer) if ($this->session->getVisitorData()) { $this->setData($this->session->getVisitorData()); + if ($this->getSessionId() != $this->session->getSessionId()) { + $this->setSessionId($this->session->getSessionId()); + } } $this->setLastVisitAt((new \DateTime())->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT)); + // prevent saving Visitor for safe methods, e.g. GET request + if ($this->requestSafety->isSafeMethod()) { + return $this; + } if (!$this->getId()) { $this->setSessionId($this->session->getSessionId()); $this->save(); @@ -174,7 +189,8 @@ public function initByRequest($observer) */ public function saveByRequest($observer) { - if ($this->skipRequestLogging || $this->isModuleIgnored($observer)) { + // prevent saving Visitor for safe methods, e.g. GET request + if ($this->skipRequestLogging || $this->requestSafety->isSafeMethod() || $this->isModuleIgnored($observer)) { return $this; } diff --git a/app/code/Magento/Customer/Setup/UpgradeData.php b/app/code/Magento/Customer/Setup/UpgradeData.php index b5aba18a92f28..0ad36b1d6d11c 100644 --- a/app/code/Magento/Customer/Setup/UpgradeData.php +++ b/app/code/Magento/Customer/Setup/UpgradeData.php @@ -159,6 +159,10 @@ public function upgrade(ModuleDataSetupInterface $setup, ModuleContextInterface $this->upgradeVersionTwoZeroTwelve($customerSetup); } + if (version_compare($context->getVersion(), '2.0.13', '<')) { + $this->upgradeVersionTwoZeroThirteen($customerSetup); + } + $indexer = $this->indexerRegistry->get(Customer::CUSTOMER_GRID_INDEXER_ID); $indexer->reindexAll(); $this->eavConfig->clear(); @@ -663,4 +667,36 @@ private function upgradeCustomerPasswordResetlinkExpirationPeriodConfig($setup) ['path = ?' => \Magento\Customer\Model\Customer::XML_PATH_CUSTOMER_RESET_PASSWORD_LINK_EXPIRATION_PERIOD] ); } + + /** + * @param CustomerSetup $customerSetup + */ + private function upgradeVersionTwoZeroThirteen(CustomerSetup $customerSetup) + { + $entityAttributes = [ + 'customer_address' => [ + 'firstname' => [ + 'input_filter' => 'trim' + ], + 'lastname' => [ + 'input_filter' => 'trim' + ], + 'middlename' => [ + 'input_filter' => 'trim' + ], + ], + 'customer' => [ + 'firstname' => [ + 'input_filter' => 'trim' + ], + 'lastname' => [ + 'input_filter' => 'trim' + ], + 'middlename' => [ + 'input_filter' => 'trim' + ], + ], + ]; + $this->upgradeAttributes($entityAttributes, $customerSetup); + } } diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php index 59c940bb85297..328f1bef3a905 100644 --- a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php +++ b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php @@ -101,6 +101,49 @@ public function testExecuteNoParamsShouldThrowException() $controller->execute(); } + /** + * @expectedException \Magento\Framework\Exception\NotFoundException + * @expectedExceptionMessage Page not found. + */ + public function testExecuteInvalidFile() + { + $file = '../../../app/etc/env.php'; + $encodedFile = base64_encode($file); + $fileName = 'customer/' . $file; + $path = 'path'; + + $this->requestMock->expects($this->atLeastOnce())->method('getParam')->with('file')->willReturn($encodedFile); + + $this->directoryMock->expects($this->once())->method('getAbsolutePath')->with($fileName)->willReturn($path); + + $this->fileSystemMock->expects($this->once())->method('getDirectoryRead') + ->with(\Magento\Framework\App\Filesystem\DirectoryList::MEDIA) + ->willReturn($this->directoryMock); + + $this->storage->expects($this->once())->method('processStorageFile')->with($path)->willReturn(false); + + $this->objectManagerMock->expects($this->any())->method('get') + ->willReturnMap( + [ + [\Magento\Framework\Filesystem::class, $this->fileSystemMock], + [\Magento\MediaStorage\Helper\File\Storage::class, $this->storage], + ] + ); + + $this->urlDecoderMock->expects($this->once())->method('decode')->with($encodedFile)->willReturn($file); + $fileFactoryMock = $this->createMock(\Magento\Framework\App\Response\Http\FileFactory::class); + + $controller = $this->objectManager->getObject( + \Magento\Customer\Controller\Adminhtml\Index\Viewfile::class, + [ + 'context' => $this->contextMock, + 'urlDecoder' => $this->urlDecoderMock, + 'fileFactory' => $fileFactoryMock, + ] + ); + $controller->execute(); + } + public function testExecuteParamFile() { $decodedFile = 'decoded_file'; @@ -122,7 +165,7 @@ public function testExecuteParamFile() ->willReturnMap( [ [\Magento\Framework\Filesystem::class, $this->fileSystemMock], - [\Magento\MediaStorage\Helper\File\Storage::class, $this->storage] + [\Magento\MediaStorage\Helper\File\Storage::class, $this->storage], ] ); @@ -142,7 +185,7 @@ public function testExecuteParamFile() [ 'context' => $this->contextMock, 'urlDecoder' => $this->urlDecoderMock, - 'fileFactory' => $fileFactoryMock + 'fileFactory' => $fileFactoryMock, ] ); $controller->execute(); @@ -172,7 +215,7 @@ public function testExecuteGetParamImage() ->willReturnMap( [ [\Magento\Framework\Filesystem::class, $this->fileSystemMock], - [\Magento\MediaStorage\Helper\File\Storage::class, $this->storage] + [\Magento\MediaStorage\Helper\File\Storage::class, $this->storage], ] ); @@ -201,7 +244,7 @@ public function testExecuteGetParamImage() [ 'context' => $this->contextMock, 'urlDecoder' => $this->urlDecoderMock, - 'resultRawFactory' => $this->resultRawFactoryMock + 'resultRawFactory' => $this->resultRawFactoryMock, ] ); $this->assertSame($this->resultRawMock, $controller->execute()); diff --git a/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php index d72d7ec87ec3d..9d32065c6b64b 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php @@ -126,6 +126,21 @@ class AccountManagementTest extends \PHPUnit\Framework\TestCase */ private $accountConfirmation; + /** + * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Framework\Session\SessionManagerInterface + */ + private $sessionManager; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory + */ + private $visitorCollectionFactory; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Framework\Session\SaveHandlerInterface + */ + private $saveHandler; + /** * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -146,7 +161,9 @@ protected function setUp() $this->customerMetadata = $this->createMock(\Magento\Customer\Api\CustomerMetadataInterface::class); $this->customerRegistry = $this->createMock(\Magento\Customer\Model\CustomerRegistry::class); $this->logger = $this->createMock(\Psr\Log\LoggerInterface::class); - $this->encryptor = $this->createMock(\Magento\Framework\Encryption\EncryptorInterface::class); + $this->encryptor = $this->getMockBuilder(\Magento\Framework\Encryption\EncryptorInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); $this->share = $this->createMock(\Magento\Customer\Model\Config\Share::class); $this->string = $this->createMock(\Magento\Framework\Stdlib\StringUtils::class); $this->customerRepository = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); @@ -171,7 +188,20 @@ protected function setUp() ->getMock(); $this->customerSecure = $this->getMockBuilder(\Magento\Customer\Model\Data\CustomerSecure::class) - ->setMethods(['setRpToken', 'addData', 'setRpTokenCreatedAt', 'setData']) + ->setMethods(['setRpToken', 'addData', 'setRpTokenCreatedAt', 'setData', 'getPasswordHash']) + ->disableOriginalConstructor() + ->getMock(); + + $this->visitorCollectionFactory = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->sessionManager = $this->getMockBuilder(\Magento\Framework\Session\SessionManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->saveHandler = $this->getMockBuilder(\Magento\Framework\Session\SaveHandlerInterface::class) ->disableOriginalConstructor() ->getMock(); @@ -206,7 +236,10 @@ protected function setUp() 'objectFactory' => $this->objectFactory, 'extensibleDataObjectConverter' => $this->extensibleDataObjectConverter, 'dateTimeFactory' => $this->dateTimeFactory, - 'accountConfirmation' => $this->accountConfirmation + 'accountConfirmation' => $this->accountConfirmation, + 'sessionManager' => $this->sessionManager, + 'saveHandler' => $this->saveHandler, + 'visitorCollectionFactory' => $this->visitorCollectionFactory, ] ); $reflection = new \ReflectionClass(get_class($this->accountManagement)); @@ -1274,7 +1307,16 @@ private function reInitModel() { $this->customerSecure = $this->getMockBuilder(\Magento\Customer\Model\Data\CustomerSecure::class) ->disableOriginalConstructor() - ->setMethods(['getRpToken', 'getRpTokenCreatedAt']) + ->setMethods( + [ + 'getRpToken', + 'getRpTokenCreatedAt', + 'getPasswordHash', + 'setPasswordHash', + 'setRpToken', + 'setRpTokenCreatedAt', + ] + ) ->getMock(); $this->customerSecure @@ -1296,6 +1338,42 @@ private function reInitModel() $this->prepareDateTimeFactory(); + $this->sessionManager = $this->getMockBuilder(\Magento\Framework\Session\SessionManagerInterface::class) + ->disableOriginalConstructor() + ->setMethods(['destroy', 'start', 'writeClose']) + ->getMockForAbstractClass(); + $this->visitorCollectionFactory = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->saveHandler = $this->getMockBuilder(\Magento\Framework\Session\SaveHandlerInterface::class) + ->disableOriginalConstructor() + ->setMethods(['destroy']) + ->getMockForAbstractClass(); + + $dateTime = '2017-10-25 18:57:08'; + $timestamp = '1508983028'; + $dateTimeMock = $this->createMock(\DateTime::class); + $dateTimeMock->expects($this->any()) + ->method('format') + ->with(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT) + ->willReturn($dateTime); + + $dateTimeMock + ->expects($this->any()) + ->method('getTimestamp') + ->willReturn($timestamp); + + $dateTimeMock + ->expects($this->any()) + ->method('setTimestamp') + ->willReturnSelf(); + + $dateTimeFactory = $this->createMock(DateTimeFactory::class); + $dateTimeFactory->expects($this->any())->method('create')->willReturn($dateTimeMock); + $this->objectManagerHelper = new ObjectManagerHelper($this); $this->accountManagement = $this->objectManagerHelper->getObject( \Magento\Customer\Model\AccountManagement::class, @@ -1304,7 +1382,16 @@ private function reInitModel() 'customerRegistry' => $this->customerRegistry, 'customerRepository' => $this->customerRepository, 'customerModel' => $this->customer, - 'dateTimeFactory' => $this->dateTimeFactory, + 'dateTimeFactory' => $dateTimeFactory, + 'stringHelper' => $this->string, + 'scopeConfig' => $this->scopeConfig, + 'sessionManager' => $this->sessionManager, + 'visitorCollectionFactory' => $this->visitorCollectionFactory, + 'saveHandler' => $this->saveHandler, + 'encryptor' => $this->encryptor, + 'dataProcessor' => $this->dataObjectProcessor, + 'storeManager' => $this->storeManager, + 'transportBuilder' => $this->transportBuilder, ] ); $reflection = new \ReflectionClass(get_class($this->accountManagement)); @@ -1324,6 +1411,7 @@ public function testChangePassword() $newPassword = 'abcdefg'; $passwordHash = '1a2b3f4c'; + $this->reInitModel(); $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class) ->getMock(); $customer->expects($this->any()) @@ -1339,24 +1427,20 @@ public function testChangePassword() $this->authenticationMock->expects($this->once()) ->method('authenticate'); - $customerSecure = $this->getMockBuilder(\Magento\Customer\Model\Data\CustomerSecure::class) - ->setMethods(['setRpToken', 'setRpTokenCreatedAt', 'getPasswordHash']) - ->disableOriginalConstructor() - ->getMock(); - $customerSecure->expects($this->once()) + $this->customerSecure->expects($this->once()) ->method('setRpToken') ->with(null); - $customerSecure->expects($this->once()) + $this->customerSecure->expects($this->once()) ->method('setRpTokenCreatedAt') ->willReturnSelf(); - $customerSecure->expects($this->any()) + $this->customerSecure->expects($this->any()) ->method('getPasswordHash') ->willReturn($passwordHash); $this->customerRegistry->expects($this->any()) ->method('retrieveSecureData') ->with($customerId) - ->willReturn($customerSecure); + ->willReturn($this->customerSecure); $this->scopeConfig->expects($this->any()) ->method('getValue') @@ -1386,9 +1470,85 @@ public function testChangePassword() ->method('save') ->with($customer); + $this->sessionManager->expects($this->atLeastOnce())->method('start'); + $this->sessionManager->expects($this->atLeastOnce())->method('writeClose'); + $this->sessionManager->expects($this->atLeastOnce())->method('getSessionId'); + + $visitor = $this->getMockBuilder(\Magento\Customer\Model\Visitor::class) + ->disableOriginalConstructor() + ->setMethods(['getSessionId']) + ->getMock(); + $visitor->expects($this->at(0))->method('getSessionId')->willReturn('session_id_1'); + $visitor->expects($this->at(1))->method('getSessionId')->willReturn('session_id_2'); + $visitorCollection = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor()->setMethods(['addFieldToFilter', 'getItems'])->getMock(); + $visitorCollection->expects($this->atLeastOnce())->method('addFieldToFilter')->willReturnSelf(); + $visitorCollection->expects($this->atLeastOnce())->method('getItems')->willReturn([$visitor, $visitor]); + $this->visitorCollectionFactory->expects($this->atLeastOnce())->method('create') + ->willReturn($visitorCollection); + $this->saveHandler->expects($this->at(0))->method('destroy')->with('session_id_1'); + $this->saveHandler->expects($this->at(1))->method('destroy')->with('session_id_2'); + $this->assertTrue($this->accountManagement->changePassword($email, $currentPassword, $newPassword)); } + public function testResetPassword() + { + $customerEmail = 'customer@example.com'; + $customerId = '1'; + $resetToken = 'newStringToken'; + $newPassword = 'new_password'; + + $this->reInitModel(); + $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class)->getMock(); + $customer->expects($this->any())->method('getId')->willReturn($customerId); + $this->customerRepository->expects($this->atLeastOnce())->method('get')->with($customerEmail) + ->willReturn($customer); + $this->customer->expects($this->atLeastOnce())->method('getResetPasswordLinkExpirationPeriod') + ->willReturn(100000); + $this->string->expects($this->any())->method('strlen')->willReturnCallback( + function ($string) { + return strlen($string); + } + ); + $this->customerRegistry->expects($this->atLeastOnce())->method('retrieveSecureData') + ->willReturn($this->customerSecure); + + $this->customerSecure->expects($this->once()) + ->method('setRpToken') + ->with(null); + $this->customerSecure->expects($this->once()) + ->method('setRpTokenCreatedAt') + ->with(null); + $this->customerSecure->expects($this->any()) + ->method('setPasswordHash') + ->willReturn(null); + + $this->sessionManager->expects($this->atLeastOnce())->method('destroy'); + $this->sessionManager->expects($this->atLeastOnce())->method('start'); + $this->sessionManager->expects($this->atLeastOnce())->method('writeClose'); + $this->sessionManager->expects($this->atLeastOnce())->method('getSessionId'); + $visitor = $this->getMockBuilder(\Magento\Customer\Model\Visitor::class) + ->disableOriginalConstructor() + ->setMethods(['getSessionId']) + ->getMock(); + $visitor->expects($this->at(0))->method('getSessionId')->willReturn('session_id_1'); + $visitor->expects($this->at(1))->method('getSessionId')->willReturn('session_id_2'); + $visitorCollection = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor()->setMethods(['addFieldToFilter', 'getItems'])->getMock(); + $visitorCollection->expects($this->atLeastOnce())->method('addFieldToFilter')->willReturnSelf(); + $visitorCollection->expects($this->atLeastOnce())->method('getItems')->willReturn([$visitor, $visitor]); + $this->visitorCollectionFactory->expects($this->atLeastOnce())->method('create') + ->willReturn($visitorCollection); + $this->saveHandler->expects($this->at(0))->method('destroy')->with('session_id_1'); + $this->saveHandler->expects($this->at(1))->method('destroy')->with('session_id_2'); + $this->assertTrue($this->accountManagement->resetPassword($customerEmail, $resetToken, $newPassword)); + } + /** * @return void */ diff --git a/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php b/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php index 2eef9a44cab74..453ec208e8846 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php @@ -41,6 +41,9 @@ class AbstractAddressTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Customer\Model\Address\AbstractAddress */ protected $model; + /** @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager */ + private $objectManager; + protected function setUp() { $this->contextMock = $this->createMock(\Magento\Framework\Model\Context::class); @@ -69,8 +72,8 @@ protected function setUp() $this->resourceCollectionMock = $this->getMockBuilder(\Magento\Framework\Data\Collection\AbstractDb::class) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->model = $objectManager->getObject( + $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->model = $this->objectManager->getObject( \Magento\Customer\Model\Address\AbstractAddress::class, [ 'context' => $this->contextMock, @@ -275,13 +278,14 @@ public function testSetDataWithObject() } /** - * @param $data - * @param $expected + * @param array $data + * @param array|bool $expected * * @dataProvider validateDataProvider */ - public function testValidate($data, $expected) + public function testValidate(array $data, $expected) { + $countryId = isset($data['country_id']) ? $data['country_id'] : null; $attributeMock = $this->createMock(\Magento\Eav\Model\Entity\Attribute::class); $attributeMock->expects($this->any()) ->method('getIsRequired') @@ -295,9 +299,50 @@ public function testValidate($data, $expected) ->method('getCountriesWithOptionalZip') ->will($this->returnValue([])); - $this->directoryDataMock->expects($this->never()) + $this->directoryDataMock->expects($this->any()) ->method('isRegionRequired'); + $countryCollectionMock = $this->getMockBuilder(\Magento\Directory\Model\ResourceModel\Country\Collection::class) + ->disableOriginalConstructor() + ->setMethods(['getAllIds']) + ->getMock(); + + $this->directoryDataMock->expects($this->any()) + ->method('getCountryCollection') + ->willReturn($countryCollectionMock); + + $countryCollectionMock->expects($this->any())->method('getAllIds')->willReturn([$countryId]); + + $regionModelMock = $this->getMockBuilder(\Magento\Directory\Model\Region::class) + ->disableOriginalConstructor() + ->setMethods(['getCountryId', 'getName', 'load']) + ->getMock(); + + $this->regionFactoryMock->expects($this->any())->method('create')->willReturn($regionModelMock); + + $regionModelMock->expects($this->any())->method('load')->with($data['region_id'])->willReturnSelf(); + $regionModelMock->expects($this->any())->method('getCountryId')->willReturn($countryId); + $regionModelMock->expects($this->any())->method('getName')->willReturn('RegionName'); + + $countryModelMock = $this->getMockBuilder(\Magento\Directory\Model\Country::class) + ->disableOriginalConstructor() + ->setMethods(['getRegionCollection', 'load']) + ->getMock(); + + $this->objectManager->setBackwardCompatibleProperty( + $this->model, + '_countryModels', + [$countryId => $countryModelMock] + ); + + $countryModelMock->expects($this->any())->method('load')->with($countryId, null)->willReturnSelf(); + $regionCollectionMock = $this->getMockBuilder(\Magento\Directory\Model\ResourceModel\Region\Collection::class) + ->disableOriginalConstructor() + ->setMethods(['getAllIds']) + ->getMock(); + $countryModelMock->expects($this->any())->method('getRegionCollection')->willReturn($regionCollectionMock); + $regionCollectionMock->expects($this->any())->method('getAllIds')->willReturn(['1']); + foreach ($data as $key => $value) { $this->model->setData($key, $value); } @@ -349,6 +394,10 @@ public function validateDataProvider() array_merge(array_diff_key($data, ['postcode' => '']), ['country_id' => $countryId++]), ['postcode is a required field.'], ], + 'region_id' => [ + array_merge($data, ['country_id' => $countryId++, 'region_id' => 2]), + ['Invalid value of "2" provided for the regionId field.'], + ], 'country_id' => [ array_diff_key($data, ['country_id' => '']), ['countryId is a required field.'], @@ -388,4 +437,13 @@ public function getStreetFullDataProvider() ['single line', 'single line'], ]; } + + protected function tearDown() + { + $this->objectManager->setBackwardCompatibleProperty( + $this->model, + '_countryModels', + [] + ); + } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Source/WebsiteTest.php b/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Source/WebsiteTest.php index 11e3d602ddf90..2f35ab9be4804 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Source/WebsiteTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Source/WebsiteTest.php @@ -7,6 +7,8 @@ use Magento\Customer\Model\Customer\Attribute\Source\Website; use Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\CollectionFactory; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\ObjectManagerInterface; class WebsiteTest extends \PHPUnit\Framework\TestCase { @@ -22,6 +24,9 @@ class WebsiteTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Store\Model\System\Store|\PHPUnit_Framework_MockObject_MockObject */ protected $storeMock; + /** @var ObjectManagerInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $objectManagerMock; + protected function setUp() { $this->collectionFactoryMock = @@ -36,6 +41,20 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->objectManagerMock = $this->getMockBuilder(ObjectManagerInterface::class) + ->setMethods(['get']) + ->getMockForAbstractClass(); + + $escaper = $this->getMockBuilder(\Magento\Framework\Escaper::class) + ->disableOriginalConstructor() + ->getMock(); + + ObjectManager::setInstance($this->objectManagerMock); + $this->objectManagerMock->expects($this->any()) + ->method('get') + ->with(\Magento\Framework\Escaper::class) + ->willReturn($escaper); + $this->model = new Website( $this->collectionFactoryMock, $this->optionFactoryMock, @@ -91,4 +110,12 @@ public function testGetOptionTextWithoutOption() $this->assertEquals(false, $this->model->getOptionText('value')); } + + protected function tearDown() + { + $property = (new \ReflectionClass(ObjectManager::class))->getProperty('_instance'); + $property->setAccessible(true); + $property->setValue(null, null); + parent::tearDown(); + } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php index 0240b7ab29ab7..49a2d5e51f8f8 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php @@ -3,10 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Customer\Test\Unit\Model; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\EmailNotification; use Magento\Framework\App\Area; +use Magento\Framework\Mail\Template\SenderResolverInterface; use Magento\Store\Model\ScopeInterface; /** @@ -47,7 +50,7 @@ class EmailNotificationTest extends \PHPUnit\Framework\TestCase private $customerSecureMock; /** - * @var \Magento\Framework\App\Config\ScopeConfigInterface + * @var \Magento\Framework\App\Config\ScopeConfigInterface | \PHPUnit_Framework_MockObject_MockObject */ private $scopeConfigMock; @@ -61,6 +64,11 @@ class EmailNotificationTest extends \PHPUnit\Framework\TestCase */ private $model; + /** + * @var SenderResolverInterface | \PHPUnit_Framework_MockObject_MockObject + */ + private $senderResolverMock; + public function setUp() { $this->customerRegistryMock = $this->createMock(\Magento\Customer\Model\CustomerRegistry::class); @@ -88,17 +96,23 @@ public function setUp() $this->storeMock = $this->createMock(\Magento\Store\Model\Store::class); + $this->senderResolverMock = $this->getMockBuilder(SenderResolverInterface::class) + ->setMethods(['resolve']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->model = $objectManager->getObject( EmailNotification::class, [ - 'customerRegistry' => $this->customerRegistryMock, + 'customerRegistry' => $this->customerRegistryMock, 'storeManager' => $this->storeManagerMock, 'transportBuilder' => $this->transportBuilderMock, 'customerViewHelper' => $this->customerViewHelperMock, 'dataProcessor' => $this->dataProcessorMock, - 'scopeConfig' => $this->scopeConfigMock + 'scopeConfig' => $this->scopeConfigMock, + 'senderResolver' => $this->senderResolverMock ] ); } @@ -121,7 +135,10 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $expects = $this->once(); + $xmlPathTemplate = EmailNotification::XML_PATH_RESET_PASSWORD_TEMPLATE; switch ($testNumber) { case 1: $xmlPathTemplate = EmailNotification::XML_PATH_RESET_PASSWORD_TEMPLATE; @@ -137,7 +154,14 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas break; } - $origCustomer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $this->senderResolverMock + ->expects($expects) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var \PHPUnit_Framework_MockObject_MockObject $origCustomer */ + $origCustomer = $this->createMock(CustomerInterface::class); $origCustomer->expects($this->any()) ->method('getStoreId') ->willReturn(0); @@ -175,7 +199,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas $this->dataProcessorMock->expects(clone $expects) ->method('buildOutputDataArray') - ->with($origCustomer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($origCustomer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -192,6 +216,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas ->with('name', $customerName) ->willReturnSelf(); + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $savedCustomer */ $savedCustomer = clone $origCustomer; $origCustomer->expects($this->any()) @@ -234,7 +259,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas ->willReturnSelf(); $this->transportBuilderMock->expects(clone $expects) ->method('setFrom') - ->with($sender) + ->with($senderValues) ->willReturnSelf(); $this->transportBuilderMock->expects(clone $expects) @@ -287,14 +312,27 @@ public function sendNotificationEmailsDataProvider() public function testPasswordReminder() { $customerId = 1; + $customerWebsiteId = 1; $customerStoreId = 2; $customerEmail = 'email@email.com'; $customerData = ['key' => 'value']; $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; - - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $senderValues = ['name' => $sender, 'email' => $sender]; + $storeIds = [1, 2]; + + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); + $customer->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($customerWebsiteId); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -313,11 +351,16 @@ public function testPasswordReminder() ->method('getStore') ->willReturn($this->storeMock); - $this->storeManagerMock->expects($this->at(1)) - ->method('getStore') - ->with($customerStoreId) - ->willReturn($this->storeMock); + $websiteMock = $this->createPartialMock(\Magento\Store\Model\Website::class, ['getStoreIds']); + $websiteMock->expects($this->any()) + ->method('getStoreIds') + ->willReturn($storeIds); + $this->storeManagerMock->expects($this->any()) + ->method('getWebsite') + ->with($customerWebsiteId) + ->willReturn($websiteMock); + $this->customerRegistryMock->expects($this->once()) ->method('retrieveSecureData') ->with($customerId) @@ -325,7 +368,7 @@ public function testPasswordReminder() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -351,34 +394,120 @@ public function testPasswordReminder() ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); - $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateIdentifier') - ->with($templateIdentifier) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateOptions') - ->with(['area' => Area::AREA_FRONTEND, 'store' => $customerStoreId]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'store' => $this->storeMock]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setFrom') - ->with($sender) + $this->model->passwordReminder($customer); + } + + /** + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testPasswordReminderCustomerWithoutStoreId() + { + $customerId = 1; + $customerWebsiteId = 1; + $customerStoreId = null; + $customerEmail = 'email@email.com'; + $customerData = ['key' => 'value']; + $customerName = 'Customer Name'; + $templateIdentifier = 'Template Identifier'; + $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $storeIds = [1, 2]; + $defaultStoreId = reset($storeIds); + + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $defaultStoreId) + ->willReturn($senderValues); + + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); + $customer->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($customerWebsiteId); + $customer->expects($this->any()) + ->method('getStoreId') + ->willReturn($customerStoreId); + $customer->expects($this->any()) + ->method('getId') + ->willReturn($customerId); + $customer->expects($this->any()) + ->method('getEmail') + ->willReturn($customerEmail); + + $this->storeMock->expects($this->any()) + ->method('getId') + ->willReturn($defaultStoreId); + + $this->storeManagerMock->expects($this->at(0)) + ->method('getStore') + ->willReturn($this->storeMock); + + $this->storeManagerMock->expects($this->at(1)) + ->method('getStore') + ->with($defaultStoreId) + ->willReturn($this->storeMock); + + $websiteMock = $this->createPartialMock(\Magento\Store\Model\Website::class, ['getStoreIds']); + $websiteMock->expects($this->any()) + ->method('getStoreIds') + ->willReturn($storeIds); + + $this->storeManagerMock->expects($this->any()) + ->method('getWebsite') + ->with($customerWebsiteId) + ->willReturn($websiteMock); + + $this->customerRegistryMock->expects($this->once()) + ->method('retrieveSecureData') + ->with($customerId) + ->willReturn($this->customerSecureMock); + + $this->dataProcessorMock->expects($this->once()) + ->method('buildOutputDataArray') + ->with($customer, CustomerInterface::class) + ->willReturn($customerData); + + $this->customerViewHelperMock->expects($this->any()) + ->method('getCustomerName') + ->with($customer) + ->willReturn($customerName); + + $this->customerSecureMock->expects($this->once()) + ->method('addData') + ->with($customerData) ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('addTo') - ->with($customerEmail, $customerName) + $this->customerSecureMock->expects($this->once()) + ->method('setData') + ->with('name', $customerName) ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('getTransport') - ->willReturn($transport); - $transport->expects($this->once()) - ->method('sendMessage'); + $this->scopeConfigMock->expects($this->at(0)) + ->method('getValue') + ->with(EmailNotification::XML_PATH_REMIND_EMAIL_TEMPLATE, ScopeInterface::SCOPE_STORE, $defaultStoreId) + ->willReturn($templateIdentifier); + $this->scopeConfigMock->expects($this->at(1)) + ->method('getValue') + ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $defaultStoreId) + ->willReturn($sender); + + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $defaultStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); $this->model->passwordReminder($customer); } @@ -395,8 +524,16 @@ public function testPasswordResetConfirmation() $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -427,7 +564,7 @@ public function testPasswordResetConfirmation() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -453,34 +590,14 @@ public function testPasswordResetConfirmation() ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); - $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); - - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateIdentifier') - ->with($templateIdentifier) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateOptions') - ->with(['area' => Area::AREA_FRONTEND, 'store' => $customerStoreId]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'store' => $this->storeMock]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setFrom') - ->with($sender) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('addTo') - ->with($customerEmail, $customerName) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('getTransport') - ->willReturn($transport); - - $transport->expects($this->once()) - ->method('sendMessage'); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); $this->model->passwordResetConfirmation($customer); } @@ -497,8 +614,16 @@ public function testNewAccount() $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -525,7 +650,7 @@ public function testNewAccount() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -551,6 +676,36 @@ public function testNewAccount() ->with(EmailNotification::XML_PATH_REGISTER_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'back_url' => '', 'store' => $this->storeMock] + ); + + $this->model->newAccount($customer, EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, '', $customerStoreId); + } + + /** + * Create defaul mock for $this->transportBuilderMock + * + * @param string $templateIdentifier + * @param int $customerStoreId + * @param array $senderValues + * @param string $customerEmail + * @param string $customerName + * @param array $templateVars + */ + protected function mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + array $senderValues, + $customerEmail, + $customerName, + array $templateVars = [] + ) { $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); $this->transportBuilderMock->expects($this->once()) @@ -563,11 +718,11 @@ public function testNewAccount() ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'back_url' => '', 'store' => $this->storeMock]) + ->with($templateVars) ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('setFrom') - ->with($sender) + ->with($senderValues) ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('addTo') @@ -579,7 +734,5 @@ public function testNewAccount() $transport->expects($this->once()) ->method('sendMessage'); - - $this->model->newAccount($customer, EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, '', $customerStoreId); } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/FileUploaderTest.php b/app/code/Magento/Customer/Test/Unit/Model/FileUploaderTest.php index f2489c8626a4f..82e1c31b54b92 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/FileUploaderTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/FileUploaderTest.php @@ -140,7 +140,7 @@ public function testUpload() 'name' => $resultFileName, 'file' => $resultFileName, 'path' => $resultFilePath, - 'tmp_name' => $resultFilePath . $resultFileName, + 'tmp_name' => ltrim($resultFileName, '/'), 'url' => $resultFileUrl, ]; diff --git a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsitesTest.php b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsitesTest.php index b083bea54cb82..86ee21aef994b 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsitesTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsitesTest.php @@ -12,7 +12,13 @@ use Magento\Framework\Data\Collection\AbstractDb; use Magento\Store\Api\Data\WebsiteInterface; use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\ObjectManagerInterface; +/** + * Tests for \Magento\Customer\Model\ResourceModel\Address\Attribute\Source\CountryWithWebsites + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CountryWithWebsitesTest extends \PHPUnit\Framework\TestCase { /** @@ -40,6 +46,9 @@ class CountryWithWebsitesTest extends \PHPUnit\Framework\TestCase */ private $shareConfigMock; + /** @var ObjectManagerInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $objectManagerMock; + public function setUp() { $this->countriesFactoryMock = @@ -62,6 +71,21 @@ public function setUp() $this->shareConfigMock = $this->getMockBuilder(Share::class) ->disableOriginalConstructor() ->getMock(); + + $this->objectManagerMock = $this->getMockBuilder(ObjectManagerInterface::class) + ->setMethods(['get']) + ->getMockForAbstractClass(); + + $escaper = $this->getMockBuilder(\Magento\Framework\Escaper::class) + ->disableOriginalConstructor() + ->getMock(); + + ObjectManager::setInstance($this->objectManagerMock); + $this->objectManagerMock->expects($this->any()) + ->method('get') + ->with(\Magento\Framework\Escaper::class) + ->willReturn($escaper); + $this->countryByWebsite = new CountryWithWebsites( $eavCollectionFactoryMock, $optionsFactoryMock, @@ -117,4 +141,12 @@ public function testGetAllOptions() ['value' => 'AM', 'label' => 'UZ', 'website_ids' => [1, 2]] ], $this->countryByWebsite->getAllOptions()); } + + protected function tearDown() + { + $property = (new \ReflectionClass(ObjectManager::class))->getProperty('_instance'); + $property->setAccessible(true); + $property->setValue(null, null); + parent::tearDown(); + } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php index 61c25641df6cd..41816d26982d6 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php @@ -419,7 +419,11 @@ public function testSave() ->method('dispatch') ->with( 'customer_save_after_data_object', - ['customer_data_object' => $this->customer, 'orig_customer_data_object' => $origCustomer] + [ + 'customer_data_object' => $this->customer, + 'orig_customer_data_object' => $origCustomer, + 'delegate_data' => [], + ] ); $this->model->save($this->customer); @@ -646,7 +650,11 @@ public function testSaveWithPasswordHash() ->method('dispatch') ->with( 'customer_save_after_data_object', - ['customer_data_object' => $this->customer, 'orig_customer_data_object' => $origCustomer] + [ + 'customer_data_object' => $this->customer, + 'orig_customer_data_object' => $origCustomer, + 'delegate_data' => [], + ] ); $this->model->save($this->customer, $passwordHash); diff --git a/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php b/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php index ca6b8708f695c..e1f3113be8ca1 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php @@ -77,14 +77,14 @@ protected function setUp() public function testInitByRequest() { - $this->session->expects($this->once())->method('getSessionId') - ->will($this->returnValue('asdfhasdfjhkj2198sadf8sdf897')); + $oldSessionId = 'asdfhasdfjhkj2198sadf8sdf897'; + $newSessionId = 'bsdfhasdfjhkj2198sadf8sdf897'; + $this->session->expects($this->any())->method('getSessionId') + ->will($this->returnValue($newSessionId)); + $this->session->expects($this->atLeastOnce())->method('getVisitorData') + ->willReturn(['session_id' => $oldSessionId]); $this->visitor->initByRequest(null); - $this->assertEquals('asdfhasdfjhkj2198sadf8sdf897', $this->visitor->getSessionId()); - - $this->visitor->setData(['visitor_id' => 1]); - $this->visitor->initByRequest(null); - $this->assertNull($this->visitor->getSessionId()); + $this->assertEquals($newSessionId, $this->visitor->getSessionId()); } public function testSaveByRequest() diff --git a/app/code/Magento/Customer/composer.json b/app/code/Magento/Customer/composer.json index bb64ccc076cc6..fd94ffba2a43e 100644 --- a/app/code/Magento/Customer/composer.json +++ b/app/code/Magento/Customer/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-customer", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-eav": "101.0.*", "magento/module-directory": "100.2.*", @@ -29,7 +29,7 @@ "magento/module-customer-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.2", + "version": "101.0.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Customer/etc/di.xml b/app/code/Magento/Customer/etc/di.xml index 40ef730120783..bd383ba237583 100644 --- a/app/code/Magento/Customer/etc/di.xml +++ b/app/code/Magento/Customer/etc/di.xml @@ -407,4 +407,12 @@ + + + Magento\Framework\Session\SessionManagerInterface\Proxy + + + diff --git a/app/code/Magento/Customer/etc/events.xml b/app/code/Magento/Customer/etc/events.xml index 66c9a3813892c..d841d8faa9c38 100644 --- a/app/code/Magento/Customer/etc/events.xml +++ b/app/code/Magento/Customer/etc/events.xml @@ -10,7 +10,7 @@ - + diff --git a/app/code/Magento/Customer/etc/module.xml b/app/code/Magento/Customer/etc/module.xml index 3f0d42b12649a..2dfe561d0da8f 100644 --- a/app/code/Magento/Customer/etc/module.xml +++ b/app/code/Magento/Customer/etc/module.xml @@ -6,7 +6,7 @@ */ --> - + diff --git a/app/code/Magento/Customer/etc/webapi_rest/di.xml b/app/code/Magento/Customer/etc/webapi_rest/di.xml index f2457963a5f3d..5f3ca2fdb7453 100644 --- a/app/code/Magento/Customer/etc/webapi_rest/di.xml +++ b/app/code/Magento/Customer/etc/webapi_rest/di.xml @@ -13,7 +13,7 @@ - Magento\Customer\Model\Authorization\CustomerSessionUserContext + Magento\Customer\Model\Authorization\CustomerSessionUserContext\Proxy 20 diff --git a/app/code/Magento/Customer/view/frontend/layout/customer_account.xml b/app/code/Magento/Customer/view/frontend/layout/customer_account.xml index ac03fa7d293a4..4224e84972f88 100644 --- a/app/code/Magento/Customer/view/frontend/layout/customer_account.xml +++ b/app/code/Magento/Customer/view/frontend/layout/customer_account.xml @@ -6,6 +6,9 @@ */ --> + + My Account + diff --git a/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml b/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml index 7881b7e857fd9..ac8e1298b29b9 100644 --- a/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml @@ -20,6 +20,7 @@ escapeHtml($block->getName()) ?>
escapeHtml($block->getCustomer()->getEmail()) ?>

+ getChildHtml('customer.account.dashboard.info.extra'); ?>
escapeHtml(__('Edit')) ?> diff --git a/app/code/Magento/Customer/view/frontend/web/js/action/login.js b/app/code/Magento/Customer/view/frontend/web/js/action/login.js index 92c0ee0c3d3d6..6c7096190e38f 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/action/login.js +++ b/app/code/Magento/Customer/view/frontend/web/js/action/login.js @@ -7,8 +7,9 @@ define([ 'jquery', 'mage/storage', 'Magento_Ui/js/model/messageList', - 'Magento_Customer/js/customer-data' -], function ($, storage, globalMessageList, customerData) { + 'Magento_Customer/js/customer-data', + 'mage/translate' +], function ($, storage, globalMessageList, customerData, $t) { 'use strict'; var callbacks = [], @@ -48,7 +49,7 @@ define([ } }).fail(function () { messageContainer.addErrorMessage({ - 'message': 'Could not authenticate. Please try again later' + 'message': $t('Could not authenticate. Please try again later') }); callbacks.forEach(function (callback) { callback(loginData); 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 c4672c48e1f4a..15df80f360bf7 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 @@ -232,9 +232,11 @@ define([ if (!_.isEmpty(privateContent)) { countryData = this.get('directory-data'); - if (_.isEmpty(countryData())) { - customerData.reload(['directory-data'], false); - } + countryData.subscribe(function () { + if (_.isEmpty(countryData())) { + customerData.reload(['directory-data'], false); + } + }, this); } }, diff --git a/app/code/Magento/Customer/view/frontend/web/js/invalidation-processor.js b/app/code/Magento/Customer/view/frontend/web/js/invalidation-processor.js index d99574ec3dfbf..a6ae8ff043aa8 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/invalidation-processor.js +++ b/app/code/Magento/Customer/view/frontend/web/js/invalidation-processor.js @@ -1,5 +1,5 @@ /** - * Copyright © 2013-2017 Magento, Inc. All rights reserved. + * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ define([ diff --git a/app/code/Magento/Customer/view/frontend/web/js/invalidation-rules/website-rule.js b/app/code/Magento/Customer/view/frontend/web/js/invalidation-rules/website-rule.js index eb7f101a6d47e..846edb2c836fa 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/invalidation-rules/website-rule.js +++ b/app/code/Magento/Customer/view/frontend/web/js/invalidation-rules/website-rule.js @@ -1,5 +1,5 @@ /** - * Copyright © 2013-2017 Magento, Inc. All rights reserved. + * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ define([ diff --git a/app/code/Magento/Customer/view/frontend/web/js/password-strength-indicator.js b/app/code/Magento/Customer/view/frontend/web/js/password-strength-indicator.js index 71ca63f6750bb..be2e0aedfe4bb 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/password-strength-indicator.js +++ b/app/code/Magento/Customer/view/frontend/web/js/password-strength-indicator.js @@ -31,6 +31,8 @@ define([ this.options.cache.label = $(this.options.passwordStrengthMeterLabelSelector, this.element); // We need to look outside the module for backward compatibility, since someone can already use the module. + // @todo Narrow this selector in 2.3 so it doesn't accidentally finds the the email field from the + // newsletter email field or any other "email" field. this.options.cache.email = $(this.options.formSelector).find(this.options.emailSelector); this._bind(); }, @@ -74,7 +76,9 @@ define([ 'password-not-equal-to-user-name': this.options.cache.email.val() }); - if (password.toLowerCase() === this.options.cache.email.val().toLowerCase()) { + // We should only perform this check in case there is an email field on screen + if (this.options.cache.email.length && + password.toLowerCase() === this.options.cache.email.val().toLowerCase()) { displayScore = 1; } else { isValid = $.validator.validateSingleElement(this.options.cache.input); diff --git a/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html b/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html index ad3d62f6c1c27..6b3a232cd3e39 100644 --- a/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html +++ b/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html @@ -54,10 +54,10 @@ id="login-form">
Action
- +
+ - - - - + + + - getShippingAddressItems($_address) as $_item): ?> - getRowItemHtml($_item) ?> + getShippingAddressItems($address) as $item) : ?> + getRowItemHtml($item) ?> - renderTotals($block->getShippingAddressTotals($_address)) ?> + renderTotals( + $block->getShippingAddressTotals($address) + ); ?>
escapeHtml(__('Order Review')); ?>
- + escapeHtml(__('Item')); ?> + + escapeHtml(__('Edit')); ?> + escapeHtml(__('Price')); ?>escapeHtml(__('Qty')); ?>escapeHtml(__('Subtotal')); ?>
@@ -112,33 +153,40 @@ - getQuote()->hasVirtualItems()): ?> + getQuote()->hasVirtualItems()) : ?>
-
+ getQuote()->getBillingAddress(); ?> + +
escapeHtml(__('Other items in your order')); ?>
+ getCheckoutData()->getAddressError($billingAddress)) :?> +
escapeHtml($error); ?>
+
- - + escapeHtml(__('Items')); ?> + escapeHtml(__('Edit Items')); ?> - helper('Magento\Tax\Helper\Data')->displayCartBothPrices() ? 2 : 1); ?> + helper(Magento\Tax\Helper\Data::class)->displayCartBothPrices() ? 2 : 1); ?>
- + - - - - + + + + - getVirtualItems() as $_item): ?> - getRowItemHtml($_item) ?> + getVirtualItems() as $_item) : ?> + getRowItemHtml($_item) ?> - renderTotals($block->getBillinAddressTotals()) ?> + renderTotals($block->getBillinAddressTotals()); ?>
escapeHtml(__('Items')); ?>
escapeHtml(__('Product Name')); ?>escapeHtml(__('Price')); ?>escapeHtml(__('Qty')); ?>escapeHtml(__('Subtotal')); ?>
@@ -146,23 +194,34 @@
- getChildHtml('items_after') ?> + getChildHtml('items_after') ?>
- getChildHtml('agreements') ?> + getChildHtml('agreements') ?>
- - helper('Magento\Checkout\Helper\Data')->formatPrice($block->getTotal()) ?> + escapeHtml(__('Grand Total:')); ?> + + helper(Magento\Checkout\Helper\Data::class) + ->formatPrice($block->getTotal()); ?> +
- +
-
diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml new file mode 100644 index 0000000000000..d6fdef6ae5f9a --- /dev/null +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml @@ -0,0 +1,90 @@ +getOrderIds(); +?> +
+

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

+ +

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

+
+

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

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

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

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

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

-

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

- getOrderIds()): ?> -

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

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

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

+ getOrderIds()) : ?> +

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

+
+
    + $incrementId) : ?> +
  • + + getCheckoutData()->getOrderShippingAddress($orderId); ?> +
    + + escapeHtml('Ship to:'); ?> + + escapeHtml( + $block->getCheckoutData()->formatOrderShippingAddress($shippingAddress) + ); ?> + + + + escapeHtml(__('No shipping required.')); ?> + + +
    +
  • + +
+
+ + getChildHtml() ?> +
+
+ +
+
-
+ diff --git a/app/code/Magento/Multishipping/view/frontend/web/js/payment.js b/app/code/Magento/Multishipping/view/frontend/web/js/payment.js index 94987328bb278..da24b99597d42 100644 --- a/app/code/Magento/Multishipping/view/frontend/web/js/payment.js +++ b/app/code/Magento/Multishipping/view/frontend/web/js/payment.js @@ -63,9 +63,12 @@ define([ parentsDl = element.closest('dl'); parentsDl.find('dt input:radio').prop('checked', false); - parentsDl.find('.items').hide().find('[name^="payment["]').prop('disabled', true); + parentsDl.find('dd').addClass('no-display').end() + .find('.items').hide() + .find('[name^="payment["]').prop('disabled', true); element.prop('checked', true).parent() - .nextUntil('dt').find('.items').show().find('[name^="payment["]').prop('disabled', false); + .next('dd').removeClass('no-display') + .find('.items').show().find('[name^="payment["]').prop('disabled', false); }, /** @@ -122,16 +125,35 @@ define([ this.element.find(this.options.methodsContainer).show(); }, + /** + * Returns checked payment method. + * + * @private + */ + _getSelectedPaymentMethod: function () { + return this.element.find('input[name=\'payment[method]\']:checked'); + }, + /** * Validate before form submit * @private * @param {EventObject} e */ _submitHandler: function (e) { + var currentMethod, + submitButton; + e.preventDefault(); if (this._validatePaymentMethod()) { - this.element.submit(); + currentMethod = this._getSelectedPaymentMethod(); + submitButton = currentMethod.parent().next('dd').find('button[type=submit]'); + + if (submitButton.length) { + submitButton.first().trigger('click'); + } else { + this.element.submit(); + } } } }); diff --git a/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php b/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php index 795028cffd18d..92231dae69fbe 100644 --- a/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php +++ b/app/code/Magento/NewRelicReporting/Console/Command/DeployMarker.php @@ -48,21 +48,21 @@ protected function configure() { $this->setName("newrelic:create:deploy-marker"); $this->setDescription("Check the deploy queue for entries and create an appropriate deploy marker.") - ->addArgument( - 'message', - InputArgument::REQUIRED, - 'Deploy Message?' - ) - ->addArgument( - 'changelog', - InputArgument::REQUIRED, - 'Change Log?' - ) - ->addArgument( - 'user', - InputArgument::OPTIONAL, - 'Deployment User' - ); + ->addArgument( + 'message', + InputArgument::REQUIRED, + 'Deploy Message?' + ) + ->addArgument( + 'changelog', + InputArgument::REQUIRED, + 'Change Log?' + ) + ->addArgument( + 'user', + InputArgument::OPTIONAL, + 'Deployment User' + ); parent::configure(); } diff --git a/app/code/Magento/NewRelicReporting/composer.json b/app/code/Magento/NewRelicReporting/composer.json index 4a02d673a54f6..cafbcc82060c8 100644 --- a/app/code/Magento/NewRelicReporting/composer.json +++ b/app/code/Magento/NewRelicReporting/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-new-relic-reporting", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-backend": "100.2.*", "magento/module-customer": "101.0.*", @@ -13,7 +13,7 @@ "magento/magento-composer-installer": "*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Newsletter/Block/Adminhtml/Template/Grid/Renderer/Sender.php b/app/code/Magento/Newsletter/Block/Adminhtml/Template/Grid/Renderer/Sender.php index fcad9a10a0526..9905ce25e1664 100644 --- a/app/code/Magento/Newsletter/Block/Adminhtml/Template/Grid/Renderer/Sender.php +++ b/app/code/Magento/Newsletter/Block/Adminhtml/Template/Grid/Renderer/Sender.php @@ -26,7 +26,7 @@ public function render(\Magento\Framework\DataObject $row) $str .= htmlspecialchars($row->getTemplateSenderName()) . ' '; } if ($row->getTemplateSenderEmail()) { - $str .= '[' . $row->getTemplateSenderEmail() . ']'; + $str .= '[' . htmlspecialchars($row->getTemplateSenderEmail()) . ']'; } if ($str == '') { $str .= '---'; diff --git a/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php b/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php index b82d6fe06918f..58b51009c205a 100644 --- a/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php +++ b/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php @@ -8,6 +8,10 @@ use Magento\Customer\Api\CustomerRepositoryInterface as CustomerRepository; use Magento\Customer\Api\Data\CustomerInterface; use Magento\Newsletter\Model\SubscriberFactory; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Newsletter\Model\ResourceModel\Subscriber; +use Magento\Customer\Api\Data\CustomerExtensionInterface; +use Magento\Framework\App\ObjectManager; class CustomerPlugin { @@ -18,14 +22,37 @@ class CustomerPlugin */ private $subscriberFactory; + /** + * @var ExtensionAttributesFactory + */ + private $extensionFactory; + + /** + * @var Subscriber + */ + private $subscriberResource; + + /** + * @var array + */ + private $customerSubscriptionStatus = []; + /** * Initialize dependencies. * * @param SubscriberFactory $subscriberFactory + * @param ExtensionAttributesFactory|null $extensionFactory + * @param Subscriber|null $subscriberResource */ - public function __construct(SubscriberFactory $subscriberFactory) - { + public function __construct( + SubscriberFactory $subscriberFactory, + ExtensionAttributesFactory $extensionFactory = null, + Subscriber $subscriberResource = null + ) { $this->subscriberFactory = $subscriberFactory; + $this->extensionFactory = $extensionFactory + ?: ObjectManager::getInstance()->get(ExtensionAttributesFactory::class); + $this->subscriberResource = $subscriberResource ?: ObjectManager::getInstance()->get(Subscriber::class); } /** @@ -41,14 +68,30 @@ public function __construct(SubscriberFactory $subscriberFactory) */ public function afterSave(CustomerRepository $subject, CustomerInterface $result, CustomerInterface $customer) { - $this->subscriberFactory->create()->updateSubscription($result->getId()); - if ($result->getId() && $customer->getExtensionAttributes()) { - if ($customer->getExtensionAttributes()->getIsSubscribed() === true) { - $this->subscriberFactory->create()->subscribeCustomerById($result->getId()); - } elseif ($customer->getExtensionAttributes()->getIsSubscribed() === false) { - $this->subscriberFactory->create()->unsubscribeCustomerById($result->getId()); + $resultId = $result->getId(); + /** @var \Magento\Newsletter\Model\Subscriber $subscriber */ + $subscriber = $this->subscriberFactory->create(); + $subscriber->updateSubscription($resultId); + // update the result only if the original customer instance had different value. + $initialExtensionAttributes = $result->getExtensionAttributes(); + if ($initialExtensionAttributes === null) { + /** @var CustomerExtensionInterface $initialExtensionAttributes */ + $initialExtensionAttributes = $this->extensionFactory->create(CustomerInterface::class); + $result->setExtensionAttributes($initialExtensionAttributes); + } + $newExtensionAttributes = $customer->getExtensionAttributes(); + if ($newExtensionAttributes + && $initialExtensionAttributes->getIsSubscribed() !== $newExtensionAttributes->getIsSubscribed() + ) { + if ($newExtensionAttributes->getIsSubscribed() === true) { + $subscriber->subscribeCustomerById($resultId); + } elseif ($newExtensionAttributes->getIsSubscribed() === false) { + $subscriber->unsubscribeCustomerById($resultId); } } + $isSubscribed = $subscriber->isSubscribed(); + $this->customerSubscriptionStatus[$resultId] = $isSubscribed; + $initialExtensionAttributes->setIsSubscribed($isSubscribed); return $result; } @@ -94,4 +137,44 @@ public function afterDelete(CustomerRepository $subject, $result, CustomerInterf } return $result; } + + /** + * Plugin after getById customer that obtains newsletter subscription status for given customer. + * + * @param CustomerRepository $subject + * @param CustomerInterface $customer + * @return CustomerInterface + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetById(CustomerRepository $subject, CustomerInterface $customer) + { + $extensionAttributes = $customer->getExtensionAttributes(); + if ($extensionAttributes === null) { + /** @var CustomerExtensionInterface $extensionAttributes */ + $extensionAttributes = $this->extensionFactory->create(CustomerInterface::class); + $customer->setExtensionAttributes($extensionAttributes); + } + if ($extensionAttributes->getIsSubscribed() === null) { + $isSubscribed = $this->isSubscribed($customer); + $extensionAttributes->setIsSubscribed($isSubscribed); + } + return $customer; + } + + /** + * This method returns newsletters subscription status for given customer. + * + * @param CustomerInterface $customer + * @return mixed + */ + private function isSubscribed(CustomerInterface $customer) + { + $customerId = $customer->getId(); + if (!isset($this->customerSubscriptionStatus[$customerId])) { + $subscriber = $this->subscriberResource->loadByCustomerData($customer); + $this->customerSubscriptionStatus[$customerId] = isset($subscriber['subscriber_status']) + && $subscriber['subscriber_status'] == 1; + } + return $this->customerSubscriptionStatus[$customerId]; + } } diff --git a/app/code/Magento/Newsletter/Model/Subscriber.php b/app/code/Magento/Newsletter/Model/Subscriber.php index b8d7ccf83af7c..8f29798472f19 100644 --- a/app/code/Magento/Newsletter/Model/Subscriber.php +++ b/app/code/Magento/Newsletter/Model/Subscriber.php @@ -604,14 +604,20 @@ protected function _updateCustomerSubscription($customerId, $subscribe) $this->save(); $sendSubscription = $sendInformationEmail; - if ($sendSubscription === null xor $sendSubscription) { + if ($sendSubscription === null xor $sendSubscription && $this->isStatusChanged()) { try { - if ($isConfirmNeed) { - $this->sendConfirmationRequestEmail(); - } elseif ($this->isStatusChanged() && $status == self::STATUS_UNSUBSCRIBED) { - $this->sendUnsubscriptionEmail(); - } elseif ($this->isStatusChanged() && $status == self::STATUS_SUBSCRIBED) { - $this->sendConfirmationSuccessEmail(); + switch ($status) { + case self::STATUS_UNSUBSCRIBED: + $this->sendUnsubscriptionEmail(); + break; + case self::STATUS_SUBSCRIBED: + $this->sendConfirmationSuccessEmail(); + break; + case self::STATUS_NOT_ACTIVE: + if ($isConfirmNeed) { + $this->sendConfirmationRequestEmail(); + } + break; } } catch (MailException $e) { // If we are not able to send a new account email, this should be ignored diff --git a/app/code/Magento/Newsletter/Setup/UpgradeSchema.php b/app/code/Magento/Newsletter/Setup/UpgradeSchema.php new file mode 100644 index 0000000000000..e7ce898de83a3 --- /dev/null +++ b/app/code/Magento/Newsletter/Setup/UpgradeSchema.php @@ -0,0 +1,36 @@ +startSetup(); + + if (version_compare($context->getVersion(), '2.0.1', '<')) { + $connection = $setup->getConnection(); + + $connection->addIndex( + $setup->getTable('newsletter_subscriber'), + $setup->getIdxName('newsletter_subscriber', ['subscriber_email']), + ['subscriber_email'] + ); + } + + $setup->endSetup(); + } +} diff --git a/app/code/Magento/Newsletter/Test/Unit/Block/Adminhtml/Template/Grid/Renderer/SenderTest.php b/app/code/Magento/Newsletter/Test/Unit/Block/Adminhtml/Template/Grid/Renderer/SenderTest.php new file mode 100644 index 0000000000000..baa80759314cb --- /dev/null +++ b/app/code/Magento/Newsletter/Test/Unit/Block/Adminhtml/Template/Grid/Renderer/SenderTest.php @@ -0,0 +1,92 @@ +objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->sender = $this->objectManagerHelper->getObject( + \Magento\Newsletter\Block\Adminhtml\Template\Grid\Renderer\Sender::class + ); + } + + /** + * @dataProvider rendererDataProvider + * @param array $expectedSender + * @param array $passedSender + */ + public function testRender(array $passedSender, array $expectedSender) + { + $row = $this->getMockBuilder(\Magento\Framework\DataObject::class) + ->setMethods(['getTemplateSenderName', 'getTemplateSenderEmail']) + ->getMock(); + $row->expects($this->atLeastOnce())->method('getTemplateSenderName') + ->willReturn($passedSender['sender']); + $row->expects($this->atLeastOnce())->method('getTemplateSenderEmail') + ->willReturn($passedSender['sender_email']); + $this->assertEquals( + $expectedSender['sender'] . ' [' . $expectedSender['sender_email'] . ']', + $this->sender->render($row) + ); + } + + /** + * @return array + */ + public function rendererDataProvider() + { + return [ + [ + [ + 'sender' => 'Sender', + 'sender_email' => 'sender@example.com', + ], + [ + 'sender' => 'Sender', + 'sender_email' => 'sender@example.com', + ], + ], + [ + [ + 'sender' => "
'Sender'
", + 'sender_email' => "
'email@example.com'
", + ], + [ + 'sender' => "<br>'Sender'</br>", + 'sender_email' => "<br>'email@example.com'</br>", + ], + ], + [ + [ + 'sender' => '""@example.com', + 'sender_email' => '""@example.com', + ], + [ + 'sender' => '"<script>alert(document.domain)</script>"@example.com', + 'sender_email' => '"<script>alert(document.domain)</script>"@example.com', + ], + ], + ]; + } +} diff --git a/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php b/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php index 47d4584857bde..39a9c2a0d95d2 100644 --- a/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php +++ b/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php @@ -7,13 +7,16 @@ use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\ResourceModel\CustomerRepository; +use Magento\Customer\Api\Data\CustomerExtensionInterface; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Newsletter\Model\ResourceModel\Subscriber; class CustomerPluginTest extends \PHPUnit\Framework\TestCase { /** * @var \Magento\Newsletter\Model\Plugin\CustomerPlugin */ - protected $plugin; + private $plugin; /** * @var \Magento\Newsletter\Model\SubscriberFactory|\PHPUnit_Framework_MockObject_MockObject @@ -28,7 +31,27 @@ class CustomerPluginTest extends \PHPUnit\Framework\TestCase /** * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager */ - protected $objectManager; + private $objectManager; + + /** + * @var ExtensionAttributesFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $extensionFactoryMock; + + /** + * @var CustomerExtensionInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerExtensionMock; + + /** + * @var Subscriber|\PHPUnit_Framework_MockObject_MockObject + */ + private $subscriberResourceMock; + + /** + * @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerMock; protected function setUp() { @@ -44,92 +67,97 @@ protected function setUp() 'delete', 'updateSubscription', 'subscribeCustomerById', - 'unsubscribeCustomerById' + 'unsubscribeCustomerById', + 'isSubscribed' ] )->disableOriginalConstructor() ->getMock(); + $this->extensionFactoryMock = $this->getMockBuilder(ExtensionAttributesFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->customerExtensionMock = $this->getMockBuilder(CustomerExtensionInterface::class) + ->setMethods(["getIsSubscribed", "setIsSubscribed"]) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->subscriberResourceMock = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerMock = $this->getMockBuilder(CustomerInterface::class) + ->setMethods(["getExtensionAttributes"]) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); $this->subscriberFactory->expects($this->any())->method('create')->willReturn($this->subscriber); - $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->plugin = $this->objectManager->getObject( \Magento\Newsletter\Model\Plugin\CustomerPlugin::class, [ - 'subscriberFactory' => $this->subscriberFactory + 'subscriberFactory' => $this->subscriberFactory, + 'extensionFactory' => $this->extensionFactoryMock, + 'subscriberResource' => $this->subscriberResourceMock ] ); } - public function testAfterSaveWithoutIsSubscribed() + /** + * @param bool $subscriptionOriginalValue + * @param bool $subscriptionNewValue + * @dataProvider afterSaveDataProvider + */ + public function testAfterSave($subscriptionOriginalValue, $subscriptionNewValue) { $customerId = 1; - /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); - /** @var CustomerRepository | \PHPUnit_Framework_MockObject_MockObject $subject */ + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $result */ + $result = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + /** @var CustomerRepository |\PHPUnit_Framework_MockObject_MockObject $subject */ $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); - - $customer->expects($this->atLeastOnce()) - ->method("getId") - ->willReturn($customerId); - - $this->assertEquals($customer, $this->plugin->afterSave($subject, $customer, $customer)); + /** @var CustomerExtensionInterface|\PHPUnit_Framework_MockObject_MockObject $resultExtensionAttributes */ + $resultExtensionAttributes = $this->getMockBuilder(CustomerExtensionInterface::class) + ->setMethods(['getIsSubscribed', 'setIsSubscribed']) + ->getMockForAbstractClass(); + $result->expects($this->atLeastOnce())->method('getId')->willReturn($customerId); + $result->expects($this->any())->method('getExtensionAttributes')->willReturn(null); + $this->extensionFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($resultExtensionAttributes); + $result->expects($this->once()) + ->method('setExtensionAttributes') + ->with($resultExtensionAttributes) + ->willReturnSelf(); + $this->customerMock->expects($this->once()) + ->method('getExtensionAttributes') + ->willReturn($this->customerExtensionMock); + $resultExtensionAttributes->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn($subscriptionOriginalValue); + $this->customerExtensionMock->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn($subscriptionNewValue); + if ($subscriptionOriginalValue !== $subscriptionNewValue) { + if ($subscriptionNewValue === true) { + $this->subscriber->expects($this->once())->method('subscribeCustomerById')->with($customerId); + } elseif ($subscriptionNewValue === false) { + $this->subscriber->expects($this->once())->method('unsubscribeCustomerById')->with($customerId); + } + $this->subscriber->expects($this->once())->method('isSubscribed')->willReturn($subscriptionNewValue); + $resultExtensionAttributes->expects($this->once())->method('setIsSubscribed')->with($subscriptionNewValue); + } + $this->assertEquals($result, $this->plugin->afterSave($subject, $result, $this->customerMock)); } /** * @return array */ - public function afterSaveExtensionAttributeDataProvider() + public function afterSaveDataProvider() { return [ [true, true], - [false, false] + [false, false], + [true, false], + [false, true], ]; } - /** - * @param boolean $isSubscribed - * @param boolean $subscribeIsCreated - * @dataProvider afterSaveExtensionAttributeDataProvider - */ - public function testAfterSaveWithIsSubscribed($isSubscribed, $subscribeIsCreated) - { - $customerId = 1; - /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); - $extensionAttributes = $this - ->getMockBuilder(\Magento\Customer\Api\Data\CustomerExtensionInterface::class) - ->setMethods(["getIsSubscribed", "setIsSubscribed"]) - ->getMockForAbstractClass(); - - $extensionAttributes - ->expects($this->atLeastOnce()) - ->method("getIsSubscribed") - ->willReturn($isSubscribed); - - $customer->expects($this->atLeastOnce()) - ->method("getExtensionAttributes") - ->willReturn($extensionAttributes); - - if ($subscribeIsCreated) { - $this->subscriber->expects($this->once()) - ->method("subscribeCustomerById") - ->with($customerId); - } else { - $this->subscriber->expects($this->once()) - ->method("unsubscribeCustomerById") - ->with($customerId); - } - - /** @var CustomerRepository | \PHPUnit_Framework_MockObject_MockObject $subject */ - $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); - - $customer->expects($this->atLeastOnce()) - ->method("getId") - ->willReturn($customerId); - - $this->assertEquals($customer, $this->plugin->afterSave($subject, $customer, $customer)); - } - public function testAfterDelete() { $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); @@ -138,7 +166,6 @@ public function testAfterDelete() $this->subscriber->expects($this->once())->method('loadByEmail')->with('test@test.com')->willReturnSelf(); $this->subscriber->expects($this->once())->method('getId')->willReturn(1); $this->subscriber->expects($this->once())->method('delete')->willReturnSelf(); - $this->assertEquals(true, $this->plugin->afterDelete($subject, true, $customer)); } @@ -155,7 +182,77 @@ public function testAroundDeleteById() $this->subscriber->expects($this->once())->method('loadByEmail')->with('test@test.com')->willReturnSelf(); $this->subscriber->expects($this->once())->method('getId')->willReturn(1); $this->subscriber->expects($this->once())->method('delete')->willReturnSelf(); - $this->assertEquals(true, $this->plugin->aroundDeleteById($subject, $deleteCustomerById, $customerId)); } + + /** + * @param int|null $subscriberStatusKey + * @param int|null $subscriberStatusValue + * @param bool $isSubscribed + * @dataProvider afterGetByIdDataProvider + */ + public function testAfterGetByIdCreatesExtensionAttributesIfItIsNotSet( + $subscriberStatusKey, + $subscriberStatusValue, + $isSubscribed + ) { + $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $subscriber = [$subscriberStatusKey => $subscriberStatusValue]; + $this->extensionFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($this->customerExtensionMock); + $this->customerMock->expects($this->once()) + ->method('setExtensionAttributes') + ->with($this->customerExtensionMock) + ->willReturnSelf(); + $this->customerMock->expects($this->any()) + ->method('getId') + ->willReturn(1); + $this->subscriberResourceMock->expects($this->once()) + ->method('loadByCustomerData') + ->with($this->customerMock) + ->willReturn($subscriber); + $this->customerExtensionMock->expects($this->once())->method('setIsSubscribed')->with($isSubscribed); + $this->assertEquals( + $this->customerMock, + $this->plugin->afterGetById($subject, $this->customerMock) + ); + } + + public function testAfterGetByIdSetsIsSubscribedFlagIfItIsNotSet() + { + $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $subscriber = ['subscriber_id' => 1, 'subscriber_status' => 1]; + $this->customerMock->expects($this->any()) + ->method('getExtensionAttributes') + ->willReturn($this->customerExtensionMock); + $this->customerExtensionMock->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn(null); + $this->subscriberResourceMock->expects($this->once()) + ->method('loadByCustomerData') + ->with($this->customerMock) + ->willReturn($subscriber); + $this->customerExtensionMock->expects($this->once()) + ->method('setIsSubscribed') + ->willReturnSelf(); + $this->assertEquals( + $this->customerMock, + $this->plugin->afterGetById($subject, $this->customerMock) + ); + } + + /** + * @return array + */ + public function afterGetByIdDataProvider() + { + return [ + ['subscriber_status', 1, true], + ['subscriber_status', 2, false], + ['subscriber_status', 3, false], + ['subscriber_status', 4, false], + [null, null, false] + ]; + } } diff --git a/app/code/Magento/Newsletter/composer.json b/app/code/Magento/Newsletter/composer.json index 3cd5e15d46398..00a6891be5983 100644 --- a/app/code/Magento/Newsletter/composer.json +++ b/app/code/Magento/Newsletter/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-newsletter", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-customer": "101.0.*", "magento/module-widget": "101.0.*", @@ -14,7 +14,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Newsletter/etc/module.xml b/app/code/Magento/Newsletter/etc/module.xml index f338445225222..5da16a9a3e9ba 100644 --- a/app/code/Magento/Newsletter/etc/module.xml +++ b/app/code/Magento/Newsletter/etc/module.xml @@ -6,7 +6,7 @@ */ --> - + diff --git a/app/code/Magento/OfflinePayments/composer.json b/app/code/Magento/OfflinePayments/composer.json index f03333a976f17..4271401281bc8 100644 --- a/app/code/Magento/OfflinePayments/composer.json +++ b/app/code/Magento/OfflinePayments/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-offline-payments", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-checkout": "100.2.*", "magento/module-payment": "100.2.*", "magento/framework": "101.0.*" diff --git a/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php b/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php index a258223e06777..1bd55cf5f1720 100644 --- a/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php +++ b/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php @@ -21,7 +21,7 @@ class Export extends \Magento\Framework\Data\Form\Element\AbstractElement * @param \Magento\Framework\Data\Form\Element\Factory $factoryElement * @param \Magento\Framework\Data\Form\Element\CollectionFactory $factoryCollection * @param \Magento\Framework\Escaper $escaper - * @param \Magento\Backend\Helper\Data $helper + * @param \Magento\Backend\Model\UrlInterface $backendUrl * @param array $data */ public function __construct( diff --git a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php index 5a3ad76f0410f..5b03ef0cb02bd 100644 --- a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php +++ b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php @@ -99,7 +99,7 @@ public function getBindings() } } else { $bind[':condition_name'] = $this->request->getConditionName(); - $bind[':condition_value'] = $this->request->getData($this->request->getConditionName()); + $bind[':condition_value'] = round($this->request->getData($this->request->getConditionName()), 4); } return $bind; diff --git a/app/code/Magento/OfflineShipping/composer.json b/app/code/Magento/OfflineShipping/composer.json index 2ce66fa368d01..c6512facf73bc 100644 --- a/app/code/Magento/OfflineShipping/composer.json +++ b/app/code/Magento/OfflineShipping/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-offline-shipping", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-config": "101.0.*", "magento/module-store": "100.2.*", "magento/module-backend": "100.2.*", @@ -19,7 +19,7 @@ "magento/module-offline-shipping-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/PageCache/composer.json b/app/code/Magento/PageCache/composer.json index cdbd8327b9cdd..ced6a0571320b 100644 --- a/app/code/Magento/PageCache/composer.json +++ b/app/code/Magento/PageCache/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-page-cache", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-config": "101.0.*", "magento/module-store": "100.2.*", "magento/module-backend": "100.2.*", diff --git a/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php b/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php index a6f9d4383918c..bb07408ad0e06 100644 --- a/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php +++ b/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php @@ -5,14 +5,13 @@ */ namespace Magento\Payment\Gateway\Command; -use Magento\Framework\Phrase; use Magento\Payment\Gateway\CommandInterface; +use Magento\Payment\Gateway\ErrorMapper\ErrorMessageMapperInterface; use Magento\Payment\Gateway\Http\ClientInterface; use Magento\Payment\Gateway\Http\TransferFactoryInterface; -use Magento\Payment\Gateway\Request; use Magento\Payment\Gateway\Request\BuilderInterface; -use Magento\Payment\Gateway\Response; use Magento\Payment\Gateway\Response\HandlerInterface; +use Magento\Payment\Gateway\Validator\ResultInterface; use Magento\Payment\Gateway\Validator\ValidatorInterface; use Psr\Log\LoggerInterface; @@ -54,6 +53,11 @@ class GatewayCommand implements CommandInterface */ private $logger; + /** + * @var ErrorMessageMapperInterface + */ + private $errorMessageMapper; + /** * @param BuilderInterface $requestBuilder * @param TransferFactoryInterface $transferFactory @@ -61,6 +65,7 @@ class GatewayCommand implements CommandInterface * @param LoggerInterface $logger * @param HandlerInterface $handler * @param ValidatorInterface $validator + * @param ErrorMessageMapperInterface|null $errorMessageMapper */ public function __construct( BuilderInterface $requestBuilder, @@ -68,7 +73,8 @@ public function __construct( ClientInterface $client, LoggerInterface $logger, HandlerInterface $handler = null, - ValidatorInterface $validator = null + ValidatorInterface $validator = null, + ErrorMessageMapperInterface $errorMessageMapper = null ) { $this->requestBuilder = $requestBuilder; $this->transferFactory = $transferFactory; @@ -76,6 +82,7 @@ public function __construct( $this->handler = $handler; $this->validator = $validator; $this->logger = $logger; + $this->errorMessageMapper = $errorMessageMapper; } /** @@ -98,10 +105,7 @@ public function execute(array $commandSubject) array_merge($commandSubject, ['response' => $response]) ); if (!$result->isValid()) { - $this->logExceptions($result->getFailsDescription()); - throw new CommandException( - __('Transaction has been declined. Please try again later.') - ); + $this->processErrors($result); } } @@ -114,13 +118,33 @@ public function execute(array $commandSubject) } /** - * @param Phrase[] $fails - * @return void + * Tries to map error messages from validation result and logs processed message. + * Throws an exception with mapped message or default error. + * + * @param ResultInterface $result + * @throws CommandException */ - private function logExceptions(array $fails) + private function processErrors(ResultInterface $result) { - foreach ($fails as $failPhrase) { - $this->logger->critical((string) $failPhrase); + $messages = []; + foreach ($result->getFailsDescription() as $failPhrase) { + $message = (string) $failPhrase; + + // error messages mapper can be not configured if payment method doesn't have custom error messages. + if ($this->errorMessageMapper !== null) { + $mapped = (string) $this->errorMessageMapper->getMessage($message); + if (!empty($mapped)) { + $messages[] = $mapped; + $message = $mapped; + } + } + $this->logger->critical('Payment Error: ' . $message); } + + throw new CommandException( + !empty($messages) + ? __(implode(PHP_EOL, $messages)) + : __('Transaction has been declined. Please try again later.') + ); } } diff --git a/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php new file mode 100644 index 0000000000000..c5759d41bf4d7 --- /dev/null +++ b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php @@ -0,0 +1,40 @@ +messageMapping = $messageMapping; + } + + /** + * @inheritdoc + */ + public function getMessage(string $code) + { + $message = $this->messageMapping->get($code); + return $message ? __($message) : null; + } +} diff --git a/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php new file mode 100644 index 0000000000000..077226fd9a062 --- /dev/null +++ b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php @@ -0,0 +1,23 @@ +message` format and converts it to [code => message] array format. + */ +class XmlToArrayConverter implements ConverterInterface +{ + /** + * @inheritdoc + */ + public function convert($source) + { + $result = []; + $messageList = $source->getElementsByTagName('message'); + foreach ($messageList as $messageNode) { + $result[(string) $messageNode->getAttribute('code')] = (string) $messageNode->nodeValue; + } + return $result; + } +} diff --git a/app/code/Magento/Payment/Helper/Data.php b/app/code/Magento/Payment/Helper/Data.php index f3565ea324290..5fd23c195f0c4 100644 --- a/app/code/Magento/Payment/Helper/Data.php +++ b/app/code/Magento/Payment/Helper/Data.php @@ -293,7 +293,9 @@ public function getPaymentMethodList($sorted = true, $asLabelValue = false, $wit foreach ($methods as $code => $title) { if (isset($groups[$code])) { $labelValues[$code]['label'] = $title; - $labelValues[$code]['value'] = null; + if (!isset($labelValues[$code]['value'])) { + $labelValues[$code]['value'] = null; + } } elseif (isset($groupRelations[$code])) { unset($labelValues[$code]); $labelValues[$groupRelations[$code]]['value'][$code] = ['value' => $code, 'label' => $title]; diff --git a/app/code/Magento/Payment/Model/Method/AbstractMethod.php b/app/code/Magento/Payment/Model/Method/AbstractMethod.php index 5378aa3bf5379..1972bd0745d41 100644 --- a/app/code/Magento/Payment/Model/Method/AbstractMethod.php +++ b/app/code/Magento/Payment/Model/Method/AbstractMethod.php @@ -8,12 +8,14 @@ namespace Magento\Payment\Model\Method; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DataObject; use Magento\Payment\Model\InfoInterface; use Magento\Payment\Model\MethodInterface; use Magento\Payment\Observer\AbstractDataAssignObserver; use Magento\Quote\Api\Data\PaymentMethodInterface; use Magento\Sales\Model\Order\Payment; +use Magento\Directory\Helper\Data as DirectoryHelper; /** * Payment method abstract model @@ -219,6 +221,11 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl */ protected $logger; + /** + * @var DirectoryHelper + */ + private $directory; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -230,6 +237,7 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data + * @param DirectoryHelper $directory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -242,7 +250,8 @@ public function __construct( \Magento\Payment\Model\Method\Logger $logger, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + DirectoryHelper $directory = null ) { parent::__construct( $context, @@ -256,6 +265,7 @@ public function __construct( $this->_paymentData = $paymentData; $this->_scopeConfig = $scopeConfig; $this->logger = $logger; + $this->directory = $directory ?: ObjectManager::getInstance()->get(DirectoryHelper::class); $this->initializeData($data); } @@ -605,6 +615,7 @@ public function validate() } else { $billingCountry = $paymentInfo->getQuote()->getBillingAddress()->getCountryId(); } + $billingCountry = $billingCountry ?: $this->directory->getDefaultCountry(); if (!$this->canUseForCountry($billingCountry)) { throw new \Magento\Framework\Exception\LocalizedException( __('You can\'t use the payment type you selected to make payments to the billing country.') diff --git a/app/code/Magento/Payment/Model/Method/Logger.php b/app/code/Magento/Payment/Model/Method/Logger.php index 74068c3b6fef0..90a2a94f92fc2 100644 --- a/app/code/Magento/Payment/Model/Method/Logger.php +++ b/app/code/Magento/Payment/Model/Method/Logger.php @@ -8,9 +8,7 @@ use Psr\Log\LoggerInterface; /** - * Class Logger for payment related information (request, response, etc.) which is used for debug - * - * @author Magento Core Team + * Class Logger for payment related information (request, response, etc.) which is used for debug. * * @api * @since 100.0.2 @@ -69,7 +67,7 @@ public function debug(array $data, array $maskKeys = null, $forceDebug = null) */ private function getDebugReplaceFields() { - if ($this->config and $this->config->getValue('debugReplaceKeys')) { + if ($this->config && $this->config->getValue('debugReplaceKeys')) { return explode(',', $this->config->getValue('debugReplaceKeys')); } return []; @@ -82,7 +80,7 @@ private function getDebugReplaceFields() */ private function isDebugOn() { - return $this->config and (bool)$this->config->getValue('debug'); + return $this->config && (bool)$this->config->getValue('debug'); } /** diff --git a/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php b/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php index df8bdc9bca54b..d17a7f302f31b 100644 --- a/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php +++ b/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php @@ -6,11 +6,15 @@ namespace Magento\Payment\Test\Unit\Gateway\Command; use Magento\Payment\Gateway\Command\GatewayCommand; +use Magento\Payment\Gateway\ErrorMapper\ErrorMessageMapperInterface; use Magento\Payment\Gateway\Http\ClientInterface; use Magento\Payment\Gateway\Http\TransferFactoryInterface; +use Magento\Payment\Gateway\Http\TransferInterface; use Magento\Payment\Gateway\Request\BuilderInterface; use Magento\Payment\Gateway\Response\HandlerInterface; +use Magento\Payment\Gateway\Validator\ResultInterface; use Magento\Payment\Gateway\Validator\ValidatorInterface; +use PHPUnit_Framework_MockObject_MockObject as MockObject; use Psr\Log\LoggerInterface; /** @@ -18,175 +22,176 @@ */ class GatewayCommandTest extends \PHPUnit\Framework\TestCase { - /** @var GatewayCommand */ - protected $command; + /** + * @var GatewayCommand + */ + private $command; /** - * @var BuilderInterface|\PHPUnit_Framework_MockObject_MockObject + * @var BuilderInterface|MockObject */ - protected $requestBuilderMock; + private $requestBuilder; /** - * @var TransferFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + * @var TransferFactoryInterface|MockObject */ - protected $transferFactoryMock; + private $transferFactory; /** - * @var ClientInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ClientInterface|MockObject */ - protected $clientMock; + private $client; /** - * @var HandlerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var HandlerInterface|MockObject */ - protected $responseHandlerMock; + private $responseHandler; /** - * @var ValidatorInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ValidatorInterface|MockObject */ - protected $validatorMock; + private $validator; /** - * @var LoggerInterface |\PHPUnit_Framework_MockObject_MockObject + * @var LoggerInterface|MockObject */ private $logger; + /** + * @var ErrorMessageMapperInterface|MockObject + */ + private $errorMessageMapper; + protected function setUp() { - $this->requestBuilderMock = $this->createMock( - BuilderInterface::class - ); - $this->transferFactoryMock = $this->createMock( - TransferFactoryInterface::class - ); - $this->clientMock = $this->createMock( - ClientInterface::class - ); - $this->responseHandlerMock = $this->createMock( - HandlerInterface::class - ); - $this->validatorMock = $this->createMock( - ValidatorInterface::class - ); + $this->requestBuilder = $this->createMock(BuilderInterface::class); + $this->transferFactory = $this->createMock(TransferFactoryInterface::class); + $this->client = $this->createMock(ClientInterface::class); + $this->responseHandler = $this->createMock(HandlerInterface::class); + $this->validator = $this->createMock(ValidatorInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->errorMessageMapper = $this->createMock(ErrorMessageMapperInterface::class); $this->command = new GatewayCommand( - $this->requestBuilderMock, - $this->transferFactoryMock, - $this->clientMock, + $this->requestBuilder, + $this->transferFactory, + $this->client, $this->logger, - $this->responseHandlerMock, - $this->validatorMock + $this->responseHandler, + $this->validator, + $this->errorMessageMapper ); } public function testExecute() { $commandSubject = ['authorize']; - $request = [ - 'request_field1' => 'request_value1', - 'request_field2' => 'request_value2' - ]; - $response = ['response_field1' => 'response_value1']; - $validationResult = $this->getMockBuilder( - \Magento\Payment\Gateway\Validator\ResultInterface::class - ) - ->getMockForAbstractClass(); + $this->processRequest($commandSubject, true); - $transferO = $this->getMockBuilder( - \Magento\Payment\Gateway\Http\TransferInterface::class - ) - ->getMockForAbstractClass(); + $this->responseHandler->method('handle') + ->with($commandSubject, ['response_field1' => 'response_value1']); - $this->requestBuilderMock->expects(static::once()) - ->method('build') - ->with($commandSubject) - ->willReturn($request); + $this->command->execute($commandSubject); + } - $this->transferFactoryMock->expects(static::once()) - ->method('create') - ->with($request) - ->willReturn($transferO); + /** + * Checks a case when request fails. + * + * @expectedException \Magento\Payment\Gateway\Command\CommandException + * @expectedExceptionMessage Transaction has been declined. Please try again later. + */ + public function testExecuteValidationFail() + { + $commandSubject = ['authorize']; + $validationFailures = [ + __('Failure #1'), + __('Failure #2'), + ]; - $this->clientMock->expects(static::once()) - ->method('placeRequest') - ->with($transferO) - ->willReturn($response); - $this->validatorMock->expects(static::once()) - ->method('validate') - ->with(array_merge($commandSubject, ['response' =>$response])) - ->willReturn($validationResult); - $validationResult->expects(static::once()) - ->method('isValid') - ->willReturn(true); + $this->processRequest($commandSubject, false, $validationFailures); - $this->responseHandlerMock->expects(static::once()) - ->method('handle') - ->with($commandSubject, $response); + $this->logger->expects(self::exactly(count($validationFailures))) + ->method('critical') + ->withConsecutive( + [self::equalTo('Payment Error: ' . $validationFailures[0])], + [self::equalTo('Payment Error: ' . $validationFailures[1])] + ); $this->command->execute($commandSubject); } - public function testExecuteValidationFail() + /** + * Checks a case when request fails and response errors are mapped. + * + * @expectedException \Magento\Payment\Gateway\Command\CommandException + * @expectedExceptionMessage Failure Mapped + */ + public function testExecuteValidationFailWithMappedErrors() { - $this->expectException( - \Magento\Payment\Gateway\Command\CommandException::class - ); - $commandSubject = ['authorize']; - $request = [ - 'request_field1' => 'request_value1', - 'request_field2' => 'request_value2' - ]; - $response = ['response_field1' => 'response_value1']; $validationFailures = [ __('Failure #1'), __('Failure #2'), ]; - $validationResult = $this->getMockBuilder( - \Magento\Payment\Gateway\Validator\ResultInterface::class - ) - ->getMockForAbstractClass(); - $transferO = $this->getMockBuilder( - \Magento\Payment\Gateway\Http\TransferInterface::class - ) + $this->processRequest($commandSubject, false, $validationFailures); + + $this->errorMessageMapper->method('getMessage') + ->willReturnMap( + [ + ['Failure #1', 'Failure Mapped'], + ['Failure #2', null] + ] + ); + + $this->logger->expects(self::exactly(count($validationFailures))) + ->method('critical') + ->withConsecutive( + [self::equalTo('Payment Error: Failure Mapped')], + [self::equalTo('Payment Error: Failure #2')] + ); + + $this->command->execute($commandSubject); + } + + /** + * Performs command actions like request, response and validation. + * + * @param array $commandSubject + * @param bool $validationResult + * @param array $validationFailures + */ + private function processRequest(array $commandSubject, bool $validationResult, array $validationFailures = []) + { + $request = [ + 'request_field1' => 'request_value1', + 'request_field2' => 'request_value2' + ]; + $response = ['response_field1' => 'response_value1']; + $transferO = $this->getMockBuilder(TransferInterface::class) ->getMockForAbstractClass(); - $this->requestBuilderMock->expects(static::once()) - ->method('build') + $this->requestBuilder->method('build') ->with($commandSubject) ->willReturn($request); - $this->transferFactoryMock->expects(static::once()) - ->method('create') + $this->transferFactory->method('create') ->with($request) ->willReturn($transferO); - $this->clientMock->expects(static::once()) - ->method('placeRequest') + $this->client->method('placeRequest') ->with($transferO) ->willReturn($response); - $this->validatorMock->expects(static::once()) - ->method('validate') - ->with(array_merge($commandSubject, ['response' =>$response])) - ->willReturn($validationResult); - $validationResult->expects(static::once()) - ->method('isValid') - ->willReturn(false); - $validationResult->expects(static::once()) - ->method('getFailsDescription') - ->willReturn( - $validationFailures - ); - $this->logger->expects(static::exactly(count($validationFailures))) - ->method('critical') - ->withConsecutive( - [$validationFailures[0]], - [$validationFailures[1]] - ); + $result = $this->getMockBuilder(ResultInterface::class) + ->getMockForAbstractClass(); - $this->command->execute($commandSubject); + $this->validator->method('validate') + ->with(array_merge($commandSubject, ['response' => $response])) + ->willReturn($result); + $result->method('isValid') + ->willReturn($validationResult); + $result->method('getFailsDescription') + ->willReturn($validationFailures); } } diff --git a/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php b/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php deleted file mode 100644 index f0cb19ef0fa0f..0000000000000 --- a/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php +++ /dev/null @@ -1,89 +0,0 @@ -_objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); - $this->_factory = $objectManagerHelper->getObject( - \Magento\Payment\Model\Method\Factory::class, - ['objectManager' => $this->_objectManagerMock] - ); - } - - public function testCreateMethod() - { - $className = \Magento\Payment\Model\Method\AbstractMethod::class; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - [] - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->_factory->create($className)); - } - - public function testCreateMethodWithArguments() - { - $className = \Magento\Payment\Model\Method\AbstractMethod::class; - $data = ['param1', 'param2']; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - $data - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->_factory->create($className, $data)); - } - - /** - * @expectedException \Magento\Framework\Exception\LocalizedException - * @expectedExceptionMessage WrongClass class doesn't implement \Magento\Payment\Model\MethodInterface - */ - public function testWrongTypeException() - { - $className = 'WrongClass'; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - [] - )->will( - $this->returnValue($methodMock) - ); - - $this->_factory->create($className); - } -} diff --git a/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php b/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php deleted file mode 100644 index 9bdc90829f6fe..0000000000000 --- a/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php +++ /dev/null @@ -1,71 +0,0 @@ -objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); - - $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->factory = $objectManagerHelper->getObject( - \Magento\Payment\Model\Method\Specification\Factory::class, - ['objectManager' => $this->objectManagerMock] - ); - } - - public function testCreateMethod() - { - $className = \Magento\Payment\Model\Method\SpecificationInterface::class; - $methodMock = $this->createMock($className); - $this->objectManagerMock->expects( - $this->once() - )->method( - 'get' - )->with( - $className - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->factory->create($className)); - } - - /** - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage Specification must implement SpecificationInterface - */ - public function testWrongTypeException() - { - $className = 'WrongClass'; - $methodMock = $this->createMock($className); - $this->objectManagerMock->expects( - $this->once() - )->method( - 'get' - )->with( - $className - )->will( - $this->returnValue($methodMock) - ); - - $this->factory->create($className); - } -} diff --git a/app/code/Magento/Payment/composer.json b/app/code/Magento/Payment/composer.json index b8dbf6cd7f16f..333397888d4ad 100644 --- a/app/code/Magento/Payment/composer.json +++ b/app/code/Magento/Payment/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-payment", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-config": "101.0.*", "magento/module-store": "100.2.*", "magento/module-sales": "101.0.*", @@ -12,7 +12,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Payment/etc/di.xml b/app/code/Magento/Payment/etc/di.xml index e2de2244bff89..74f553cc64094 100644 --- a/app/code/Magento/Payment/etc/di.xml +++ b/app/code/Magento/Payment/etc/di.xml @@ -12,6 +12,7 @@ + @@ -36,4 +37,47 @@ Magento\Payment\Gateway\Config\Config + + + + Magento_Payment + error_mapping.xsd + + + + + Magento\Payment\Gateway\ErrorMapper\XmlToArrayConverter + Magento\Payment\Gateway\ErrorMapper\VirtualSchemaLocator + error_mapping.xml + + + + + Magento\Payment\Gateway\ErrorMapper\VirtualConfigReader + payment_error_mapper + + + + + Magento\Payment\Gateway\ErrorMapper\NullMappingData + + + + + + /var/log/payment.log + + + + + + Magento\Payment\Model\Method\VirtualDebug + + + + + + Magento\Payment\Model\Method\VirtualLogger + + diff --git a/app/code/Magento/Payment/etc/error_mapping.xsd b/app/code/Magento/Payment/etc/error_mapping.xsd new file mode 100644 index 0000000000000..97f3c181beb37 --- /dev/null +++ b/app/code/Magento/Payment/etc/error_mapping.xsd @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/Payment/view/adminhtml/templates/info/substitution.phtml b/app/code/Magento/Payment/view/adminhtml/templates/info/substitution.phtml index ad24b113ffdea..582b8ca8a24be 100644 --- a/app/code/Magento/Payment/view/adminhtml/templates/info/substitution.phtml +++ b/app/code/Magento/Payment/view/adminhtml/templates/info/substitution.phtml @@ -10,6 +10,8 @@ */ ?>
- escapeHtml($block->getMethod()->getTitle());?> + getMethod()->getTitle() + ? $block->escapeHtml($block->getMethod()->getTitle()) + : $block->escapeHtml(__('Payment method')); ?> escapeHtml(__(' is not available. You still can process offline actions.')) ?>
diff --git a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml index cb28c0ed69bbb..ff1234cfecc2b 100644 --- a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml +++ b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml @@ -44,6 +44,18 @@ $params = $block->getParams(); }); } ); + + var require = window.top.require; + require( + [ + 'jquery' + ], + function($) { + var parent = window.top; + $(parent).trigger('clearTimeout'); + $(parent.document).find('#multishipping-billing-form').submit(); + } + ); window.top.location = "escapeUrl($params['order_success']) ?>"; diff --git a/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php b/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php new file mode 100644 index 0000000000000..db25a7c3d9c11 --- /dev/null +++ b/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php @@ -0,0 +1,111 @@ +express = $express; + + parent::__construct( + $context, + $registry, + $salesConfig, + $reorderHelper, + $data + ); + } + + /** + * Constructor + * + * @return void + * @throws LocalizedException + */ + protected function _construct() + { + parent::_construct(); + + $order = $this->getOrder(); + if (!$order) { + return; + } + $message = __('Are you sure you want to authorize full order amount?'); + if ($this->_isAllowedAction('Magento_Paypal::authorization') && $this->canAuthorize($order)) { + $this->addButton( + 'order_authorize', + [ + 'label' => __('Authorize'), + 'class' => 'authorize', + 'onclick' => "confirmSetLocation('{$message}', '{$this->getPaymentAuthorizationUrl()}')" + ] + ); + } + } + + /** + * Returns URL for authorization of full order amount. + * + * @return string + */ + private function getPaymentAuthorizationUrl(): string + { + return $this->getUrl('paypal/express/authorization'); + } + + /** + * Checks if order available for payment authorization. + * + * @param Order $order + * @return bool + * @throws LocalizedException + */ + public function canAuthorize(Order $order): bool + { + if ($order->canUnhold() || $order->isPaymentReview()) { + return false; + } + + $state = $order->getState(); + if ($order->isCanceled() || $state === Order::STATE_COMPLETE || $state === Order::STATE_CLOSED) { + return false; + } + + return $this->express->isOrderAuthorizationAllowed($order->getPayment()); + } +} diff --git a/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php b/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php new file mode 100644 index 0000000000000..a4b44adabfaef --- /dev/null +++ b/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php @@ -0,0 +1,115 @@ +express = $express; + + parent::__construct( + $context, + $coreRegistry, + $fileFactory, + $translateInline, + $resultPageFactory, + $resultJsonFactory, + $resultLayoutFactory, + $resultRawFactory, + $orderManagement, + $orderRepository, + $logger + ); + } + + /** + * Authorize full order payment amount. + * + * @return Redirect + */ + public function execute(): Redirect + { + $resultRedirect = $this->resultRedirectFactory->create(); + if ($order = $this->_initOrder()) { + try { + $this->express->authorizeOrder($order); + $this->orderRepository->save($order); + $this->messageManager->addSuccessMessage(__('Payment authorization has been successfully created.')); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage(__('Unable to make payment authorization.')); + } + + $resultRedirect->setPath('sales/order/view', ['order_id' => $order->getId()]); + } else { + $resultRedirect->setPath('sales/order/index'); + } + + return $resultRedirect; + } +} diff --git a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php index 1222679593f36..72f208a4996cd 100644 --- a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php +++ b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php @@ -6,7 +6,9 @@ */ namespace Magento\Paypal\Controller\Express\AbstractExpress; +use Magento\Framework\Exception\LocalizedException; use Magento\Paypal\Model\Api\ProcessableException as ApiProcessableException; +use Magento\Sales\Api\PaymentFailuresInterface; /** * Class PlaceOrder @@ -19,6 +21,11 @@ class PlaceOrder extends \Magento\Paypal\Controller\Express\AbstractExpress */ protected $agreementsValidator; + /** + * @var PaymentFailuresInterface + */ + private $paymentFailures; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Customer\Model\Session $customerSession @@ -29,6 +36,8 @@ class PlaceOrder extends \Magento\Paypal\Controller\Express\AbstractExpress * @param \Magento\Framework\Url\Helper\Data $urlHelper * @param \Magento\Customer\Model\Url $customerUrl * @param \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + * @param PaymentFailuresInterface|null $paymentFailures + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -39,9 +48,9 @@ public function __construct( \Magento\Framework\Session\Generic $paypalSession, \Magento\Framework\Url\Helper\Data $urlHelper, \Magento\Customer\Model\Url $customerUrl, - \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator, + PaymentFailuresInterface $paymentFailures = null ) { - $this->agreementsValidator = $agreementValidator; parent::__construct( $context, $customerSession, @@ -52,6 +61,9 @@ public function __construct( $urlHelper, $customerUrl ); + + $this->agreementsValidator = $agreementValidator; + $this->paymentFailures = $paymentFailures ? : $this->_objectManager->get(PaymentFailuresInterface::class); } /** @@ -114,15 +126,27 @@ public function execute() return; } catch (ApiProcessableException $e) { $this->_processPaypalApiError($e); + } catch (LocalizedException $e) { + $this->processException($e, $e->getRawMessage()); } catch (\Exception $e) { - $this->messageManager->addExceptionMessage( - $e, - __('We can\'t place the order.') - ); - $this->_redirect('*/*/review'); + $this->processException($e, 'We can\'t place the order.'); } } + /** + * Process exception. + * + * @param \Exception $exception + * @param string $message + * + * @return void + */ + private function processException(\Exception $exception, string $message) + { + $this->messageManager->addExceptionMessage($exception, __($message)); + $this->_redirect('*/*/review'); + } + /** * Process PayPal API's processable errors * @@ -131,6 +155,8 @@ public function execute() */ protected function _processPaypalApiError($exception) { + $this->paymentFailures->handle((int)$this->_getCheckoutSession()->getQuoteId(), $exception->getMessage()); + switch ($exception->getCode()) { case ApiProcessableException::API_MAX_PAYMENT_ATTEMPTS_EXCEEDED: case ApiProcessableException::API_TRANSACTION_EXPIRED: diff --git a/app/code/Magento/Paypal/Controller/Payflow.php b/app/code/Magento/Paypal/Controller/Payflow.php index ab21986bde3ba..730c93c731bbb 100644 --- a/app/code/Magento/Paypal/Controller/Payflow.php +++ b/app/code/Magento/Paypal/Controller/Payflow.php @@ -5,6 +5,8 @@ */ namespace Magento\Paypal\Controller; +use Magento\Sales\Api\PaymentFailuresInterface; + /** * Payflow Checkout Controller */ @@ -41,6 +43,11 @@ abstract class Payflow extends \Magento\Framework\App\Action\Action */ protected $_redirectBlockName = 'payflow.link.iframe'; + /** + * @var PaymentFailuresInterface + */ + private $paymentFailures; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Checkout\Model\Session $checkoutSession @@ -48,6 +55,7 @@ abstract class Payflow extends \Magento\Framework\App\Action\Action * @param \Magento\Paypal\Model\PayflowlinkFactory $payflowModelFactory * @param \Magento\Paypal\Helper\Checkout $checkoutHelper * @param \Psr\Log\LoggerInterface $logger + * @param PaymentFailuresInterface|null $paymentFailures */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -55,14 +63,17 @@ public function __construct( \Magento\Sales\Model\OrderFactory $orderFactory, \Magento\Paypal\Model\PayflowlinkFactory $payflowModelFactory, \Magento\Paypal\Helper\Checkout $checkoutHelper, - \Psr\Log\LoggerInterface $logger + \Psr\Log\LoggerInterface $logger, + PaymentFailuresInterface $paymentFailures = null ) { + parent::__construct($context); + $this->_checkoutSession = $checkoutSession; $this->_orderFactory = $orderFactory; $this->_logger = $logger; $this->_payflowModelFactory = $payflowModelFactory; $this->_checkoutHelper = $checkoutHelper; - parent::__construct($context); + $this->paymentFailures = $paymentFailures ? : $this->_objectManager->get(PaymentFailuresInterface::class); } /** @@ -74,6 +85,10 @@ public function __construct( protected function _cancelPayment($errorMsg = '') { $errorMsg = trim(strip_tags($errorMsg)); + $order = $this->_checkoutSession->getLastRealOrder(); + if ($order->getId()) { + $this->paymentFailures->handle((int)$order->getQuoteId(), $errorMsg); + } $gotoSection = false; $this->_checkoutHelper->cancelCurrentOrder($errorMsg); diff --git a/app/code/Magento/Paypal/Controller/Transparent/Response.php b/app/code/Magento/Paypal/Controller/Transparent/Response.php index 23ac20ca8c87b..d873c2aeb51e9 100644 --- a/app/code/Magento/Paypal/Controller/Transparent/Response.php +++ b/app/code/Magento/Paypal/Controller/Transparent/Response.php @@ -14,6 +14,8 @@ use Magento\Paypal\Model\Payflow\Service\Response\Transaction; use Magento\Paypal\Model\Payflow\Service\Response\Validator\ResponseValidator; use Magento\Paypal\Model\Payflow\Transparent; +use Magento\Sales\Api\PaymentFailuresInterface; +use Magento\Framework\Session\Generic as Session; /** * Class Response @@ -47,6 +49,16 @@ class Response extends \Magento\Framework\App\Action\Action */ private $transparent; + /** + * @var PaymentFailuresInterface + */ + private $paymentFailures; + + /** + * @var Session + */ + private $sessionTransparent; + /** * Constructor * @@ -56,6 +68,8 @@ class Response extends \Magento\Framework\App\Action\Action * @param ResponseValidator $responseValidator * @param LayoutFactory $resultLayoutFactory * @param Transparent $transparent + * @param Session|null $sessionTransparent + * @param PaymentFailuresInterface|null $paymentFailures */ public function __construct( Context $context, @@ -63,7 +77,9 @@ public function __construct( Transaction $transaction, ResponseValidator $responseValidator, LayoutFactory $resultLayoutFactory, - Transparent $transparent + Transparent $transparent, + Session $sessionTransparent = null, + PaymentFailuresInterface $paymentFailures = null ) { parent::__construct($context); $this->coreRegistry = $coreRegistry; @@ -71,6 +87,8 @@ public function __construct( $this->responseValidator = $responseValidator; $this->resultLayoutFactory = $resultLayoutFactory; $this->transparent = $transparent; + $this->sessionTransparent = $sessionTransparent ? : $this->_objectManager->get(Session::class); + $this->paymentFailures = $paymentFailures ? : $this->_objectManager->get(PaymentFailuresInterface::class); } /** @@ -86,6 +104,7 @@ public function execute() } catch (LocalizedException $exception) { $parameters['error'] = true; $parameters['error_msg'] = $exception->getMessage(); + $this->paymentFailures->handle((int)$this->sessionTransparent->getQuoteId(), $parameters['error_msg']); } $this->coreRegistry->register(Iframe::REGISTRY_KEY, $parameters); diff --git a/app/code/Magento/Paypal/Model/Adminhtml/Express.php b/app/code/Magento/Paypal/Model/Adminhtml/Express.php new file mode 100644 index 0000000000000..0881e72abbed0 --- /dev/null +++ b/app/code/Magento/Paypal/Model/Adminhtml/Express.php @@ -0,0 +1,178 @@ +authCommand = $authCommand; + + parent::__construct( + $context, + $registry, + $extensionFactory, + $customAttributeFactory, + $paymentData, + $scopeConfig, + $logger, + $proFactory, + $storeManager, + $urlBuilder, + $cartFactory, + $checkoutSession, + $exception, + $transactionRepository, + $transactionBuilder, + $resource, + $resourceCollection, + $data + ); + } + + /** + * Creates an authorization of requested amount. + * + * @param OrderInterface $order + * @return $this + * @throws LocalizedException + */ + public function authorizeOrder(OrderInterface $order) + { + $baseTotalDue = $order->getBaseTotalDue(); + + /** @var $payment Payment */ + $payment = $order->getPayment(); + if (!$payment || !$this->isOrderAuthorizationAllowed($payment)) { + throw new LocalizedException(__('Authorization is not allowed.')); + } + + $orderTransaction = $this->getOrderTransaction($payment); + + $api = $this->_callDoAuthorize($baseTotalDue, $payment, $orderTransaction->getTxnId()); + $this->_pro->importPaymentInfo($api, $payment); + + $payment->resetTransactionAdditionalInfo() + ->setIsTransactionClosed(false) + ->setTransactionId($api->getTransactionId()) + ->setParentTransactionId($orderTransaction->getTxnId()); + + $transaction = $payment->addTransaction(Transaction::TYPE_AUTH, null, true); + $message = $this->authCommand->execute($payment, $baseTotalDue, $payment->getOrder()); + $message = $payment->prependMessage($message); + + $payment->addTransactionCommentsToOrder($transaction, $message); + $payment->setAmountAuthorized($order->getTotalDue()); + $payment->setBaseAmountAuthorized($baseTotalDue); + + return $this; + } + + /** + * Checks if payment has authorization transactions. + * + * @param Payment $payment + * @return bool + */ + private function hasAuthorization(Payment $payment): bool + { + return (bool) ($payment->getAmountAuthorized() ?? 0); + } + + /** + * Checks if payment authorization allowed + * + * @param Payment $payment + * @return bool + * @throws LocalizedException + */ + public function isOrderAuthorizationAllowed(Payment $payment): bool + { + if ($payment->getMethod() === Config::METHOD_EXPRESS && + $payment->getMethodInstance()->getConfigPaymentAction() === AbstractMethod::ACTION_ORDER) { + return !$this->hasAuthorization($payment); + } + + return false; + } +} diff --git a/app/code/Magento/Paypal/Model/Express.php b/app/code/Magento/Paypal/Model/Express.php index 8ba8adcede511..4684abdc9be6d 100644 --- a/app/code/Magento/Paypal/Model/Express.php +++ b/app/code/Magento/Paypal/Model/Express.php @@ -10,6 +10,7 @@ use Magento\Paypal\Model\Express\Checkout as ExpressCheckout; use Magento\Quote\Api\Data\PaymentInterface; use Magento\Sales\Api\Data\OrderPaymentInterface; +use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Payment; use Magento\Sales\Model\Order\Payment\Transaction; use Magento\Quote\Model\Quote; @@ -363,7 +364,6 @@ public function getConfigData($field, $storeId = null) * @param \Magento\Framework\DataObject|\Magento\Payment\Model\InfoInterface|Payment $payment * @param float $amount * @return $this - * @throws \Magento\Framework\Exception\LocalizedException */ public function order(\Magento\Payment\Model\InfoInterface $payment, $amount) { @@ -375,63 +375,12 @@ public function order(\Magento\Payment\Model\InfoInterface $payment, $amount) } $payment->setAdditionalInformation($this->_isOrderPaymentActionKey, true); - if ($payment->getIsFraudDetected()) { return $this; } - $order = $payment->getOrder(); - $orderTransactionId = $payment->getTransactionId(); - - $api = $this->_callDoAuthorize($amount, $payment, $orderTransactionId); - - $state = \Magento\Sales\Model\Order::STATE_PROCESSING; - $status = true; - - $formattedPrice = $order->getBaseCurrency()->formatTxt($amount); - if ($payment->getIsTransactionPending()) { - $message = __('The ordering amount of %1 is pending approval on the payment gateway.', $formattedPrice); - $state = \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW; - } else { - $message = __('Ordered amount of %1', $formattedPrice); - } - - $transaction = $this->transactionBuilder->setPayment($payment) - ->setOrder($order) - ->setTransactionId($payment->getTransactionId()) - ->build(Transaction::TYPE_ORDER); - $payment->addTransactionCommentsToOrder($transaction, $message); + $payment->getOrder()->setActionFlag(Order::ACTION_FLAG_INVOICE, false); - $this->_pro->importPaymentInfo($api, $payment); - - if ($payment->getIsTransactionPending()) { - $message = __( - 'We\'ll authorize the amount of %1 as soon as the payment gateway approves it.', - $formattedPrice - ); - $state = \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW; - if ($payment->getIsFraudDetected()) { - $status = \Magento\Sales\Model\Order::STATUS_FRAUD; - } - } else { - $message = __('The authorized amount is %1.', $formattedPrice); - } - - $payment->resetTransactionAdditionalInfo(); - - $payment->setTransactionId($api->getTransactionId()); - $payment->setParentTransactionId($orderTransactionId); - - $transaction = $this->transactionBuilder->setPayment($payment) - ->setOrder($order) - ->setTransactionId($payment->getTransactionId()) - ->build(Transaction::TYPE_AUTH); - $payment->addTransactionCommentsToOrder($transaction, $message); - - $order->setState($state) - ->setStatus($status); - - $payment->setSkipOrderProcessing(true); return $this; } @@ -669,7 +618,7 @@ public function getApi() public function assignData(\Magento\Framework\DataObject $data) { parent::assignData($data); - + $additionalData = $data->getData(PaymentInterface::KEY_ADDITIONAL_DATA); if (!is_array($additionalData)) { @@ -677,6 +626,11 @@ public function assignData(\Magento\Framework\DataObject $data) } foreach ($additionalData as $key => $value) { + // Skip extension attributes + if ($key === \Magento\Framework\Api\ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY) { + continue; + } + $this->getInfoInstance()->setAdditionalInformation($key, $value); } return $this; diff --git a/app/code/Magento/Paypal/Model/Express/Checkout.php b/app/code/Magento/Paypal/Model/Express/Checkout.php index e9f2c1b8415a8..9c9b4dc3e87a7 100644 --- a/app/code/Magento/Paypal/Model/Express/Checkout.php +++ b/app/code/Magento/Paypal/Model/Express/Checkout.php @@ -809,7 +809,9 @@ public function place($token, $shippingMethodCode = null) case \Magento\Sales\Model\Order::STATE_PROCESSING: case \Magento\Sales\Model\Order::STATE_COMPLETE: case \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW: - $this->orderSender->send($order); + if (!$order->getEmailSent()) { + $this->orderSender->send($order); + } $this->_checkoutSession->start(); break; default: @@ -897,7 +899,12 @@ protected function _setExportedAddressData($address, $exportedAddress) { // Exported data is more priority if we came from Express Checkout button $isButton = (bool)$this->_quote->getPayment()->getAdditionalInformation(self::PAYMENT_INFO_BUTTON); - if (!$isButton) { + + // Since country is required field for billing and shipping address, + // we consider the address information to be empty if country is empty. + $isEmptyAddress = ($address->getCountryId() === null); + + if (!$isButton && !$isEmptyAddress) { return; } diff --git a/app/code/Magento/Paypal/Model/Ipn.php b/app/code/Magento/Paypal/Model/Ipn.php index aa019ce276d3b..a47b820e64d22 100644 --- a/app/code/Magento/Paypal/Model/Ipn.php +++ b/app/code/Magento/Paypal/Model/Ipn.php @@ -9,9 +9,9 @@ namespace Magento\Paypal\Model; use Exception; +use Magento\Framework\Exception\LocalizedException; use Magento\Sales\Model\Order\Email\Sender\CreditmemoSender; use Magento\Sales\Model\Order\Email\Sender\OrderSender; -use Magento\Paypal\Model\Info; /** * PayPal Instant Payment Notification processor model @@ -161,11 +161,11 @@ protected function _processOrder() case Info::TXN_TYPE_NEW_CASE: $this->_registerDispute(); break; - // handle new adjustment is created + // handle new adjustment is created case Info::TXN_TYPE_ADJUSTMENT: $this->_registerAdjustment(); break; - //handle new transaction created + //handle new transaction created default: $this->_registerTransaction(); break; @@ -236,16 +236,16 @@ protected function _registerTransaction() case Info::PAYMENTSTATUS_COMPLETED: $this->_registerPaymentCapture(true); break; - // the holded payment was denied on paypal side + // the holded payment was denied on paypal side case Info::PAYMENTSTATUS_DENIED: $this->_registerPaymentDenial(); break; - // customer attempted to pay via bank account, but failed + // customer attempted to pay via bank account, but failed case Info::PAYMENTSTATUS_FAILED: // cancel order $this->_registerPaymentFailure(); break; - // payment was obtained, but money were not captured yet + // payment was obtained, but money were not captured yet case Info::PAYMENTSTATUS_PENDING: $this->_registerPaymentPending(); break; @@ -260,7 +260,7 @@ protected function _registerTransaction() case Info::PAYMENTSTATUS_REFUNDED: $this->_registerPaymentRefund(); break; - // authorization expire/void + // authorization expire/void case Info::PAYMENTSTATUS_EXPIRED: // break is intentionally omitted case Info::PAYMENTSTATUS_VOIDED: @@ -276,6 +276,7 @@ protected function _registerTransaction() * * @param bool $skipFraudDetection * @return void + * @throws LocalizedException */ protected function _registerPaymentCapture($skipFraudDetection = false) { @@ -285,24 +286,12 @@ protected function _registerPaymentCapture($skipFraudDetection = false) $parentTransactionId = $this->getRequestData('parent_txn_id'); $this->_importPaymentInformation(); $payment = $this->_order->getPayment(); - $payment->setTransactionId( - $this->getRequestData('txn_id') - ); - $payment->setCurrencyCode( - $this->getRequestData('mc_currency') - ); - $payment->setPreparedMessage( - $this->_createIpnComment('') - ); - $payment->setParentTransactionId( - $parentTransactionId - ); - $payment->setShouldCloseParentTransaction( - 'Completed' === $this->getRequestData('auth_status') - ); - $payment->setIsTransactionClosed( - 0 - ); + $payment->setTransactionId($this->getRequestData('txn_id')); + $payment->setCurrencyCode($this->getRequestData('mc_currency')); + $payment->setPreparedMessage($this->_createIpnComment('')); + $payment->setParentTransactionId($parentTransactionId); + $payment->setShouldCloseParentTransaction('Completed' === $this->getRequestData('auth_status')); + $payment->setIsTransactionClosed(0); $payment->registerCaptureNotification( $this->getRequestData('mc_gross'), $skipFraudDetection && $parentTransactionId @@ -315,9 +304,9 @@ protected function _registerPaymentCapture($skipFraudDetection = false) $this->orderSender->send($this->_order); $this->_order->addStatusHistoryComment( __('You notified customer about invoice #%1.', $invoice->getIncrementId()) - )->setIsCustomerNotified( - true - )->save(); + ) + ->setIsCustomerNotified(true) + ->save(); } } @@ -331,15 +320,13 @@ protected function _registerPaymentDenial() { try { $this->_importPaymentInformation(); - $this->_order->getPayment()->setTransactionId( - $this->getRequestData('txn_id') - )->setNotificationResult( - true - )->setIsTransactionClosed( - true - )->deny(false); + $this->_order->getPayment() + ->setTransactionId($this->getRequestData('txn_id')) + ->setNotificationResult(true) + ->setIsTransactionClosed(true) + ->deny(false); $this->_order->save(); - } catch (\Magento\Framework\Exception\LocalizedException $e) { + } catch (LocalizedException $e) { if ($e->getMessage() != __('We cannot cancel this order.')) { throw $e; } @@ -370,9 +357,7 @@ public function _registerPaymentPending() $this->_registerPaymentAuthorization(); return; } - if ('order' === $reason) { - throw new Exception('The "order" authorizations are not implemented.'); - } + // case when was placed using PayPal standard if (\Magento\Sales\Model\Order::STATE_PENDING_PAYMENT == $this->_order->getState() && !$this->getRequestData('transaction_entity') @@ -383,13 +368,13 @@ public function _registerPaymentPending() $this->_importPaymentInformation(); - $this->_order->getPayment()->setPreparedMessage( - $this->_createIpnComment($this->_paypalInfo->explainPendingReason($reason)) - )->setTransactionId( - $this->getRequestData('txn_id') - )->setIsTransactionClosed( - 0 - )->update(false); + $this->_order->getPayment() + ->setPreparedMessage( + $this->_createIpnComment($this->_paypalInfo->explainPendingReason($reason)) + ) + ->setTransactionId($this->getRequestData('txn_id')) + ->setIsTransactionClosed(0) + ->update(false); $this->_order->save(); } @@ -406,19 +391,12 @@ protected function _registerPaymentAuthorization() $payment->update(true); } else { $this->_importPaymentInformation(); - $payment->setPreparedMessage( - $this->_createIpnComment('') - )->setTransactionId( - $this->getRequestData('txn_id') - )->setParentTransactionId( - $this->getRequestData('parent_txn_id') - )->setCurrencyCode( - $this->getRequestData('mc_currency') - )->setIsTransactionClosed( - 0 - )->registerAuthorizationNotification( - $this->getRequestData('mc_gross') - ); + $payment->setPreparedMessage($this->_createIpnComment('')) + ->setTransactionId($this->getRequestData('txn_id')) + ->setParentTransactionId($this->getRequestData('parent_txn_id')) + ->setCurrencyCode($this->getRequestData('mc_currency')) + ->setIsTransactionClosed(0) + ->registerAuthorizationNotification($this->getRequestData('mc_gross')); } if (!$this->_order->getEmailSent()) { $this->orderSender->send($this->_order); @@ -446,12 +424,13 @@ protected function _registerPaymentReversal() { $reasonCode = $this->getRequestData('reason_code'); $reasonComment = $this->_paypalInfo->explainReasonCode($reasonCode); - $notificationAmount = $this->_order->getBaseCurrency()->formatTxt( - $this->getRequestData('mc_gross') + $this->getRequestData('mc_fee') - ); + $notificationAmount = $this->_order->getBaseCurrency() + ->formatTxt( + $this->getRequestData('mc_gross') + $this->getRequestData('mc_fee') + ); $paymentStatus = $this->_filterPaymentStatus($this->getRequestData('payment_status')); $orderStatus = $paymentStatus == - Info::PAYMENTSTATUS_REVERSED ? Info::ORDER_STATUS_REVERSED : Info::ORDER_STATUS_CANCELED_REVERSAL; + Info::PAYMENTSTATUS_REVERSED ? Info::ORDER_STATUS_REVERSED : Info::ORDER_STATUS_CANCELED_REVERSAL; //Change order status to PayPal Reversed/PayPal Cancelled Reversal if it is possible. $message = __( 'IPN "%1". %2 Transaction amount %3. Transaction ID: "%4"', @@ -461,8 +440,9 @@ protected function _registerPaymentReversal() $this->getRequestData('txn_id') ); $this->_order->setStatus($orderStatus); - $this->_order->save(); - $this->_order->addStatusHistoryComment($message, $orderStatus)->setIsCustomerNotified(false)->save(); + $this->_order->addStatusHistoryComment($message, $orderStatus) + ->setIsCustomerNotified(false) + ->save(); } /** @@ -475,17 +455,14 @@ protected function _registerPaymentRefund() $this->_importPaymentInformation(); $reason = $this->getRequestData('reason_code'); $isRefundFinal = !$this->_paypalInfo->isReversalDisputable($reason); - $payment = $this->_order->getPayment()->setPreparedMessage( - $this->_createIpnComment($this->_paypalInfo->explainReasonCode($reason)) - )->setTransactionId( - $this->getRequestData('txn_id') - )->setParentTransactionId( - $this->getRequestData('parent_txn_id') - )->setIsTransactionClosed( - $isRefundFinal - )->registerRefundNotification( - -1 * $this->getRequestData('mc_gross') - ); + $payment = $this->_order->getPayment() + ->setPreparedMessage( + $this->_createIpnComment($this->_paypalInfo->explainReasonCode($reason)) + ) + ->setTransactionId($this->getRequestData('txn_id')) + ->setParentTransactionId($this->getRequestData('parent_txn_id')) + ->setIsTransactionClosed($isRefundFinal) + ->registerRefundNotification(-1 * $this->getRequestData('mc_gross')); $this->_order->save(); // TODO: there is no way to close a capture right now @@ -495,9 +472,9 @@ protected function _registerPaymentRefund() $this->creditmemoSender->send($creditMemo); $this->_order->addStatusHistoryComment( __('You notified customer about creditmemo #%1.', $creditMemo->getIncrementId()) - )->setIsCustomerNotified( - true - )->save(); + ) + ->setIsCustomerNotified(true) + ->save(); } } @@ -510,19 +487,14 @@ protected function _registerPaymentVoid() { $this->_importPaymentInformation(); - $parentTxnId = $this->getRequestData( - 'transaction_entity' - ) == 'auth' ? $this->getRequestData( - 'txn_id' - ) : $this->getRequestData( - 'parent_txn_id' - ); + $parentTxnId = $this->getRequestData('transaction_entity') == 'auth' + ? $this->getRequestData('txn_id') + : $this->getRequestData('parent_txn_id'); - $this->_order->getPayment()->setPreparedMessage( - $this->_createIpnComment('') - )->setParentTransactionId( - $parentTxnId - )->registerVoidNotification(); + $this->_order->getPayment() + ->setPreparedMessage($this->_createIpnComment('')) + ->setParentTransactionId($parentTxnId) + ->registerVoidNotification(); $this->_order->save(); } @@ -543,14 +515,14 @@ protected function _importPaymentInformation() // collect basic information $from = []; foreach ([ - Info::PAYER_ID, - 'payer_email' => Info::PAYER_EMAIL, - Info::PAYER_STATUS, - Info::ADDRESS_STATUS, - Info::PROTECTION_EL, - Info::PAYMENT_STATUS, - Info::PENDING_REASON, - ] as $privateKey => $publicKey) { + Info::PAYER_ID, + 'payer_email' => Info::PAYER_EMAIL, + Info::PAYER_STATUS, + Info::ADDRESS_STATUS, + Info::PROTECTION_EL, + Info::PAYMENT_STATUS, + Info::PENDING_REASON, + ] as $privateKey => $publicKey) { if (is_int($privateKey)) { $privateKey = $publicKey; } diff --git a/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php b/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php index 661d1f3814a0b..1ec7f4832bcb2 100644 --- a/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php +++ b/app/code/Magento/Paypal/Model/Payflow/AvsEmsCodeMapper.php @@ -24,7 +24,7 @@ class AvsEmsCodeMapper implements PaymentVerificationInterface * * @var string */ - private static $unavailableCode = 'U'; + private static $unavailableCode = ''; /** * List of mapping AVS codes diff --git a/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php b/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php index 9d215ca6cbe17..4afd398da2b54 100644 --- a/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php +++ b/app/code/Magento/Paypal/Model/Payflow/Service/Request/SecureToken.php @@ -5,13 +5,12 @@ */ namespace Magento\Paypal\Model\Payflow\Service\Request; -use Magento\Framework\Math\Random; use Magento\Framework\DataObject; +use Magento\Framework\Math\Random; use Magento\Framework\UrlInterface; use Magento\Paypal\Model\Payflow\Transparent; use Magento\Paypal\Model\Payflowpro; use Magento\Quote\Model\Quote; -use Magento\Sales\Model\Order\Payment; /** * Class SecureToken @@ -59,6 +58,7 @@ public function __construct( */ public function requestToken(Quote $quote) { + $this->transparent->setStore($quote->getStoreId()); $request = $this->transparent->buildBasicRequest(); $request->setTrxtype(Payflowpro::TRXTYPE_AUTH_ONLY); diff --git a/app/code/Magento/Paypal/Model/Payflowlink.php b/app/code/Magento/Paypal/Model/Payflowlink.php index 8771383159c2e..b8e73263057b3 100644 --- a/app/code/Magento/Paypal/Model/Payflowlink.php +++ b/app/code/Magento/Paypal/Model/Payflowlink.php @@ -12,6 +12,7 @@ use Magento\Payment\Model\Method\ConfigInterfaceFactory; use Magento\Paypal\Model\Payflow\Service\Response\Handler\HandlerInterface; use Magento\Sales\Api\Data\OrderPaymentInterface; +use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Email\Sender\OrderSender; /** @@ -241,11 +242,13 @@ public function initialize($paymentAction, $stateObject) case \Magento\Paypal\Model\Config::PAYMENT_ACTION_AUTH: case \Magento\Paypal\Model\Config::PAYMENT_ACTION_SALE: $payment = $this->getInfoInstance(); + /** @var Order $order */ $order = $payment->getOrder(); $order->setCanSendNewEmailFlag(false); $payment->setAmountAuthorized($order->getTotalDue()); $payment->setBaseAmountAuthorized($order->getBaseTotalDue()); $this->_generateSecureSilentPostHash($payment); + $this->setStore($order->getStoreId()); $request = $this->_buildTokenRequest($payment); $response = $this->postRequest($request, $this->getConfig()); $this->_processTokenErrors($response, $payment); diff --git a/app/code/Magento/Paypal/Model/Payflowpro.php b/app/code/Magento/Paypal/Model/Payflowpro.php index 0e8d6db5e9a28..f6fb4ae8e078a 100644 --- a/app/code/Magento/Paypal/Model/Payflowpro.php +++ b/app/code/Magento/Paypal/Model/Payflowpro.php @@ -647,6 +647,7 @@ public function buildBasicRequest() * @param DataObject $response * @return void * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Payment\Gateway\Command\CommandException * @throws \Magento\Framework\Exception\State\InvalidTransitionException */ public function processErrors(DataObject $response) @@ -658,9 +659,9 @@ public function processErrors(DataObject $response) } elseif ($response->getResultCode() != self::RESPONSE_CODE_APPROVED && $response->getResultCode() != self::RESPONSE_CODE_FRAUDSERVICE_FILTER ) { - throw new \Magento\Framework\Exception\LocalizedException(__($response->getRespmsg())); + throw new \Magento\Payment\Gateway\Command\CommandException(__($response->getRespmsg())); } elseif ($response->getOrigresult() == self::RESPONSE_CODE_DECLINED_BY_FILTER) { - throw new \Magento\Framework\Exception\LocalizedException(__($response->getRespmsg())); + throw new \Magento\Payment\Gateway\Command\CommandException(__($response->getRespmsg())); } } diff --git a/app/code/Magento/Paypal/Model/Pro.php b/app/code/Magento/Paypal/Model/Pro.php index 1bb6c48f6ba94..698ed87f26c09 100644 --- a/app/code/Magento/Paypal/Model/Pro.php +++ b/app/code/Magento/Paypal/Model/Pro.php @@ -258,7 +258,7 @@ public function capture(\Magento\Framework\DataObject $payment, $amount) } $api = $this->getApi() ->setAuthorizationId($authTransactionId) - ->setIsCaptureComplete($payment->getShouldCloseParentTransaction()) + ->setIsCaptureComplete($payment->isCaptureFinal($amount)) ->setAmount($amount); $order = $payment->getOrder(); diff --git a/app/code/Magento/Paypal/Model/Report/Settlement.php b/app/code/Magento/Paypal/Model/Report/Settlement.php index cca58e1770791..79d473633c611 100644 --- a/app/code/Magento/Paypal/Model/Report/Settlement.php +++ b/app/code/Magento/Paypal/Model/Report/Settlement.php @@ -376,7 +376,8 @@ public function parseCsv($localCsv, $format = 'new') // Section columns. // In case ever the column order is changed, we will have the items recorded properly // anyway. We have named, not numbered columns. - for ($i = 1; $i < count($line); $i++) { + $count = count($line); + for ($i = 1; $i < $count; $i++) { $sectionColumns[$line[$i]] = $i; } $flippedSectionColumns = array_flip($sectionColumns); diff --git a/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php b/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php new file mode 100644 index 0000000000000..8393b756be364 --- /dev/null +++ b/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php @@ -0,0 +1,47 @@ +express = $express; + } + + /** + * Checks a possibility to invoice of PayPal Express payments when payment action is "order". + * + * @param Order $order + * @param $result + * @return bool + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function afterCanInvoice(Order $order, $result): bool + { + if ($this->express->isOrderAuthorizationAllowed($order->getPayment())) { + return false; + } + + return $result; + } +} diff --git a/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php b/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php new file mode 100644 index 0000000000000..520c3d92beb3b --- /dev/null +++ b/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php @@ -0,0 +1,52 @@ +express = $express; + } + + /** + * Checks a possibility to invoice of PayPal Express payments when payment action is "order". + * + * @param CanInvoice $subject + * @param $result + * @param OrderInterface $order + * @return array + * @throws LocalizedException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterValidate(CanInvoice $subject, $result, OrderInterface $order): array + { + if ($this->express->isOrderAuthorizationAllowed($order->getPayment())) { + $result[] = __('An invoice cannot be created when none of authorization transactions available.'); + } + + return $result; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php b/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php new file mode 100644 index 0000000000000..1341fa62a6d3d --- /dev/null +++ b/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php @@ -0,0 +1,123 @@ +order = $this->createPartialMock( + Order::class, + ['canUnhold', 'isPaymentReview', 'getState', 'isCanceled', 'getPayment'] + ); + + $this->express = $this->createPartialMock( + Express::class, + ['isOrderAuthorizationAllowed'] + ); + + $this->payment = $this->createMock(Payment::class); + + $this->view = $objectManager->getObject( + View::class, + [ + 'express' => $this->express, + 'data' => [] + ] + ); + } + + /** + * Tests if authorization action is allowed for order. + * + * @param bool $canUnhold + * @param bool $isPaymentReview + * @param bool $isCanceled + * @param bool $authAllowed + * @param string $orderState + * @param bool $canAuthorize + * @throws LocalizedException + * @dataProvider orderDataProvider + */ + public function testIsOrderAuthorizationAllowed( + $canUnhold, + $isPaymentReview, + $isCanceled, + $authAllowed, + $orderState, + $canAuthorize + ) { + $this->order->method('canUnhold') + ->willReturn($canUnhold); + + $this->order->method('isPaymentReview') + ->willReturn($isPaymentReview); + + $this->order->method('isCanceled') + ->willReturn($isCanceled); + + $this->order->method('getState') + ->willReturn($orderState); + + $this->order->method('getPayment') + ->willReturn($this->payment); + + $this->express->method('isOrderAuthorizationAllowed') + ->with($this->payment) + ->willReturn($authAllowed); + + $this->assertEquals($canAuthorize, $this->view->canAuthorize($this->order)); + } + + /** + * Data provider for order methods call. + * + * @return array + */ + public function orderDataProvider(): array + { + return [ + [true, false, false, true, Order::STATE_PROCESSING, false], + [false, true, false, true, Order::STATE_PROCESSING, false], + [false, false, true, true, Order::STATE_PROCESSING, false], + [false, false, false, false, Order::STATE_PROCESSING, false], + [false, false, false, true, Order::STATE_COMPLETE, false], + [false, false, false, true, Order::STATE_CLOSED, false], + [false, false, false, true, Order::STATE_PROCESSING, true], + ]; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php b/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php index e25864bbc2f3c..bd4da25cb84d0 100644 --- a/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php @@ -5,6 +5,7 @@ */ namespace Magento\Paypal\Test\Unit\Controller\Payflow; +use Magento\Sales\Api\PaymentFailuresInterface; use Magento\Checkout\Block\Onepage\Success; use Magento\Checkout\Model\Session; use Magento\Framework\App\Action\Context; @@ -90,6 +91,11 @@ class ReturnUrlTest extends \PHPUnit\Framework\TestCase */ private $objectManager; + /** + * @var PaymentFailuresInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentFailures; + /** * @inheritdoc */ @@ -138,6 +144,17 @@ protected function setUp() ->setMethods(['getLastRealOrderId', 'getLastRealOrder', 'restoreQuote']) ->getMock(); + $this->quote = $this->getMockBuilder(CartInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->context->expects($this->any())->method('getView')->willReturn($this->view); + $this->context->expects($this->any())->method('getRequest')->willReturn($this->request); + + $this->paymentFailures = $this->getMockBuilder(PaymentFailuresInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->context->method('getView') ->willReturn($this->view); $this->context->method('getRequest') @@ -148,6 +165,7 @@ protected function setUp() 'checkoutSession' => $this->checkoutSession, 'orderFactory' => $this->orderFactory, 'checkoutHelper' => $this->checkoutHelper, + 'paymentFailures' => $this->paymentFailures, ]); } @@ -321,6 +339,7 @@ public function testCheckAdvancedAcceptingByPaymentMethod() 'checkoutSession' => $this->checkoutSession, 'orderFactory' => $this->orderFactory, 'checkoutHelper' => $this->checkoutHelper, + 'paymentFailures' => $this->paymentFailures, ]); $returnUrl->execute(); diff --git a/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php b/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php index a10d103860c65..acefebb779200 100644 --- a/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Controller/Transparent/ResponseTest.php @@ -8,16 +8,16 @@ use Magento\Framework\App\Action\Context; use Magento\Framework\App\RequestInterface; use Magento\Framework\Registry; +use Magento\Framework\Session\Generic as Session; use Magento\Framework\View\Result\Layout; use Magento\Framework\View\Result\LayoutFactory; use Magento\Paypal\Controller\Transparent\Response; use Magento\Paypal\Model\Payflow\Service\Response\Transaction; use Magento\Paypal\Model\Payflow\Service\Response\Validator\ResponseValidator; use Magento\Paypal\Model\Payflow\Transparent; +use Magento\Sales\Api\PaymentFailuresInterface; /** - * Class ResponseTest - * * Test for class \Magento\Paypal\Controller\Transparent\Response * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -53,6 +53,19 @@ class ResponseTest extends \PHPUnit\Framework\TestCase */ private $payflowFacade; + /** + * @var PaymentFailuresInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentFailures; + + /** + * @var Session|\PHPUnit_Framework_MockObject_MockObject + */ + private $sessionTransparent; + + /** + * @inheritdoc + */ protected function setUp() { $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) @@ -97,6 +110,14 @@ protected function setUp() ->disableOriginalConstructor() ->setMethods([]) ->getMock(); + $this->paymentFailures = $this->getMockBuilder(PaymentFailuresInterface::class) + ->disableOriginalConstructor() + ->setMethods(['handle']) + ->getMock(); + $this->sessionTransparent = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->setMethods(['getQuoteId']) + ->getMock(); $this->object = new Response( $this->contextMock, @@ -104,7 +125,9 @@ protected function setUp() $this->transactionMock, $this->responseValidatorMock, $this->resultLayoutFactoryMock, - $this->payflowFacade + $this->payflowFacade, + $this->sessionTransparent, + $this->paymentFailures ); } @@ -131,6 +154,8 @@ public function testExecute() $this->resultLayoutMock->expects($this->once()) ->method('getLayout') ->willReturn($this->getLayoutMock()); + $this->paymentFailures->expects($this->never()) + ->method('handle'); $this->assertInstanceOf(\Magento\Framework\Controller\ResultInterface::class, $this->object->execute()); } @@ -156,6 +181,12 @@ public function testExecuteWithException() $this->resultLayoutMock->expects($this->once()) ->method('getLayout') ->willReturn($this->getLayoutMock()); + $this->sessionTransparent->method('getQuoteId') + ->willReturn(1); + $this->paymentFailures->expects($this->once()) + ->method('handle') + ->with(1) + ->willReturnSelf(); $this->assertInstanceOf(\Magento\Framework\Controller\ResultInterface::class, $this->object->execute()); } diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php new file mode 100644 index 0000000000000..27eb4ffeca699 --- /dev/null +++ b/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php @@ -0,0 +1,209 @@ +nvp = $this->createPartialMock( + Nvp::class, + ['getData','setProcessableErrors', 'callDoAuthorization'] + ); + $this->nvp->method('getData')->willReturn([]); + $this->nvp->method('setProcessableErrors')->willReturnSelf(); + + $this->pro = $this->createPartialMock( + Pro::class, + ['setMethod', 'getApi', 'importPaymentInfo'] + ); + $this->pro->method('getApi')->willReturn($this->nvp); + + $this->transaction = $this->getMockForAbstractClass(TransactionInterface::class); + $this->transactionRepository = $this->createPartialMock( + TransactionRepository::class, + ['getByTransactionType'] + ); + $this->transactionRepository->method('getByTransactionType')->willReturn($this->transaction); + + $this->express = $objectManager->getObject( + Express::class, + [ + 'data' => [$this->pro], + 'transactionRepository' => $this->transactionRepository + ] + ); + + $this->paymentInstance = $this->getMockForAbstractClass(MethodInterface::class); + $this->payment = $this->createPartialMock( + Payment::class, + [ + 'getAmountAuthorized', + 'getMethod', + 'getMethodInstance', + 'getId', + 'getOrder', + 'addTransaction', + 'addTransactionCommentsToOrder', + 'setAmountAuthorized' + ] + ); + $this->payment->method('getMethodInstance') + ->willReturn($this->paymentInstance); + + $this->payment->method('addTransaction') + ->willReturn($this->transaction); + } + + /** + * Tests payment authorization flow for order. + * + * @throws LocalizedException + */ + public function testAuthorizeOrder() + { + $this->order = $this->createPartialMock( + Order::class, + ['getId', 'getPayment', 'getTotalDue', 'getBaseTotalDue'] + ); + $this->order->method('getPayment') + ->willReturn($this->payment); + $this->order->method('getId') + ->willReturn(1); + + $totalDue = 15; + $baseTotalDue = 10; + + $this->order->method('getTotalDue') + ->willReturn($totalDue); + $this->order->method('getBaseTotalDue') + ->willReturn($baseTotalDue); + + $this->payment->method('getMethod') + ->willReturn('paypal_express'); + $this->payment->method('getId') + ->willReturn(1); + $this->payment->method('getOrder') + ->willReturn($this->order); + $this->payment->method('getAmountAuthorized') + ->willReturn(0); + + $this->paymentInstance->method('getConfigPaymentAction') + ->willReturn('order'); + + $this->nvp->expects(static::once()) + ->method('callDoAuthorization') + ->willReturnSelf(); + + $this->payment->expects(static::once()) + ->method('addTransaction') + ->with(Transaction::TYPE_AUTH) + ->willReturn($this->transaction); + + $this->payment->method('addTransactionCommentsToOrder') + ->with($this->transaction); + + $this->payment->method('setAmountAuthorized') + ->with($totalDue); + + $this->express->authorizeOrder($this->order); + } + + /** + * Checks if payment authorization is allowed. + * + * @param string $method + * @param string $action + * @param float $authorizedAmount + * @param bool $isAuthAllowed + * @throws LocalizedException + * @dataProvider paymentDataProvider + */ + public function testIsOrderAuthorizationAllowed($method, $action, $authorizedAmount, $isAuthAllowed) + { + $this->payment->method('getMethod') + ->willReturn($method); + + $this->paymentInstance->method('getConfigPaymentAction') + ->willReturn($action); + + $this->payment->method('getAmountAuthorized') + ->willReturn($authorizedAmount); + + static::assertEquals($isAuthAllowed, $this->express->isOrderAuthorizationAllowed($this->payment)); + } + + /** + * Data provider for payment methods call. + * + * @return array + */ + public function paymentDataProvider(): array + { + return [ + ['paypal_express', 'sale', 10, false], + ['paypal_express', 'order', 50, false], + ['paypal_express', 'capture', 0, false], + ['paypal_express', 'order', 0, true], + ['braintree', 'authorize', 10, false], + ['braintree', 'authorize', 0, false] + ]; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php index 6a2d33d010190..2575408078926 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php @@ -5,13 +5,21 @@ */ namespace Magento\Paypal\Test\Unit\Model; +use Magento\Checkout\Model\Session; use Magento\Framework\DataObject; use Magento\Framework\Event\ManagerInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Payment\Model\InfoInterface; use Magento\Payment\Observer\AbstractDataAssignObserver; +use Magento\Paypal\Model\Api\Nvp; use Magento\Paypal\Model\Api\ProcessableException as ApiProcessableException; use Magento\Paypal\Model\Express; +use Magento\Paypal\Model\Pro; use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; +use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface; +use \PHPUnit_Framework_MockObject_MockObject as MockObject; /** * Class ExpressTest @@ -38,151 +46,157 @@ class ExpressTest extends \PHPUnit\Framework\TestCase /** * @var Express */ - protected $_model; + private $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Session|MockObject */ - protected $_checkoutSession; + private $checkoutSession; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Pro|MockObject */ - protected $_pro; + private $pro; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Nvp|MockObject */ - protected $_nvp; + private $nvp; /** - * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager + * @var ObjectManager */ - protected $_helper; + private $helper; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var BuilderInterface|MockObject */ - protected $transactionBuilder; + private $transactionBuilder; /** - * @var ManagerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ManagerInterface|MockObject */ - private $eventManagerMock; + private $eventManager; protected function setUp() { - $this->_checkoutSession = $this->createPartialMock( - \Magento\Checkout\Model\Session::class, + $this->checkoutSession = $this->createPartialMock( + Session::class, ['getPaypalTransactionData', 'setPaypalTransactionData'] ); $this->transactionBuilder = $this->getMockForAbstractClass( - \Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface::class, + BuilderInterface::class, [], '', false, false ); - $this->_nvp = $this->createPartialMock( - \Magento\Paypal\Model\Api\Nvp::class, - ['setProcessableErrors', 'setAmount', 'setCurrencyCode', 'setTransactionId', 'callDoAuthorization'] + $this->nvp = $this->createPartialMock( + Nvp::class, + [ + 'setProcessableErrors', + 'setAmount', + 'setCurrencyCode', + 'setTransactionId', + 'callDoAuthorization', + 'setData' + ] ); - $this->_pro = $this->createPartialMock( - \Magento\Paypal\Model\Pro::class, + $this->pro = $this->createPartialMock( + Pro::class, ['setMethod', 'getApi', 'importPaymentInfo', 'resetApi'] ); - $this->eventManagerMock = $this->getMockBuilder(ManagerInterface::class) + $this->eventManager = $this->getMockBuilder(ManagerInterface::class) ->setMethods(['dispatch']) ->getMockForAbstractClass(); - $this->_pro->expects($this->any())->method('getApi')->will($this->returnValue($this->_nvp)); - $this->_helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->pro->expects($this->any())->method('getApi')->will($this->returnValue($this->nvp)); + $this->helper = new ObjectManager($this); } public function testSetApiProcessableErrors() { - $this->_nvp->expects($this->once())->method('setProcessableErrors')->with($this->errorCodes); + $this->nvp->expects($this->once())->method('setProcessableErrors')->with($this->errorCodes); - $this->_model = $this->_helper->getObject( + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, 'transactionBuilder' => $this->transactionBuilder ] ); } + /** + * Tests order payment action. + */ public function testOrder() { - $this->_nvp->expects($this->any())->method('setProcessableErrors')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setAmount')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setCurrencyCode')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setTransactionId')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('callDoAuthorization')->will($this->returnSelf()); - - $this->_checkoutSession->expects($this->once())->method('getPaypalTransactionData')->will( - $this->returnValue([]) - ); - $this->_checkoutSession->expects($this->once())->method('setPaypalTransactionData')->with([]); - - $currency = $this->createPartialMock(\Magento\Directory\Model\Currency::class, ['__wakeup', 'formatTxt']); - $paymentModel = $this->createPartialMock(\Magento\Sales\Model\Order\Payment::class, [ - '__wakeup', - 'getBaseCurrency', - 'getOrder', - 'getIsTransactionPending', - 'addStatusHistoryComment', - 'addTransactionCommentsToOrder' - ]); - $order = $this->createPartialMock( - \Magento\Sales\Model\Order::class, - ['setState', 'getBaseCurrency', 'getBaseCurrencyCode', 'setStatus'] - ); - $paymentModel->expects($this->any())->method('getOrder')->willReturn($order); - $order->expects($this->any())->method('getBaseCurrency')->willReturn($currency); - $order->expects($this->any())->method('setState')->with('payment_review')->willReturnSelf(); - $paymentModel->expects($this->any())->method('getIsTransactionPending')->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setOrder')->with($order)->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setPayment')->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setTransactionId')->will($this->returnSelf()); - $this->_model = $this->_helper->getObject( + $transactionData = ['TOKEN' => 'EC-7NJ4634216284232D']; + $this->checkoutSession + ->method('getPaypalTransactionData') + ->willReturn($transactionData); + + $order = $this->createPartialMock(Order::class, ['setActionFlag']); + $order->method('setActionFlag') + ->with(Order::ACTION_FLAG_INVOICE, false) + ->willReturnSelf(); + + $paymentModel = $this->createPartialMock(Payment::class, ['getOrder']); + $paymentModel->method('getOrder') + ->willReturn($order); + + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, - 'transactionBuilder' => $this->transactionBuilder + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession ] ); - $this->assertEquals($this->_model, $this->_model->order($paymentModel, 12.3)); + + $this->nvp->method('setData') + ->with($transactionData) + ->willReturnSelf(); + + static::assertEquals($this->model, $this->model->order($paymentModel, 12.3)); } public function testAssignData() { $transportValue = 'something'; + $extensionAttribute = $this->getMockForAbstractClass( + \Magento\Quote\Api\Data\PaymentExtensionInterface::class, + [], + '', + false, + false + ); + $data = new DataObject( [ PaymentInterface::KEY_ADDITIONAL_DATA => [ Express\Checkout::PAYMENT_INFO_TRANSPORT_BILLING_AGREEMENT => $transportValue, Express\Checkout::PAYMENT_INFO_TRANSPORT_PAYER_ID => $transportValue, - Express\Checkout::PAYMENT_INFO_TRANSPORT_TOKEN => $transportValue + Express\Checkout::PAYMENT_INFO_TRANSPORT_TOKEN => $transportValue, + \Magento\Framework\Api\ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY => $extensionAttribute ] ] ); - $this->_model = $this->_helper->getObject( + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, 'transactionBuilder' => $this->transactionBuilder, - 'eventDispatcher' => $this->eventManagerMock, + 'eventDispatcher' => $this->eventManager, ] ); $paymentInfo = $this->createMock(InfoInterface::class); - $this->_model->setInfoInstance($paymentInfo); + $this->model->setInfoInstance($paymentInfo); $this->parentAssignDataExpectation($data); @@ -194,7 +208,7 @@ public function testAssignData() [Express\Checkout::PAYMENT_INFO_TRANSPORT_TOKEN, $transportValue] ); - $this->_model->assignData($data); + $this->model->assignData($data); } /** @@ -204,16 +218,16 @@ private function parentAssignDataExpectation(DataObject $data) { $eventData = [ AbstractDataAssignObserver::METHOD_CODE => $this, - AbstractDataAssignObserver::MODEL_CODE => $this->_model->getInfoInstance(), + AbstractDataAssignObserver::MODEL_CODE => $this->model->getInfoInstance(), AbstractDataAssignObserver::DATA_CODE => $data ]; - $this->eventManagerMock->expects(static::exactly(2)) + $this->eventManager->expects(static::exactly(2)) ->method('dispatch') ->willReturnMap( [ [ - 'payment_method_assign_data_' . $this->_model->getCode(), + 'payment_method_assign_data_' . $this->model->getCode(), $eventData ], [ diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php index eb259043a2d4f..ea86a04206f7b 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/AvsEmsCodeMapperTest.php @@ -85,17 +85,17 @@ public function testGetCodeWithException() public function getCodeDataProvider() { return [ - ['avsZip' => null, 'avsStreet' => null, 'expected' => 'U'], - ['avsZip' => null, 'avsStreet' => 'Y', 'expected' => 'U'], - ['avsZip' => 'Y', 'avsStreet' => null, 'expected' => 'U'], + ['avsZip' => null, 'avsStreet' => null, 'expected' => ''], + ['avsZip' => null, 'avsStreet' => 'Y', 'expected' => ''], + ['avsZip' => 'Y', 'avsStreet' => null, 'expected' => ''], ['avsZip' => 'Y', 'avsStreet' => 'Y', 'expected' => 'Y'], ['avsZip' => 'N', 'avsStreet' => 'Y', 'expected' => 'A'], ['avsZip' => 'Y', 'avsStreet' => 'N', 'expected' => 'Z'], ['avsZip' => 'N', 'avsStreet' => 'N', 'expected' => 'N'], - ['avsZip' => 'X', 'avsStreet' => 'Y', 'expected' => 'U'], - ['avsZip' => 'N', 'avsStreet' => 'X', 'expected' => 'U'], - ['avsZip' => '', 'avsStreet' => 'Y', 'expected' => 'U'], - ['avsZip' => 'N', 'avsStreet' => '', 'expected' => 'U'] + ['avsZip' => 'X', 'avsStreet' => 'Y', 'expected' => ''], + ['avsZip' => 'N', 'avsStreet' => 'X', 'expected' => ''], + ['avsZip' => '', 'avsStreet' => 'Y', 'expected' => ''], + ['avsZip' => 'N', 'avsStreet' => '', 'expected' => ''] ]; } } diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php index d4a7db25cae89..e4e9dd14ec7e9 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Request/SecureTokenTest.php @@ -5,45 +5,46 @@ */ namespace Magento\Paypal\Test\Unit\Model\Payflow\Service\Request; -use Magento\Framework\Math\Random; use Magento\Framework\DataObject; +use Magento\Framework\Math\Random; use Magento\Framework\UrlInterface; use Magento\Paypal\Model\Payflow\Service\Request\SecureToken; use Magento\Paypal\Model\Payflow\Transparent; +use Magento\Paypal\Model\PayflowConfig; +use Magento\Quote\Model\Quote; +use PHPUnit_Framework_MockObject_MockObject as MockObject; -/** - * Test class for \Magento\Paypal\Model\Payflow\Service\Request\SecureToken - */ class SecureTokenTest extends \PHPUnit\Framework\TestCase { /** * @var SecureToken */ - protected $model; + private $service; /** - * @var \PHPUnit_Framework_MockObject_MockObject|Transparent + * @var Transparent|MockObject */ - protected $transparent; + private $transparent; /** - * @var \PHPUnit_Framework_MockObject_MockObject|Random + * @var Random|MockObject */ - protected $mathRandom; + private $mathRandom; /** - * @var \PHPUnit_Framework_MockObject_MockObject|UrlInterface + * @inheritdoc */ - protected $url; - protected function setUp() { - $this->url = $this->createMock(\Magento\Framework\UrlInterface::class); - $this->mathRandom = $this->createMock(\Magento\Framework\Math\Random::class); - $this->transparent = $this->createMock(\Magento\Paypal\Model\Payflow\Transparent::class); + $url = $this->getMockForAbstractClass(UrlInterface::class); + $this->mathRandom = $this->getMockBuilder(Random::class) + ->getMock(); + $this->transparent = $this->getMockBuilder(Transparent::class) + ->disableOriginalConstructor() + ->getMock(); - $this->model = new SecureToken( - $this->url, + $this->service = new SecureToken( + $url, $this->mathRandom, $this->transparent ); @@ -51,32 +52,46 @@ protected function setUp() public function testRequestToken() { - $request = new DataObject(); + $storeId = 1; $secureTokenID = 'Sdj46hDokds09c8k2klaGJdKLl032ekR'; + $response = new DataObject([ + 'result' => '0', + 'respmsg' => 'Approved', + 'securetoken' => '80IgSbabyj0CtBDWHZZeQN3', + 'securetokenid' => $secureTokenID, + 'result_code' => '0', + ]); - $this->transparent->expects($this->once()) - ->method('buildBasicRequest') - ->willReturn($request); - $this->transparent->expects($this->once()) - ->method('fillCustomerContacts'); - $this->transparent->expects($this->once()) - ->method('getConfig') - ->willReturn($this->createMock(\Magento\Paypal\Model\PayflowConfig::class)); - $this->transparent->expects($this->once()) - ->method('postRequest') - ->willReturn(new DataObject()); + $quote = $this->getMockBuilder(Quote::class) + ->disableOriginalConstructor() + ->getMock(); + $quote->method('getStoreId') + ->willReturn($storeId); - $this->mathRandom->expects($this->once()) - ->method('getUniqueHash') - ->willReturn($secureTokenID); + $this->transparent->expects(self::once()) + ->method('setStore') + ->with($storeId); - $this->url->expects($this->exactly(3)) - ->method('getUrl'); + $this->transparent->method('buildBasicRequest') + ->willReturn(new DataObject()); - $quote = $this->createMock(\Magento\Quote\Model\Quote::class); + $config = $this->getMockBuilder(PayflowConfig::class) + ->disableOriginalConstructor() + ->getMock(); + $this->transparent->method('getConfig') + ->willReturn($config); + $this->transparent->method('postRequest') + ->with(self::callback(function ($request) use ($secureTokenID) { + self::assertEquals($secureTokenID, $request->getSecuretokenid(), '{Secure Token} should match.'); + return true; + })) + ->willReturn($response); + + $this->mathRandom->method('getUniqueHash') + ->willReturn($secureTokenID); - $this->model->requestToken($quote); + $actual = $this->service->requestToken($quote); - $this->assertEquals($secureTokenID, $request->getSecuretokenid()); + self::assertEquals($secureTokenID, $actual->getSecuretokenid()); } } diff --git a/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php b/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php index 362615e965d1b..060b44ecaa8c9 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/PayflowlinkTest.php @@ -5,95 +5,121 @@ */ namespace Magento\Paypal\Test\Unit\Model; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\DataObject; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Payment\Model\Method\ConfigInterfaceFactory; use Magento\Paypal\Block\Payment\Info; +use Magento\Paypal\Model\Config; +use Magento\Paypal\Model\Payflow\Request; +use Magento\Paypal\Model\Payflow\RequestFactory; +use Magento\Paypal\Model\Payflow\Service\Gateway; use Magento\Paypal\Model\Payflowlink; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; use Magento\Store\Model\ScopeInterface; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit_Framework_MockObject_MockObject as MockObject; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class PayflowlinkTest extends \PHPUnit\Framework\TestCase { - /** @var Payflowlink */ - protected $model; + /** + * @var Payflowlink + */ + private $model; - /** @var \Magento\Sales\Model\Order\Payment|\PHPUnit_Framework_MockObject_MockObject */ - protected $infoInstance; + /** + * @var Payment|MockObject + */ + private $infoInstance; - /** @var \Magento\Paypal\Model\Payflow\Request|\PHPUnit_Framework_MockObject_MockObject */ - protected $payflowRequest; + /** + * @var Request|MockObject + */ + private $payflowRequest; - /** @var \Magento\Paypal\Model\Config|\PHPUnit_Framework_MockObject_MockObject */ - protected $paypalConfig; + /** + * @var Config|MockObject + */ + private $paypalConfig; - /** @var \Magento\Store\Model\Store|\PHPUnit_Framework_MockObject_MockObject */ - protected $store; + /** + * @var Store|MockObject + */ + private $store; - /** @var \Magento\Paypal\Model\Payflow\Service\Gateway|\PHPUnit_Framework_MockObject_MockObject */ - private $gatewayMock; + /** + * @var Gateway|MockObject + */ + private $gateway; - /** @var \Magento\Framework\App\Config\ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $scopeConfigMock; + /** + * @var ScopeConfigInterface|MockObject + */ + private $scopeConfig; protected function setUp() { - $this->store = $this->createMock(\Magento\Store\Model\Store::class); + $this->store = $this->createMock(Store::class); $storeManager = $this->createMock( - \Magento\Store\Model\StoreManagerInterface::class + StoreManagerInterface::class ); - $this->paypalConfig = $this->getMockBuilder(\Magento\Paypal\Model\Config::class) + $this->paypalConfig = $this->getMockBuilder(Config::class) ->disableOriginalConstructor() ->getMock(); - $configFactoryMock = $this->getMockBuilder(\Magento\Payment\Model\Method\ConfigInterfaceFactory::class) + $configFactory = $this->getMockBuilder(ConfigInterfaceFactory::class) ->setMethods(['create']) ->disableOriginalConstructor() ->getMock(); - $requestFactory = $this->getMockBuilder(\Magento\Paypal\Model\Payflow\RequestFactory::class) + $requestFactory = $this->getMockBuilder(RequestFactory::class) ->setMethods(['create']) ->disableOriginalConstructor() ->getMock(); - $this->payflowRequest = $this->getMockBuilder(\Magento\Paypal\Model\Payflow\Request::class) + $this->payflowRequest = $this->getMockBuilder(Request::class) ->disableOriginalConstructor() ->getMock(); - $this->infoInstance = $this->getMockBuilder(\Magento\Sales\Model\Order\Payment::class) + $this->infoInstance = $this->getMockBuilder(Payment::class) ->disableOriginalConstructor() ->getMock(); - $this->scopeConfigMock = $this->getMockBuilder(\Magento\Framework\App\Config\ScopeConfigInterface::class) + $this->scopeConfig = $this->getMockBuilder(ScopeConfigInterface::class) ->getMockForAbstractClass(); - $this->gatewayMock = $this->getMockBuilder(\Magento\Paypal\Model\Payflow\Service\Gateway::class) + $this->gateway = $this->getMockBuilder(Gateway::class) ->disableOriginalConstructor() ->getMock(); - $storeManager->expects($this->any())->method('getStore')->will($this->returnValue($this->store)); - $configFactoryMock->expects($this->any()) - ->method('create') + $storeManager->method('getStore') + ->willReturn($this->store); + $configFactory->method('create') ->willReturn($this->paypalConfig); - $this->payflowRequest->expects($this->any()) - ->method('__call') - ->will($this->returnCallback(function ($method) { + $this->payflowRequest->method('__call') + ->willReturnCallback(function ($method) { if (strpos($method, 'set') === 0) { return $this->payflowRequest; } return null; - })); - $requestFactory->expects($this->any())->method('create')->will($this->returnValue($this->payflowRequest)); + }); + $requestFactory->method('create') + ->willReturn($this->payflowRequest); $helper = new ObjectManagerHelper($this); $this->model = $helper->getObject( - \Magento\Paypal\Model\Payflowlink::class, + Payflowlink::class, [ - 'scopeConfig' => $this->scopeConfigMock, + 'scopeConfig' => $this->scopeConfig, 'storeManager' => $storeManager, - 'configFactory' => $configFactoryMock, + 'configFactory' => $configFactory, 'requestFactory' => $requestFactory, - 'gateway' => $this->gatewayMock, + 'gateway' => $this->gateway, ] ); $this->model->setInfoInstance($this->infoInstance); @@ -101,18 +127,18 @@ protected function setUp() public function testInitialize() { - $order = $this->createMock(\Magento\Sales\Model\Order::class); - $this->infoInstance->expects($this->any()) - ->method('getOrder') - ->will($this->returnValue($order)); - $this->infoInstance->expects($this->any()) - ->method('setAdditionalInformation') - ->will($this->returnSelf()); - $this->paypalConfig->expects($this->once()) - ->method('getBuildNotationCode') - ->will($this->returnValue('build notation code')); - - $response = new \Magento\Framework\DataObject( + $storeId = 1; + $order = $this->createMock(Order::class); + $order->method('getStoreId') + ->willReturn($storeId); + $this->infoInstance->method('getOrder') + ->willReturn($order); + $this->infoInstance->method('setAdditionalInformation') + ->willReturnSelf(); + $this->paypalConfig->method('getBuildNotationCode') + ->willReturn('build notation code'); + + $response = new DataObject( [ 'result' => '0', 'pnref' => 'V19A3D27B61E', @@ -123,11 +149,10 @@ public function testInitialize() 'result_code' => '0', ] ); - $this->gatewayMock->expects($this->once()) - ->method('postRequest') + $this->gateway->method('postRequest') ->willReturn($response); - $this->payflowRequest->expects($this->exactly(3)) + $this->payflowRequest->expects(self::exactly(3)) ->method('setData') ->willReturnMap( [ @@ -140,14 +165,15 @@ public function testInitialize() 'BUTTONSOURCE' => 'build notation code', 'tender' => 'C', ], - $this->returnSelf() + self::returnSelf() ], - ['USER1', 1, $this->returnSelf()], - ['USER2', 'a20d3dc6824c1f7780c5529dc37ae5e', $this->returnSelf()] + ['USER1', 1, self::returnSelf()], + ['USER2', 'a20d3dc6824c1f7780c5529dc37ae5e', self::returnSelf()] ); - $stateObject = new \Magento\Framework\DataObject(); - $this->model->initialize(\Magento\Paypal\Model\Config::PAYMENT_ACTION_AUTH, $stateObject); + $stateObject = new DataObject(); + $this->model->initialize(Config::PAYMENT_ACTION_AUTH, $stateObject); + self::assertEquals($storeId, $this->model->getStore(), '{Store} should be set'); } /** @@ -158,7 +184,7 @@ public function testInitialize() public function testIsActive($expectedResult, $configResult) { $storeId = 15; - $this->scopeConfigMock->expects($this->once()) + $this->scopeConfig->expects($this->once()) ->method('getValue') ->with( "payment/payflow_link/active", @@ -180,9 +206,6 @@ public function dataProviderForTestIsActive() ]; } - /** - * @covers \Magento\Paypal\Model\Payflowlink::getInfoBlockType() - */ public function testGetInfoBlockType() { static::assertEquals(Info::class, $this->model->getInfoBlockType()); diff --git a/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php b/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php index 9cf32b99ff510..e1bf204973a8e 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php @@ -120,6 +120,9 @@ public function testCapture() ->method('getOrder') ->willReturn($orderMock); + $paymentMock->method('isCaptureFinal') + ->willReturn(true); + $this->apiMock->expects(static::once()) ->method('getTransactionId') ->willReturn(45); @@ -228,16 +231,14 @@ protected function getPaymentMock() $paymentMock = $this->getMockBuilder(\Magento\Payment\Model\Info::class) ->disableOriginalConstructor() ->setMethods([ - 'getParentTransactionId', 'getOrder', 'getShouldCloseParentTransaction' + 'getParentTransactionId', 'getOrder', 'getShouldCloseParentTransaction', 'isCaptureFinal' ]) ->getMock(); $parentTransactionId = 43; $paymentMock->expects(static::once()) ->method('getParentTransactionId') ->willReturn($parentTransactionId); - $paymentMock->expects(static::once()) - ->method('getShouldCloseParentTransaction') - ->willReturn(true); + return $paymentMock; } diff --git a/app/code/Magento/Paypal/composer.json b/app/code/Magento/Paypal/composer.json index 8aec470cb65ac..700ecacb8ee4a 100644 --- a/app/code/Magento/Paypal/composer.json +++ b/app/code/Magento/Paypal/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-paypal", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-config": "101.0.*", "magento/module-store": "100.2.*", "magento/module-checkout": "100.2.*", @@ -26,7 +26,7 @@ "magento/module-checkout-agreements": "100.2.*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "proprietary" ], diff --git a/app/code/Magento/Paypal/etc/acl.xml b/app/code/Magento/Paypal/etc/acl.xml index 0344af13519e5..4373ebc4c12bf 100644 --- a/app/code/Magento/Paypal/etc/acl.xml +++ b/app/code/Magento/Paypal/etc/acl.xml @@ -33,6 +33,11 @@ + + + + + diff --git a/app/code/Magento/Paypal/etc/di.xml b/app/code/Magento/Paypal/etc/di.xml index 3071907af4717..c0141bbb3215e 100644 --- a/app/code/Magento/Paypal/etc/di.xml +++ b/app/code/Magento/Paypal/etc/di.xml @@ -67,6 +67,12 @@ + + + + + + Magento\Paypal\Model\Config::METHOD_WPP_PE_EXPRESS diff --git a/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml b/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml new file mode 100644 index 0000000000000..3c11109b7fd63 --- /dev/null +++ b/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml index 4f514806eeb89..73c44faff5a57 100644 --- a/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml @@ -44,6 +44,15 @@ true + + false + + + false + + + false + diff --git a/app/code/Magento/Paypal/view/frontend/layout/customer_account.xml b/app/code/Magento/Paypal/view/frontend/layout/customer_account.xml index a671c066fb6c3..5eef537198139 100644 --- a/app/code/Magento/Paypal/view/frontend/layout/customer_account.xml +++ b/app/code/Magento/Paypal/view/frontend/layout/customer_account.xml @@ -6,9 +6,6 @@ */ --> - - Billing Agreements - diff --git a/app/code/Magento/Paypal/view/frontend/web/js/action/set-payment-method.js b/app/code/Magento/Paypal/view/frontend/web/js/action/set-payment-method.js index a994f9defd583..63e34437c6f90 100644 --- a/app/code/Magento/Paypal/view/frontend/web/js/action/set-payment-method.js +++ b/app/code/Magento/Paypal/view/frontend/web/js/action/set-payment-method.js @@ -4,48 +4,12 @@ */ define([ - 'jquery', 'Magento_Checkout/js/model/quote', - 'Magento_Checkout/js/model/url-builder', - 'mage/storage', - 'Magento_Checkout/js/model/error-processor', - 'Magento_Customer/js/model/customer', - 'Magento_Checkout/js/model/full-screen-loader' -], function ($, quote, urlBuilder, storage, errorProcessor, customer, fullScreenLoader) { + 'Magento_Checkout/js/action/set-payment-information' +], function (quote, setPaymentInformation) { 'use strict'; return function (messageContainer) { - var serviceUrl, - payload, - paymentData = quote.paymentMethod(); - - /** - * Checkout for guest and registered customer. - */ - if (!customer.isLoggedIn()) { - serviceUrl = urlBuilder.createUrl('/guest-carts/:cartId/set-payment-information', { - cartId: quote.getQuoteId() - }); - payload = { - cartId: quote.getQuoteId(), - email: quote.guestEmail, - paymentMethod: paymentData - }; - } else { - serviceUrl = urlBuilder.createUrl('/carts/mine/set-payment-information', {}); - payload = { - cartId: quote.getQuoteId(), - paymentMethod: paymentData - }; - } - fullScreenLoader.startLoader(); - - return storage.post( - serviceUrl, JSON.stringify(payload) - ).fail(function (response) { - errorProcessor.process(response, messageContainer); - }).always(function () { - fullScreenLoader.stopLoader(); - }); + return setPaymentInformation(messageContainer, quote.paymentMethod()); }; }); diff --git a/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php b/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php index 2aaf0f30fe71d..42baf7d692a7c 100644 --- a/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php +++ b/app/code/Magento/Persistent/Observer/CheckExpirePersistentQuoteObserver.php @@ -4,6 +4,9 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Persistent\Observer; use Magento\Framework\Event\ObserverInterface; @@ -50,6 +53,20 @@ class CheckExpirePersistentQuoteObserver implements ObserverInterface */ protected $_persistentData = null; + /** + * Request + * + * @var \Magento\Framework\App\RequestInterface + */ + private $request; + + /** + * Checkout Page path + * + * @var string + */ + private $checkoutPagePath = 'checkout'; + /** * @param \Magento\Persistent\Helper\Session $persistentSession * @param \Magento\Persistent\Helper\Data $persistentData @@ -57,6 +74,7 @@ class CheckExpirePersistentQuoteObserver implements ObserverInterface * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Customer\Model\Session $customerSession * @param \Magento\Checkout\Model\Session $checkoutSession + * @param \Magento\Framework\App\RequestInterface $request */ public function __construct( \Magento\Persistent\Helper\Session $persistentSession, @@ -64,7 +82,8 @@ public function __construct( \Magento\Persistent\Model\QuoteManager $quoteManager, \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Customer\Model\Session $customerSession, - \Magento\Checkout\Model\Session $checkoutSession + \Magento\Checkout\Model\Session $checkoutSession, + \Magento\Framework\App\RequestInterface $request ) { $this->_persistentSession = $persistentSession; $this->quoteManager = $quoteManager; @@ -72,6 +91,7 @@ public function __construct( $this->_checkoutSession = $checkoutSession; $this->_eventManager = $eventManager; $this->_persistentData = $persistentData; + $this->request = $request; } /** @@ -90,12 +110,32 @@ public function execute(\Magento\Framework\Event\Observer $observer) !$this->_persistentSession->isPersistent() && !$this->_customerSession->isLoggedIn() && $this->_checkoutSession->getQuoteId() && - !$observer->getControllerAction() instanceof \Magento\Checkout\Controller\Onepage - // persistent session does not expire on onepage checkout page to not spoil customer group id + !$this->isRequestFromCheckoutPage($this->request) + // persistent session does not expire on onepage checkout page ) { $this->_eventManager->dispatch('persistent_session_expired'); $this->quoteManager->expire(); $this->_customerSession->setCustomerId(null)->setCustomerGroupId(null); } } + + /** + * Check current request is coming from onepage checkout page. + * + * @param \Magento\Framework\App\RequestInterface $request + * @return bool + */ + private function isRequestFromCheckoutPage(\Magento\Framework\App\RequestInterface $request): bool + { + $requestUri = (string)$request->getRequestUri(); + $refererUri = (string)$request->getServer('HTTP_REFERER'); + + /** @var bool $isCheckoutPage */ + $isCheckoutPage = ( + false !== strpos($requestUri, $this->checkoutPagePath) || + false !== strpos($refererUri, $this->checkoutPagePath) + ); + + return $isCheckoutPage; + } } diff --git a/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php b/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php index a52e22a960e0b..29a3196c5f45e 100644 --- a/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Observer/CheckExpirePersistentQuoteObserverTest.php @@ -49,6 +49,11 @@ class CheckExpirePersistentQuoteObserverTest extends \PHPUnit\Framework\TestCase */ protected $eventManagerMock; + /** + * @var \PHPUnit\Framework\MockObject\MockObject|\Magento\Framework\App\RequestInterface + */ + private $requestMock; + protected function setUp() { $this->sessionMock = $this->createMock(\Magento\Persistent\Helper\Session::class); @@ -56,17 +61,23 @@ protected function setUp() $this->persistentHelperMock = $this->createMock(\Magento\Persistent\Helper\Data::class); $this->observerMock = $this->createPartialMock(\Magento\Framework\Event\Observer::class, ['getControllerAction', - '__wakeUp']); + '__wakeUp']); $this->quoteManagerMock = $this->createMock(\Magento\Persistent\Model\QuoteManager::class); $this->eventManagerMock = $this->createMock(\Magento\Framework\Event\ManagerInterface::class); $this->checkoutSessionMock = $this->createMock(\Magento\Checkout\Model\Session::class); + $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getRequestUri', 'getServer']) + ->getMockForAbstractClass(); + $this->model = new \Magento\Persistent\Observer\CheckExpirePersistentQuoteObserver( $this->sessionMock, $this->persistentHelperMock, $this->quoteManagerMock, $this->eventManagerMock, $this->customerSessionMock, - $this->checkoutSessionMock + $this->checkoutSessionMock, + $this->requestMock ); } @@ -93,8 +104,23 @@ public function testExecuteWhenPersistentIsNotEnabled() $this->model->execute($this->observerMock); } - public function testExecuteWhenPersistentIsEnabled() - { + /** + * Test method \Magento\Persistent\Observer\CheckExpirePersistentQuoteObserver::execute when persistent is enabled + * + * @param $refererUri + * @param $requestUri + * @param \PHPUnit_Framework_MockObject_Matcher_InvokedCount $expireCounter + * @param \PHPUnit_Framework_MockObject_Matcher_InvokedCount $dispatchCounter + * @param \PHPUnit_Framework_MockObject_Matcher_InvokedCount $setCustomerIdCounter + * @dataProvider requestDataProvider + */ + public function testExecuteWhenPersistentIsEnabled( + $refererUri, + $requestUri, + \PHPUnit_Framework_MockObject_Matcher_InvokedCount $expireCounter, + \PHPUnit_Framework_MockObject_Matcher_InvokedCount $dispatchCounter, + \PHPUnit_Framework_MockObject_Matcher_InvokedCount $setCustomerIdCounter + ) { $this->persistentHelperMock ->expects($this->once()) ->method('canProcess') @@ -102,16 +128,66 @@ public function testExecuteWhenPersistentIsEnabled() ->will($this->returnValue(true)); $this->persistentHelperMock->expects($this->once())->method('isEnabled')->will($this->returnValue(true)); $this->sessionMock->expects($this->once())->method('isPersistent')->will($this->returnValue(false)); - $this->customerSessionMock->expects($this->once())->method('isLoggedIn')->will($this->returnValue(false)); - $this->checkoutSessionMock->expects($this->once())->method('getQuoteId')->will($this->returnValue(10)); - $this->observerMock->expects($this->once())->method('getControllerAction'); - $this->eventManagerMock->expects($this->once())->method('dispatch'); - $this->quoteManagerMock->expects($this->once())->method('expire'); $this->customerSessionMock - ->expects($this->once()) + ->expects($this->atLeastOnce()) + ->method('isLoggedIn') + ->will($this->returnValue(false)); + $this->checkoutSessionMock + ->expects($this->atLeastOnce()) + ->method('getQuoteId') + ->will($this->returnValue(10)); + $this->eventManagerMock->expects($dispatchCounter)->method('dispatch'); + $this->quoteManagerMock->expects($expireCounter)->method('expire'); + $this->customerSessionMock + ->expects($setCustomerIdCounter) ->method('setCustomerId') ->with(null) ->will($this->returnSelf()); + $this->requestMock->expects($this->atLeastOnce())->method('getRequestUri')->willReturn($refererUri); + $this->requestMock + ->expects($this->atLeastOnce()) + ->method('getServer') + ->with('HTTP_REFERER') + ->willReturn($requestUri); $this->model->execute($this->observerMock); } + + /** + * Request Data Provider + * + * @return array + */ + public function requestDataProvider() + { + return [ + [ + 'refererUri' => 'checkout', + 'requestUri' => 'index', + 'expireCounter' => $this->never(), + 'dispatchCounter' => $this->never(), + 'setCustomerIdCounter' => $this->never(), + ], + [ + 'refererUri' => 'checkout', + 'requestUri' => 'checkout', + 'expireCounter' => $this->never(), + 'dispatchCounter' => $this->never(), + 'setCustomerIdCounter' => $this->never(), + ], + [ + 'refererUri' => 'index', + 'requestUri' => 'checkout', + 'expireCounter' => $this->never(), + 'dispatchCounter' => $this->never(), + 'setCustomerIdCounter' => $this->never(), + ], + [ + 'refererUri' => 'index', + 'requestUri' => 'index', + 'expireCounter' => $this->once(), + 'dispatchCounter' => $this->once(), + 'setCustomerIdCounter' => $this->once(), + ], + ]; + } } diff --git a/app/code/Magento/Persistent/composer.json b/app/code/Magento/Persistent/composer.json index ac065ba413cdb..674f5ba498f50 100644 --- a/app/code/Magento/Persistent/composer.json +++ b/app/code/Magento/Persistent/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-persistent", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-checkout": "100.2.*", "magento/module-customer": "101.0.*", diff --git a/app/code/Magento/ProductAlert/Controller/Add/TestObserver.php b/app/code/Magento/ProductAlert/Controller/Add/TestObserver.php deleted file mode 100644 index 74f03220e59d3..0000000000000 --- a/app/code/Magento/ProductAlert/Controller/Add/TestObserver.php +++ /dev/null @@ -1,23 +0,0 @@ -_objectManager->get(\Magento\ProductAlert\Model\Observer::class); - $observer->process($object); - } -} diff --git a/app/code/Magento/ProductAlert/composer.json b/app/code/Magento/ProductAlert/composer.json index 86e67e6bdc8ae..c4566f3d82d8b 100644 --- a/app/code/Magento/ProductAlert/composer.json +++ b/app/code/Magento/ProductAlert/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-product-alert", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-backend": "100.2.*", "magento/module-store": "100.2.*", "magento/module-catalog": "102.0.*", @@ -13,7 +13,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.0", + "version": "100.2.1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ProductVideo/Model/Plugin/ExternalVideoResourceBackend.php b/app/code/Magento/ProductVideo/Model/Plugin/ExternalVideoResourceBackend.php index a46a90f24d9fa..166963739fe6d 100644 --- a/app/code/Magento/ProductVideo/Model/Plugin/ExternalVideoResourceBackend.php +++ b/app/code/Magento/ProductVideo/Model/Plugin/ExternalVideoResourceBackend.php @@ -61,13 +61,7 @@ public function afterCreateBatchBaseSelect(Gallery $originalResourceModel, Selec 'value.store_id = value_video.store_id', ] ), - [ - 'video_provider' => 'provider', - 'video_url' => 'url', - 'video_title' => 'title', - 'video_description' => 'description', - 'video_metadata' => 'metadata' - ] + [] )->joinLeft( ['default_value_video' => $originalResourceModel->getTable(InstallSchema::GALLERY_VALUE_VIDEO_TABLE)], implode( @@ -77,14 +71,24 @@ public function afterCreateBatchBaseSelect(Gallery $originalResourceModel, Selec 'default_value.store_id = default_value_video.store_id', ] ), - [ - 'video_provider_default' => 'provider', - 'video_url_default' => 'url', - 'video_title_default' => 'title', - 'video_description_default' => 'description', - 'video_metadata_default' => 'metadata', - ] - ); + [] + )->columns([ + 'video_provider' => $originalResourceModel->getConnection() + ->getIfNullSql('`value_video`.`provider`', '`default_value_video`.`provider`'), + 'video_url' => $originalResourceModel->getConnection() + ->getIfNullSql('`value_video`.`url`', '`default_value_video`.`url`'), + 'video_title' => $originalResourceModel->getConnection() + ->getIfNullSql('`value_video`.`title`', '`default_value_video`.`title`'), + 'video_description' => $originalResourceModel->getConnection() + ->getIfNullSql('`value_video`.`description`', '`default_value_video`.`description`'), + 'video_metadata' => $originalResourceModel->getConnection() + ->getIfNullSql('`value_video`.`metadata`', '`default_value_video`.`metadata`'), + 'video_provider_default' => 'default_value_video.provider', + 'video_url_default' => 'default_value_video.url', + 'video_title_default' => 'default_value_video.title', + 'video_description_default' => 'default_value_video.description', + 'video_metadata_default' => 'default_value_video.metadata', + ]); return $select; } diff --git a/app/code/Magento/ProductVideo/composer.json b/app/code/Magento/ProductVideo/composer.json index d40707a06de8a..da094c0471433 100644 --- a/app/code/Magento/ProductVideo/composer.json +++ b/app/code/Magento/ProductVideo/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-product-video", "description": "Add Video to Products", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-catalog": "102.0.*", "magento/module-backend": "100.2.*", "magento/module-eav": "101.0.*", @@ -16,7 +16,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.1", + "version": "100.2.2", "license": [ "proprietary" ], diff --git a/app/code/Magento/ProductVideo/view/adminhtml/web/js/get-video-information.js b/app/code/Magento/ProductVideo/view/adminhtml/web/js/get-video-information.js index ca920e8740978..13b0e43a84d81 100644 --- a/app/code/Magento/ProductVideo/view/adminhtml/web/js/get-video-information.js +++ b/app/code/Magento/ProductVideo/view/adminhtml/web/js/get-video-information.js @@ -302,7 +302,7 @@ define([ additionalParams += '&autoplay=1'; } - src = window.location.protocol + '//player.vimeo.com/video/' + + src = 'https://player.vimeo.com/video/' + this._code + '?api=1&player_id=vimeo' + this._code + timestamp + @@ -525,7 +525,7 @@ define([ ); } else if (type === 'vimeo') { $.ajax({ - url: window.location.protocol + '//www.vimeo.com/api/v2/video/' + id + '.json', + url: 'https://www.vimeo.com/api/v2/video/' + id + '.json', dataType: 'jsonp', data: { format: 'json' diff --git a/app/code/Magento/ProductVideo/view/frontend/web/js/load-player.js b/app/code/Magento/ProductVideo/view/frontend/web/js/load-player.js index 5a9f6a3eca941..3519d538e523a 100644 --- a/app/code/Magento/ProductVideo/view/frontend/web/js/load-player.js +++ b/app/code/Magento/ProductVideo/view/frontend/web/js/load-player.js @@ -317,7 +317,7 @@ define(['jquery', 'jquery/ui'], function ($) { if (this._loop) { additionalParams += '&loop=1'; } - src = window.location.protocol + '//player.vimeo.com/video/' + + src = 'https://player.vimeo.com/video/' + this._code + '?api=1&player_id=vimeo' + this._code + timestamp + diff --git a/app/code/Magento/Quote/Api/ChangeQuoteControlInterface.php b/app/code/Magento/Quote/Api/ChangeQuoteControlInterface.php new file mode 100644 index 0000000000000..ef6ecf746df76 --- /dev/null +++ b/app/code/Magento/Quote/Api/ChangeQuoteControlInterface.php @@ -0,0 +1,23 @@ +getShippingAddress()->getData(); $addressTotals = $quote->getShippingAddress()->getTotals(); } + unset($addressTotalsData[ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY]); /** @var \Magento\Quote\Api\Data\TotalsInterface $quoteTotals */ $quoteTotals = $this->totalsFactory->create(); diff --git a/app/code/Magento/Quote/Model/ChangeQuoteControl.php b/app/code/Magento/Quote/Model/ChangeQuoteControl.php new file mode 100644 index 0000000000000..a4ed0345f207d --- /dev/null +++ b/app/code/Magento/Quote/Model/ChangeQuoteControl.php @@ -0,0 +1,53 @@ +userContext = $userContext; + } + + /** + * {@inheritdoc} + */ + public function isAllowed(CartInterface $quote): bool + { + switch ($this->userContext->getUserType()) { + case UserContextInterface::USER_TYPE_CUSTOMER: + $isAllowed = ($quote->getCustomerId() == $this->userContext->getUserId()); + break; + case UserContextInterface::USER_TYPE_GUEST: + $isAllowed = ($quote->getCustomerId() === null); + break; + case UserContextInterface::USER_TYPE_ADMIN: + case UserContextInterface::USER_TYPE_INTEGRATION: + $isAllowed = true; + break; + default: + $isAllowed = false; + } + + return $isAllowed; + } +} diff --git a/app/code/Magento/Quote/Model/PaymentMethodManagement.php b/app/code/Magento/Quote/Model/PaymentMethodManagement.php index f12b9e5d1fb7f..5a0a3e8608a88 100644 --- a/app/code/Magento/Quote/Model/PaymentMethodManagement.php +++ b/app/code/Magento/Quote/Model/PaymentMethodManagement.php @@ -51,36 +51,35 @@ public function set($cartId, \Magento\Quote\Api\Data\PaymentInterface $method) { /** @var \Magento\Quote\Model\Quote $quote */ $quote = $this->quoteRepository->get($cartId); - + $quote->setTotalsCollectedFlag(false); $method->setChecks([ \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_CHECKOUT, \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_COUNTRY, \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_CURRENCY, \Magento\Payment\Model\Method\AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, ]); - $payment = $quote->getPayment(); - - $data = $method->getData(); - $payment->importData($data); if ($quote->isVirtual()) { - $quote->getBillingAddress()->setPaymentMethod($payment->getMethod()); + $address = $quote->getBillingAddress(); } else { + $address = $quote->getShippingAddress(); // check if shipping address is set - if ($quote->getShippingAddress()->getCountryId() === null) { + if ($address->getCountryId() === null) { throw new InvalidTransitionException(__('Shipping address is not set')); } - $quote->getShippingAddress()->setPaymentMethod($payment->getMethod()); - } - if (!$quote->isVirtual() && $quote->getShippingAddress()) { - $quote->getShippingAddress()->setCollectShippingRates(true); + $address->setCollectShippingRates(true); } + $paymentData = $method->getData(); + $payment = $quote->getPayment(); + $payment->importData($paymentData); + $address->setPaymentMethod($payment->getMethod()); + if (!$this->zeroTotalValidator->isApplicable($payment->getMethodInstance(), $quote)) { throw new InvalidTransitionException(__('The requested Payment Method is not available.')); } - $quote->setTotalsCollectedFlag(false)->collectTotals()->save(); + $quote->save(); return $quote->getPayment()->getId(); } diff --git a/app/code/Magento/Quote/Model/Quote.php b/app/code/Magento/Quote/Model/Quote.php index 7741d3b0f7657..536c58861a253 100644 --- a/app/code/Magento/Quote/Model/Quote.php +++ b/app/code/Magento/Quote/Model/Quote.php @@ -2389,8 +2389,9 @@ protected function _afterLoad() { // collect totals and save me, if required if (1 == $this->getTriggerRecollect()) { - $this->collectTotals()->save(); - $this->setTriggerRecollect(0); + $this->collectTotals() + ->setTriggerRecollect(0) + ->save(); } return parent::_afterLoad(); } diff --git a/app/code/Magento/Quote/Model/Quote/Address.php b/app/code/Magento/Quote/Model/Quote/Address.php index 87c5feaba8f2e..67393e3598568 100644 --- a/app/code/Magento/Quote/Model/Quote/Address.php +++ b/app/code/Magento/Quote/Model/Quote/Address.php @@ -1008,6 +1008,7 @@ public function requestShippingRates(\Magento\Quote\Model\Quote\Item\AbstractIte /** * Store and website identifiers specified from StoreManager */ + $request->setQuoteStoreId($this->getQuote()->getStoreId()); $request->setStoreId($this->storeManager->getStore()->getId()); $request->setWebsiteId($this->storeManager->getWebsite()->getId()); $request->setFreeShipping($this->getFreeShipping()); diff --git a/app/code/Magento/Quote/Model/Quote/Item.php b/app/code/Magento/Quote/Model/Quote/Item.php index d8177ddfe5236..fe6d712500bcd 100644 --- a/app/code/Magento/Quote/Model/Quote/Item.php +++ b/app/code/Magento/Quote/Model/Quote/Item.php @@ -745,6 +745,9 @@ public function saveItemOptions() unset($this->_options[$index]); unset($this->_optionsByCode[$option->getCode()]); } else { + if (!$option->getItem() || !$option->getItem()->getId()) { + $option->setItem($this); + } $option->save(); } } diff --git a/app/code/Magento/Quote/Model/Quote/Validator/MinimumOrderAmount/ValidationMessage.php b/app/code/Magento/Quote/Model/Quote/Validator/MinimumOrderAmount/ValidationMessage.php index 3113721f8a597..38bfcbf1d30ca 100644 --- a/app/code/Magento/Quote/Model/Quote/Validator/MinimumOrderAmount/ValidationMessage.php +++ b/app/code/Magento/Quote/Model/Quote/Validator/MinimumOrderAmount/ValidationMessage.php @@ -19,22 +19,32 @@ class ValidationMessage /** * @var \Magento\Framework\Locale\CurrencyInterface + * @deprecated since 101.0.0 */ private $currency; + /** + * @var \Magento\Framework\Pricing\Helper\Data + */ + private $priceHelper; + /** * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Framework\Locale\CurrencyInterface $currency + * @param \Magento\Framework\Pricing\Helper\Data $priceHelper */ public function __construct( \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\Framework\Locale\CurrencyInterface $currency + \Magento\Framework\Locale\CurrencyInterface $currency, + \Magento\Framework\Pricing\Helper\Data $priceHelper = null ) { $this->scopeConfig = $scopeConfig; $this->storeManager = $storeManager; $this->currency = $currency; + $this->priceHelper = $priceHelper ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Framework\Pricing\Helper\Data::class); } /** @@ -50,13 +60,11 @@ public function getMessage() \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); if (!$message) { - $currencyCode = $this->storeManager->getStore()->getCurrentCurrencyCode(); - $minimumAmount = $this->currency->getCurrency($currencyCode)->toCurrency( - $this->scopeConfig->getValue( - 'sales/minimum_order/amount', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ) - ); + $minimumAmount = $this->priceHelper->currency($this->scopeConfig->getValue( + 'sales/minimum_order/amount', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ), true, false); + $message = __('Minimum order amount is %1', $minimumAmount); } else { //Added in order to address the issue: https://github.com/magento/magento2/issues/8287 diff --git a/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php b/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php index 3eff09faac1f5..79b518fc54534 100644 --- a/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php +++ b/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php @@ -3,10 +3,10 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Quote\Model\QuoteRepository\Plugin; -use Magento\Authorization\Model\UserContextInterface; -use Magento\Quote\Model\Quote; +use Magento\Quote\Api\ChangeQuoteControlInterface; use Magento\Framework\Exception\StateException; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Api\Data\CartInterface; @@ -17,24 +17,23 @@ class AccessChangeQuoteControl { /** - * @var UserContextInterface + * @var ChangeQuoteControlInterface $changeQuoteControl */ - private $userContext; + private $changeQuoteControl; /** - * @param UserContextInterface $userContext + * @param ChangeQuoteControlInterface $changeQuoteControl */ - public function __construct( - UserContextInterface $userContext - ) { - $this->userContext = $userContext; + public function __construct(ChangeQuoteControlInterface $changeQuoteControl) + { + $this->changeQuoteControl = $changeQuoteControl; } /** * Checks if change quote's customer id is allowed for current user. * * @param CartRepositoryInterface $subject - * @param Quote $quote + * @param CartInterface $quote * @throws StateException if Guest has customer_id or Customer's customer_id not much with user_id * or unknown user's type * @return void @@ -42,34 +41,8 @@ public function __construct( */ public function beforeSave(CartRepositoryInterface $subject, CartInterface $quote) { - if (!$this->isAllowed($quote)) { + if (! $this->changeQuoteControl->isAllowed($quote)) { throw new StateException(__("Invalid state change requested")); } } - - /** - * Checks if user is allowed to change the quote. - * - * @param Quote $quote - * @return bool - */ - private function isAllowed(Quote $quote) - { - switch ($this->userContext->getUserType()) { - case UserContextInterface::USER_TYPE_CUSTOMER: - $isAllowed = ($quote->getCustomerId() == $this->userContext->getUserId()); - break; - case UserContextInterface::USER_TYPE_GUEST: - $isAllowed = ($quote->getCustomerId() === null); - break; - case UserContextInterface::USER_TYPE_ADMIN: - case UserContextInterface::USER_TYPE_INTEGRATION: - $isAllowed = true; - break; - default: - $isAllowed = false; - } - - return $isAllowed; - } } diff --git a/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php b/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php index 0487d7e46eb26..2405eaa9d37a3 100644 --- a/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php +++ b/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php @@ -45,6 +45,11 @@ class Collection extends \Magento\Framework\Model\ResourceModel\Db\VersionContro */ protected $_quoteConfig; + /** + * @var \Magento\Store\Model\StoreManagerInterface|null + */ + private $storeManager; + /** * @param \Magento\Framework\Data\Collection\EntityFactory $entityFactory * @param \Psr\Log\LoggerInterface $logger @@ -56,6 +61,7 @@ class Collection extends \Magento\Framework\Model\ResourceModel\Db\VersionContro * @param \Magento\Quote\Model\Quote\Config $quoteConfig * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection * @param \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource + * @param \Magento\Store\Model\StoreManagerInterface|null $storeManager * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -68,7 +74,8 @@ public function __construct( \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory, \Magento\Quote\Model\Quote\Config $quoteConfig, \Magento\Framework\DB\Adapter\AdapterInterface $connection = null, - \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource = null + \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource = null, + \Magento\Store\Model\StoreManagerInterface $storeManager = null ) { parent::__construct( $entityFactory, @@ -82,6 +89,10 @@ public function __construct( $this->_itemOptionCollectionFactory = $itemOptionCollectionFactory; $this->_productCollectionFactory = $productCollectionFactory; $this->_quoteConfig = $quoteConfig; + + // Backward compatibility constructor parameters + $this->storeManager = $storeManager ?: + \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Store\Model\StoreManagerInterface::class); } /** @@ -101,7 +112,10 @@ protected function _construct() */ public function getStoreId() { - return (int)$this->_productCollectionFactory->create()->getStoreId(); + // Fallback to current storeId if no quote is provided + // (see https://github.com/magento/magento2/commit/9d3be732a88884a66d667b443b3dc1655ddd0721) + return $this->_quote === null ? + (int) $this->storeManager->getStore()->getId() : (int) $this->_quote->getStoreId(); } /** diff --git a/app/code/Magento/Quote/Observer/Webapi/SubmitObserver.php b/app/code/Magento/Quote/Observer/SubmitObserver.php similarity index 94% rename from app/code/Magento/Quote/Observer/Webapi/SubmitObserver.php rename to app/code/Magento/Quote/Observer/SubmitObserver.php index 4f1e66dcc724b..1213636e5966b 100644 --- a/app/code/Magento/Quote/Observer/Webapi/SubmitObserver.php +++ b/app/code/Magento/Quote/Observer/SubmitObserver.php @@ -3,7 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -namespace Magento\Quote\Observer\Webapi; +namespace Magento\Quote\Observer; use Magento\Sales\Model\Order\Email\Sender\OrderSender; use Magento\Framework\Event\ObserverInterface; @@ -13,12 +13,12 @@ class SubmitObserver implements ObserverInterface /** * @var \Psr\Log\LoggerInterface */ - protected $logger; + private $logger; /** * @var OrderSender */ - protected $orderSender; + private $orderSender; /** * @param \Psr\Log\LoggerInterface $logger diff --git a/app/code/Magento/Quote/Setup/UpgradeSchema.php b/app/code/Magento/Quote/Setup/UpgradeSchema.php index 1bb20a669bdf2..1689bc55954e2 100644 --- a/app/code/Magento/Quote/Setup/UpgradeSchema.php +++ b/app/code/Magento/Quote/Setup/UpgradeSchema.php @@ -5,6 +5,7 @@ */ namespace Magento\Quote\Setup; +use Magento\Framework\DB\Ddl\Table; use Magento\Framework\Setup\UpgradeSchemaInterface; use Magento\Framework\Setup\ModuleContextInterface; use Magento\Framework\Setup\SchemaSetupInterface; @@ -25,7 +26,6 @@ class UpgradeSchema implements UpgradeSchemaInterface public function upgrade(SchemaSetupInterface $setup, ModuleContextInterface $context) { $setup->startSetup(); - if (version_compare($context->getVersion(), '2.0.1', '<')) { $setup->getConnection(self::$connectionName)->addIndex( $setup->getTable('quote_id_mask', self::$connectionName), @@ -33,14 +33,13 @@ public function upgrade(SchemaSetupInterface $setup, ModuleContextInterface $con ['masked_id'] ); } - if (version_compare($context->getVersion(), '2.0.2', '<')) { $setup->getConnection(self::$connectionName)->changeColumn( $setup->getTable('quote_address', self::$connectionName), 'street', 'street', [ - 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'type' => Table::TYPE_TEXT, 'length' => 255, 'comment' => 'Street' ] @@ -61,7 +60,7 @@ public function upgrade(SchemaSetupInterface $setup, ModuleContextInterface $con $setup->getTable('quote_address', self::$connectionName), 'shipping_method', [ - 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'type' => Table::TYPE_TEXT, 'length' => 120 ] ); @@ -72,33 +71,79 @@ public function upgrade(SchemaSetupInterface $setup, ModuleContextInterface $con $setup->getTable('quote_address', self::$connectionName), 'firstname', [ - 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'type' => Table::TYPE_TEXT, 'length' => 255, ] )->modifyColumn( $setup->getTable('quote_address', self::$connectionName), 'middlename', [ - 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'type' => Table::TYPE_TEXT, 'length' => 40, ] )->modifyColumn( $setup->getTable('quote_address', self::$connectionName), 'lastname', [ - 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'type' => Table::TYPE_TEXT, 'length' => 255, ] )->modifyColumn( $setup->getTable('quote', self::$connectionName), 'updated_at', [ - 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TIMESTAMP, + 'type' => Table::TYPE_TIMESTAMP, 'nullable' => false, - 'default' => \Magento\Framework\DB\Ddl\Table::TIMESTAMP_INIT_UPDATE, + 'default' => Table::TIMESTAMP_INIT_UPDATE, ] ); } + if (version_compare($context->getVersion(), '2.0.7', '<')) { + $this->expandQuoteAddressFields($setup); + } + if (version_compare($context->getVersion(), '2.0.8', '<')) { + $this->expandRemoteIpField($setup); + } $setup->endSetup(); } + + /** + * @param SchemaSetupInterface $setup + * @return void + */ + private function expandRemoteIpField(SchemaSetupInterface $setup) + { + $connection = $setup->getConnection(self::$connectionName); + $connection->modifyColumn( + $setup->getTable('quote', self::$connectionName), + 'remote_ip', + ['type' => Table::TYPE_TEXT, 'length' => 45] + ); + } + + /** + * @param SchemaSetupInterface $setup + * @return void + */ + private function expandQuoteAddressFields(SchemaSetupInterface $setup) + { + $connection = $setup->getConnection(self::$connectionName); + $connection->modifyColumn( + $setup->getTable('quote_address', self::$connectionName), + 'telephone', + ['type' => Table::TYPE_TEXT, 'length' => 255] + )->modifyColumn( + $setup->getTable('quote_address', self::$connectionName), + 'fax', + ['type' => Table::TYPE_TEXT, 'length' => 255] + )->modifyColumn( + $setup->getTable('quote_address', self::$connectionName), + 'region', + ['type' => Table::TYPE_TEXT, 'length' => 255] + )->modifyColumn( + $setup->getTable('quote_address', self::$connectionName), + 'city', + ['type' => Table::TYPE_TEXT, 'length' => 255] + ); + } } diff --git a/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php b/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php index 8143e0e417ead..3d6ef2dd2882c 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/PaymentMethodManagementTest.php @@ -151,8 +151,8 @@ public function testSetVirtualProduct() ->with($paymentMethod) ->willReturnSelf(); - $quoteMock->expects($this->exactly(2))->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->exactly(2))->method('isVirtual')->willReturn(true); + $quoteMock->method('getPayment')->willReturn($paymentMock); + $quoteMock->expects($this->once())->method('isVirtual')->willReturn(true); $quoteMock->expects($this->once())->method('getBillingAddress')->willReturn($billingAddressMock); $methodInstance = $this->getMockForAbstractClass(\Magento\Payment\Model\MethodInterface::class); @@ -164,7 +164,6 @@ public function testSetVirtualProduct() ->willReturn(true); $quoteMock->expects($this->once())->method('setTotalsCollectedFlag')->with(false)->willReturnSelf(); - $quoteMock->expects($this->once())->method('collectTotals')->willReturnSelf(); $quoteMock->expects($this->once())->method('save')->willReturnSelf(); $paymentMock->expects($this->once())->method('getId')->willReturn($paymentId); @@ -217,9 +216,9 @@ public function testSetVirtualProductThrowsExceptionIfPaymentMethodNotAvailable( ->with($paymentMethod) ->willReturnSelf(); - $quoteMock->expects($this->once())->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->exactly(2))->method('isVirtual')->willReturn(true); - $quoteMock->expects($this->once())->method('getBillingAddress')->willReturn($billingAddressMock); + $quoteMock->method('getPayment')->willReturn($paymentMock); + $quoteMock->method('isVirtual')->willReturn(true); + $quoteMock->method('getBillingAddress')->willReturn($billingAddressMock); $methodInstance = $this->getMockForAbstractClass(\Magento\Payment\Model\MethodInterface::class); $paymentMock->expects($this->once())->method('getMethodInstance')->willReturn($methodInstance); @@ -267,17 +266,20 @@ public function testSetSimpleProduct() $shippingAddressMock = $this->createPartialMock( \Magento\Quote\Model\Quote\Address::class, - ['getCountryId', 'setPaymentMethod'] + ['getCountryId', 'setPaymentMethod', 'setCollectShippingRates'] ); $shippingAddressMock->expects($this->once())->method('getCountryId')->willReturn(100); $shippingAddressMock->expects($this->once()) ->method('setPaymentMethod') ->with($paymentMethod) ->willReturnSelf(); + $shippingAddressMock->expects($this->once()) + ->method('setCollectShippingRates') + ->with(true); - $quoteMock->expects($this->exactly(2))->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->exactly(2))->method('isVirtual')->willReturn(false); - $quoteMock->expects($this->exactly(4))->method('getShippingAddress')->willReturn($shippingAddressMock); + $quoteMock->method('getPayment')->willReturn($paymentMock); + $quoteMock->method('isVirtual')->willReturn(false); + $quoteMock->method('getShippingAddress')->willReturn($shippingAddressMock); $methodInstance = $this->getMockForAbstractClass(\Magento\Payment\Model\MethodInterface::class); $paymentMock->expects($this->once())->method('getMethodInstance')->willReturn($methodInstance); @@ -288,7 +290,6 @@ public function testSetSimpleProduct() ->willReturn(true); $quoteMock->expects($this->once())->method('setTotalsCollectedFlag')->with(false)->willReturnSelf(); - $quoteMock->expects($this->once())->method('collectTotals')->willReturnSelf(); $quoteMock->expects($this->once())->method('save')->willReturnSelf(); $paymentMock->expects($this->once())->method('getId')->willReturn($paymentId); @@ -302,7 +303,6 @@ public function testSetSimpleProduct() public function testSetSimpleProductTrowsExceptionIfShippingAddressNotSet() { $cartId = 100; - $methodData = ['method' => 'data']; $quoteMock = $this->createPartialMock( \Magento\Quote\Model\Quote::class, @@ -310,6 +310,7 @@ public function testSetSimpleProductTrowsExceptionIfShippingAddressNotSet() ); $this->quoteRepositoryMock->expects($this->once())->method('get')->with($cartId)->willReturn($quoteMock); + /** @var \Magento\Quote\Model\Quote\Payment|\PHPUnit_Framework_MockObject_MockObject $methodMock */ $methodMock = $this->createPartialMock(\Magento\Quote\Model\Quote\Payment::class, ['setChecks', 'getData']); $methodMock->expects($this->once()) ->method('setChecks') @@ -320,17 +321,13 @@ public function testSetSimpleProductTrowsExceptionIfShippingAddressNotSet() \Magento\Payment\Model\Method\AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, ]) ->willReturnSelf(); - $methodMock->expects($this->once())->method('getData')->willReturn($methodData); - - $paymentMock = $this->createPartialMock(\Magento\Quote\Model\Quote\Payment::class, ['importData']); - $paymentMock->expects($this->once())->method('importData')->with($methodData)->willReturnSelf(); + $methodMock->expects($this->never())->method('getData'); $shippingAddressMock = $this->createPartialMock(\Magento\Quote\Model\Quote\Address::class, ['getCountryId']); $shippingAddressMock->expects($this->once())->method('getCountryId')->willReturn(null); - $quoteMock->expects($this->once())->method('getPayment')->willReturn($paymentMock); - $quoteMock->expects($this->once())->method('isVirtual')->willReturn(false); - $quoteMock->expects($this->once())->method('getShippingAddress')->willReturn($shippingAddressMock); + $quoteMock->method('isVirtual')->willReturn(false); + $quoteMock->method('getShippingAddress')->willReturn($shippingAddressMock); $this->model->set($cartId, $methodMock); } diff --git a/app/code/Magento/Quote/Test/Unit/Model/Quote/Validator/MinimumOrderAmount/ValidationMessageTest.php b/app/code/Magento/Quote/Test/Unit/Model/Quote/Validator/MinimumOrderAmount/ValidationMessageTest.php index 64204ea1fb93d..272a4e3a4ba49 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/Quote/Validator/MinimumOrderAmount/ValidationMessageTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/Quote/Validator/MinimumOrderAmount/ValidationMessageTest.php @@ -26,19 +26,27 @@ class ValidationMessageTest extends \PHPUnit\Framework\TestCase /** * @var \PHPUnit_Framework_MockObject_MockObject + * @deprecated since 101.0.0 */ private $currencyMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $priceHelperMock; + protected function setUp() { $this->scopeConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); $this->storeManagerMock = $this->createMock(\Magento\Store\Model\StoreManagerInterface::class); $this->currencyMock = $this->createMock(\Magento\Framework\Locale\CurrencyInterface::class); + $this->priceHelperMock = $this->createMock(\Magento\Framework\Pricing\Helper\Data::class); $this->model = new \Magento\Quote\Model\Quote\Validator\MinimumOrderAmount\ValidationMessage( $this->scopeConfigMock, $this->storeManagerMock, - $this->currencyMock + $this->currencyMock, + $this->priceHelperMock ); } @@ -46,8 +54,6 @@ public function testGetMessage() { $minimumAmount = 20; $minimumAmountCurrency = '$20'; - $currencyCode = 'currency_code'; - $this->scopeConfigMock->expects($this->at(0)) ->method('getValue') ->with('sales/minimum_order/description', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) @@ -58,27 +64,13 @@ public function testGetMessage() ->with('sales/minimum_order/amount', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) ->willReturn($minimumAmount); - $storeMock = $this->createPartialMock(\Magento\Store\Model\Store::class, ['getCurrentCurrencyCode']); - $storeMock->expects($this->once())->method('getCurrentCurrencyCode')->willReturn($currencyCode); - $this->storeManagerMock->expects($this->once())->method('getStore')->willReturn($storeMock); + $this->priceHelperMock->expects($this->once()) + ->method('currency') + ->with($minimumAmount, true, false) + ->will($this->returnValue($minimumAmountCurrency)); - $currencyMock = $this->createMock(\Magento\Framework\Currency::class); - $this->currencyMock->expects($this->once()) - ->method('getCurrency') - ->with($currencyCode) - ->willReturn($currencyMock); - - $currencyMock->expects($this->once()) - ->method('toCurrency') - ->with($minimumAmount) - ->willReturn($minimumAmountCurrency); - - $this->assertEquals( - __('Minimum order amount is %1', $minimumAmountCurrency), - $this->model->getMessage() - ); + $this->assertEquals(__('Minimum order amount is %1', $minimumAmountCurrency), $this->model->getMessage()); } - public function testGetConfigMessage() { $configMessage = 'config_message'; diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php index f330ebda17317..043e04319362d 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php @@ -3,9 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Quote\Test\Unit\Model\QuoteRepository\Plugin; use Magento\Authorization\Model\UserContextInterface; +use Magento\Quote\Model\ChangeQuoteControl; use Magento\Quote\Model\QuoteRepository\Plugin\AccessChangeQuoteControl; use Magento\Quote\Model\Quote; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; @@ -34,6 +36,11 @@ class AccessChangeQuoteControlTest extends \PHPUnit\Framework\TestCase */ private $quoteRepositoryMock; + /** + * @var ChangeQuoteControl|MockObject + */ + private $changeQuoteControlMock; + protected function setUp() { $this->userContextMock = $this->getMockBuilder(UserContextInterface::class) @@ -50,15 +57,19 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->changeQuoteControlMock = $this->getMockBuilder(ChangeQuoteControl::class) + ->disableOriginalConstructor() + ->getMock(); + $objectManagerHelper = new ObjectManager($this); $this->accessChangeQuoteControl = $objectManagerHelper->getObject( AccessChangeQuoteControl::class, - ['userContext' => $this->userContextMock] + ['changeQuoteControl' => $this->changeQuoteControlMock] ); } /** - * User with role Customer and customer_id much with context user_id. + * User with role Customer and customer_id matches context user_id. */ public function testBeforeSaveForCustomer() { @@ -68,6 +79,9 @@ public function testBeforeSaveForCustomer() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -81,11 +95,15 @@ public function testBeforeSaveForCustomer() */ public function testBeforeSaveException() { - $this->userContextMock->method('getUserType') - ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); $this->quoteMock->method('getCustomerId') ->willReturn(2); + $this->userContextMock->method('getUserType') + ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); + + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } @@ -100,6 +118,9 @@ public function testBeforeSaveForAdmin() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_ADMIN); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -116,6 +137,9 @@ public function testBeforeSaveForGuest() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_GUEST); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -135,6 +159,9 @@ public function testBeforeSaveForGuestException() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_GUEST); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } @@ -152,6 +179,9 @@ public function testBeforeSaveForUnknownUserTypeException() $this->userContextMock->method('getUserType') ->willReturn(10); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } } diff --git a/app/code/Magento/Quote/Test/Unit/Observer/Webapi/SubmitObserverTest.php b/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php similarity index 95% rename from app/code/Magento/Quote/Test/Unit/Observer/Webapi/SubmitObserverTest.php rename to app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php index 618a633fd62e0..c19606a7b8f5d 100644 --- a/app/code/Magento/Quote/Test/Unit/Observer/Webapi/SubmitObserverTest.php +++ b/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php @@ -3,12 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -namespace Magento\Quote\Test\Unit\Observer\Webapi; +namespace Magento\Quote\Test\Unit\Observer; class SubmitObserverTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Quote\Observer\Webapi\SubmitObserver + * @var \Magento\Quote\Observer\SubmitObserver */ protected $model; @@ -59,7 +59,7 @@ protected function setUp() $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\Webapi\SubmitObserver( + $this->model = new \Magento\Quote\Observer\SubmitObserver( $this->loggerMock, $this->orderSenderMock ); diff --git a/app/code/Magento/Quote/composer.json b/app/code/Magento/Quote/composer.json index 31f875a0f9a35..e6bd1d668fe27 100644 --- a/app/code/Magento/Quote/composer.json +++ b/app/code/Magento/Quote/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-quote", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-store": "100.2.*", "magento/module-catalog": "102.0.*", "magento/module-customer": "101.0.*", @@ -23,7 +23,7 @@ "magento/module-webapi": "100.2.*" }, "type": "magento2-module", - "version": "101.0.2", + "version": "101.0.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Quote/etc/di.xml b/app/code/Magento/Quote/etc/di.xml index 674e0eea46e97..aa6fafb5dd051 100644 --- a/app/code/Magento/Quote/etc/di.xml +++ b/app/code/Magento/Quote/etc/di.xml @@ -22,6 +22,7 @@ + diff --git a/app/code/Magento/Quote/etc/frontend/events.xml b/app/code/Magento/Quote/etc/frontend/events.xml new file mode 100644 index 0000000000000..1e9822bbf3ef8 --- /dev/null +++ b/app/code/Magento/Quote/etc/frontend/events.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/code/Magento/Quote/etc/module.xml b/app/code/Magento/Quote/etc/module.xml index f682568e63d02..6720a77a79e15 100644 --- a/app/code/Magento/Quote/etc/module.xml +++ b/app/code/Magento/Quote/etc/module.xml @@ -6,6 +6,6 @@ */ --> - + diff --git a/app/code/Magento/Quote/etc/webapi_rest/events.xml b/app/code/Magento/Quote/etc/webapi_rest/events.xml index 7b94434f3f20a..1e9822bbf3ef8 100644 --- a/app/code/Magento/Quote/etc/webapi_rest/events.xml +++ b/app/code/Magento/Quote/etc/webapi_rest/events.xml @@ -7,6 +7,6 @@ --> - + diff --git a/app/code/Magento/Quote/etc/webapi_soap/events.xml b/app/code/Magento/Quote/etc/webapi_soap/events.xml index 7b94434f3f20a..1e9822bbf3ef8 100644 --- a/app/code/Magento/Quote/etc/webapi_soap/events.xml +++ b/app/code/Magento/Quote/etc/webapi_soap/events.xml @@ -7,6 +7,6 @@ --> - + diff --git a/app/code/Magento/QuoteAnalytics/composer.json b/app/code/Magento/QuoteAnalytics/composer.json index c75abc5bb5da2..4a417efd15e31 100644 --- a/app/code/Magento/QuoteAnalytics/composer.json +++ b/app/code/Magento/QuoteAnalytics/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-quote-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/framework": "101.0.*", "magento/module-quote": "101.0.*" }, diff --git a/app/code/Magento/ReleaseNotification/composer.json b/app/code/Magento/ReleaseNotification/composer.json index 40e9e02db9217..08fb44e1aac48 100644 --- a/app/code/Magento/ReleaseNotification/composer.json +++ b/app/code/Magento/ReleaseNotification/composer.json @@ -2,13 +2,13 @@ "name": "magento/module-release-notification", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.0.13|~7.1.0", "magento/module-user": "101.0.*", "magento/module-backend": "100.2.*", "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.0", + "version": "100.2.1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ReleaseNotification/i18n/en_US.csv b/app/code/Magento/ReleaseNotification/i18n/en_US.csv index 4a3cd02782b9c..7af356627b1ea 100644 --- a/app/code/Magento/ReleaseNotification/i18n/en_US.csv +++ b/app/code/Magento/ReleaseNotification/i18n/en_US.csv @@ -1,112 +1,7 @@ "Next >","Next >" "< Back","< Back" "Done","Done" -"What's new with Magento 2.2.2","What's new with Magento 2.2.2" -"

Magento 2.2.2 offers advanced new features, including:

-
-
-

Advanced Reporting

-

Gain valuable insights through a dynamic suite of product, order, and customer reports, - powered by Magento Business Intelligence.

-
-
-

Instant Purchase

-

Simplify ordering and boost conversion rates by allowing your customers to use stored - payment and shipping information to skip tedious checkout steps.

-
-
-

Email Marketing Automation

-

Send smarter, faster email campaigns with marketing automation from dotmailer, powered by - your Magento store's live data.

-
-

Release notes and additional details can be found at - Magento DevDocs. -

","

Magento 2.2.2 offers advanced new features, including:

-
-
-

Advanced Reporting

-

Gain valuable insights through a dynamic suite of product, order, and customer reports, - powered by Magento Business Intelligence.

-
-
-

Instant Purchase

-

Simplify ordering and boost conversion rates by allowing your customers to use stored - payment and shipping information to skip tedious checkout steps.

-
-
-

Email Marketing Automation

-

Send smarter, faster email campaigns with marketing automation from dotmailer, powered by - your Magento store's live data.

-
-

Release notes and additional details can be found at - Magento DevDocs. -

" -"Advanced Reporting","Advanced Reporting" -"

Advanced Reporting - provides you with a dynamic suite of reports with rich insights about the health of your - business.


As part of the Advanced Reporting service, we may also use your customer - data for such purposes as benchmarking, improving our products and services, and providing you - with new and improved analytics.


By using Magento 2.2.2, you agree to the Advanced - Reporting Privacy Policy and - Terms - of Service. You may opt out at any time from the Stores Configuration page.

- ","

Advanced Reporting - provides you with a dynamic suite of reports with rich insights about the health of your - business.


As part of the Advanced Reporting service, we may also use your customer - data for such purposes as benchmarking, improving our products and services, and providing you - with new and improved analytics.


By using Magento 2.2.2, you agree to the Advanced - Reporting Privacy Policy and - Terms - of Service. You may opt out at any time from the Stores Configuration page.

" -"Instant Purchase","Instant Purchase" -"

Now you can deliver an Amazon-like experience with a new, streamlined checkout option. - Logged-in customers can use previously-stored payment credentials and shipping information - to skip steps, making the process faster and easier, especially for mobile shoppers. Key - features include: -

-
    -
  • Configurable “Instant Purchase” button to place orders.
  • -
  • Support for all payment solutions using Braintree Vault, including Braintree Credit - Card, Braintree PayPal, and PayPal Payflow Pro.
  • -
  • Shipping to the customer’s default address using the lowest cost, available shipping - method.
  • -
  • Ability for developers to customize the Instant Purchase business logic to meet - merchant needs.
  • -
","

Now you can deliver an Amazon-like experience with a new, streamlined checkout option. - Logged-in customers can use previously-stored payment credentials and shipping information - to skip steps, making the process faster and easier, especially for mobile shoppers. Key - features include: -

-
    -
  • Configurable “Instant Purchase” button to place orders.
  • -
  • Support for all payment solutions using Braintree Vault, including Braintree Credit - Card, Braintree PayPal, and PayPal Payflow Pro.
  • -
  • Shipping to the customer’s default address using the lowest cost, available shipping - method.
  • -
  • Ability for developers to customize the Instant Purchase business logic to meet - merchant needs.
  • -
" -"Email Marketing Automation","Email Marketing Automation" -"

Unlock an unparalleled level of insight and control of your eCommerce marketing with - dotmailer Email Marketing Automation. Included with Magento 2.2.2 for easy set-up, dotmailer - ensures every customer’s journey is captured, segmented, and personalized, enabling you to - deliver customer-centric campaigns that beat your results over and over again. Benefits include: -

-
    -
  • No obligation 14-day trial.
  • -
  • Automation campaigns using your live Magento store data that drive revenue, - including abandoned cart, abandoned browse, product replenishment, and many more
  • -
  • Built-in solution for transactional emails.
  • -
  • Telephone support and advice from marketing experts included.
  • -
","

Unlock an unparalleled level of insight and control of your eCommerce marketing with - dotmailer Email Marketing Automation. Included with Magento 2.2.2 for easy set-up, dotmailer - ensures every customer’s journey is captured, segmented, and personalized, enabling you to - deliver customer-centric campaigns that beat your results over and over again. Benefits include: -

-
    -
  • No obligation 14-day trial.
  • -
  • Automation campaigns using your live Magento store data that drive revenue, - including abandoned cart, abandoned browse, product replenishment, and many more
  • -
  • Built-in solution for transactional emails.
  • -
  • Telephone support and advice from marketing experts included.
  • -
" +"Flexible Payment Terms","Flexible Payment Terms" +"Trusted Payment Options","Trusted Payment Options" +"Sales and Use Tax Automation","Sales and Use Tax Automation" +"What’s new with Magento 2.2.4","What’s new with Magento 2.2.4" diff --git a/app/code/Magento/ReleaseNotification/view/adminhtml/ui_component/release_notification.xml b/app/code/Magento/ReleaseNotification/view/adminhtml/ui_component/release_notification.xml index 0364750d56a38..eecd8612e2c7a 100644 --- a/app/code/Magento/ReleaseNotification/view/adminhtml/ui_component/release_notification.xml +++ b/app/code/Magento/ReleaseNotification/view/adminhtml/ui_component/release_notification.xml @@ -43,7 +43,7 @@ true - + @@ -60,26 +60,21 @@ release-notification-text Magento 2.2.2 offers advanced new features, including:

+

Magento 2.2.4 provides powerful new tools to help reduce cart abandonment and simplify store operations:


-
-

Advanced Reporting

-

Gain valuable insights through a dynamic suite of product, order, and customer reports, - powered by Magento Business Intelligence.

+
+

Trusted Payment Options

+

Online or on the go, your shoppers can easily purchase in a familiar, trusted way with Amazon Pay.

-
-

Instant Purchase

-

Simplify ordering and boost conversion rates by allowing your customers to use stored - payment and shipping information to skip tedious checkout steps.

+
+

Flexible Payment Terms

+

Increase sales by providing shoppers with the flexibility to pay now, later, or in installments using Klarna’s integrated payment option.

-